diff --git a/.agents/skills/crabbox/SKILL.md b/.agents/skills/crabbox/SKILL.md index 8ad98e7dedf0..fc9ac13d89ef 100644 --- a/.agents/skills/crabbox/SKILL.md +++ b/.agents/skills/crabbox/SKILL.md @@ -54,6 +54,13 @@ pnpm crabbox:run -- --help | sed -n '1,120p' - For broad OpenClaw maintainer `pnpm` gates, prefer the repo wrapper with `--provider blacksmith-testbox` or the repo Testbox helpers when the standing Testbox policy applies. +- Cold Testbox acquisition and hydration often take tens of seconds. When broad + remote proof is likely, immediately start + `node scripts/crabbox-wrapper.mjs warmup --provider blacksmith-testbox --keep --timing-json` + in a background command session while inspecting, editing, and running + focused local tests. Poll later, reuse the returned `tbx_...` with + `--provider blacksmith-testbox --id `, and stop it before handoff. + Do not warm speculatively when remote proof is unlikely. - Always report the actual provider and id. `cbx_...` means AWS Crabbox; `tbx_...` means Blacksmith Testbox through Crabbox. If the output only says `blacksmith testbox list`, use `blacksmith testbox list --all` before diff --git a/.agents/skills/discord-user-post/SKILL.md b/.agents/skills/discord-user-post/SKILL.md new file mode 100644 index 000000000000..480f4690f039 --- /dev/null +++ b/.agents/skills/discord-user-post/SKILL.md @@ -0,0 +1,51 @@ +--- +name: discord-user-post +description: Post an approved message as the logged-in Discord user through the Discord desktop app. Use for release announcements or other direct user-authored Discord posts; not for OpenClaw channel sends, bots, webhooks, relays, agent sessions, or archive search. +--- + +# Discord User Post + +Use `$computer-use` to operate `/Applications/Discord.app` in the user's +existing logged-in session. This workflow represents the user directly. + +## Prepare + +1. Draft the complete final message outside Discord. +2. Confirm the intended server and channel with the user when either is + ambiguous. +3. Open Discord and navigate to the exact destination without entering the + message. +4. Verify the visible server name, channel header, and logged-in account. + +Do not infer the target from unrelated Discord content. Stop if Discord is not +logged in, the account is wrong, or the exact destination cannot be verified. + +## Confirm and Post + +Posting is representational communication. Follow the `$computer-use` +confirmation policy even when the user previously asked for an announcement: + +1. Show the user the exact final body and verified destination. +2. Request action-time confirmation before typing into Discord. +3. After confirmation, enter the approved body unchanged. +4. Visually inspect the composed message and destination again. +5. Send once. + +If the body or destination changes after confirmation, request confirmation +again before sending. + +## Verify + +- Confirm the message appears once, from the user's account, in the intended + channel. +- Report the server, channel, and visible send result. +- Do not edit, delete, react, or send a follow-up without the corresponding + user instruction and confirmation. + +## Guardrails + +- Never use `openclaw message`, an OpenClaw agent, a Discord bot, webhook, relay, + or token for this workflow. +- Never expose private Discord content or account details in public output. +- Never send a draft, partial message, duplicate, or unreviewed attachment. +- For Discord archive/history/search, use `$discrawl` instead. diff --git a/.agents/skills/discord-user-post/agents/openai.yaml b/.agents/skills/discord-user-post/agents/openai.yaml new file mode 100644 index 000000000000..abc856abe684 --- /dev/null +++ b/.agents/skills/discord-user-post/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Discord User Post" + short_description: "Post approved messages through the logged-in Discord app" + default_prompt: "Post this approved message as me through the logged-in Discord desktop app." diff --git a/.agents/skills/openclaw-changelog-update/SKILL.md b/.agents/skills/openclaw-changelog-update/SKILL.md index 3802b0d133c7..dc41ba63ad84 100644 --- a/.agents/skills/openclaw-changelog-update/SKILL.md +++ b/.agents/skills/openclaw-changelog-update/SKILL.md @@ -19,7 +19,7 @@ attribution. ## Inputs -- Target base version: `YYYY.M.D`, without beta suffix. +- Target base version: `YYYY.M.PATCH`, without beta suffix. - Base tag: last reachable shipped release tag, usually the previous stable or the previous beta train requested by the operator. - Target ref: exact branch/SHA being released. @@ -37,7 +37,7 @@ attribution. 3. Read linked PRs/issues or diffs for ambiguous commits. Direct commits matter; infer notes from subject, body, touched files, tests, and nearby commits. 4. Rewrite one stable-base section only: - - use `## YYYY.M.D` + - use `## YYYY.M.PATCH` - do not create beta-specific headings - do not leave a stale `## Unreleased` section above the target release - if `Unreleased` contains release-bound notes, fold them into the target @@ -93,7 +93,7 @@ attribution. 10. Validate and ship: - `git diff --check` - for docs/changelog-only changes, no broad tests are required - - commit with `scripts/committer "docs(changelog): refresh YYYY.M.D notes" CHANGELOG.md` + - commit with `scripts/committer "docs(changelog): refresh YYYY.M.PATCH notes" CHANGELOG.md` - push, pull/rebase if needed, then branch/rebase release from latest `main` ## Quota / API Outage Rule diff --git a/.agents/skills/openclaw-qa-testing/SKILL.md b/.agents/skills/openclaw-qa-testing/SKILL.md index 2d21cf6059cd..01f1d5ee3dc3 100644 --- a/.agents/skills/openclaw-qa-testing/SKILL.md +++ b/.agents/skills/openclaw-qa-testing/SKILL.md @@ -13,7 +13,7 @@ Use this skill for `qa-lab` / `qa-channel` work. Repo-local QA only. - `docs/help/testing.md` - `docs/channels/qa-channel.md` - `qa/README.md` -- `qa/scenarios/index.md` +- `qa/scenarios/index.yaml` - `extensions/qa-lab/src/suite.ts` - `extensions/qa-lab/src/character-eval.ts` @@ -198,7 +198,9 @@ pnpm openclaw qa character-eval \ - Judges default to `openai/gpt-5.4,thinking=xhigh,fast` and `anthropic/claude-opus-4-6,thinking=high`. - Report includes judge ranking, run stats, durations, and full transcripts; do not include raw judge replies. Duration is benchmark context, not a grading signal. - Candidate and judge concurrency default to 16. Use `--concurrency ` and `--judge-concurrency ` to override when local gateways or provider limits need a gentler lane. -- Scenario source should stay markdown-driven under `qa/scenarios/`. +- Scenario source is YAML-only under `qa/scenarios/`: use `index.yaml` and + per-scenario `*.yaml` files with top-level `title`, `scenario`, and optional + `flow`. Never add fenced `qa-scenario` / `qa-flow` Markdown files. - For isolated character/persona evals, write the persona into `SOUL.md` and blank `IDENTITY.md` in the scenario flow. Use `SOUL.md + IDENTITY.md` only when intentionally testing how the normal OpenClaw identity combines with the character. - Keep prompts natural and task-shaped. The candidate model should receive character setup through `SOUL.md`, then normal user turns such as chat, workspace help, and small file tasks; do not ask "how would you react?" or tell the model it is in an eval. - Prefer at least one real task, such as creating or editing a tiny workspace artifact, so the transcript captures character under normal tool use instead of pure roleplay. @@ -234,7 +236,8 @@ pnpm openclaw qa manual \ ## Repo facts -- Seed scenarios live in `qa/`. +- Seed scenarios live in `qa/scenarios/index.yaml` and + `qa/scenarios//*.yaml`. - Main live runner: `extensions/qa-lab/src/suite.ts` - QA lab server: `extensions/qa-lab/src/lab-server.ts` - Child gateway harness: `extensions/qa-lab/src/gateway-child.ts` @@ -262,8 +265,9 @@ pnpm openclaw qa manual \ ## When adding scenarios -- Add or update scenario markdown under `qa/scenarios/` -- Keep kickoff expectations in `qa/scenarios/index.md` aligned +- Add or update scenario YAML under `qa/scenarios/`; do not add `.md` scenario + files or fenced YAML blocks. +- Keep kickoff expectations in `qa/scenarios/index.yaml` aligned - Add executable coverage in `extensions/qa-lab/src/suite.ts` - Prefer end-to-end assertions over mock-only checks - Save outputs under `.artifacts/qa-e2e/` diff --git a/.agents/skills/release-openclaw-announcement/SKILL.md b/.agents/skills/release-openclaw-announcement/SKILL.md index 8f03255881a8..d06f2d5ef7a5 100644 --- a/.agents/skills/release-openclaw-announcement/SKILL.md +++ b/.agents/skills/release-openclaw-announcement/SKILL.md @@ -6,7 +6,8 @@ description: "Draft or post OpenClaw beta/stable Discord release announcements f # OpenClaw Release Announcement Use with `release-openclaw-maintainer` after a beta or stable release is live. -Use with `openclaw-discord` when actually posting to Discord. +Use with `$discord-user-post` when actually posting to Discord as the logged-in +user. ## Evidence First @@ -80,6 +81,7 @@ Fresh installs still point to `https://openclaw.ai`. ## Posting -When asked to post, use the configured Discord workflow from -`openclaw-discord` or the approved OpenClaw relay. Never print tokens. -For public channels, inspect the final body before sending. +When asked to post, use `$discord-user-post` to operate the logged-in Discord +desktop app as the user. Resolve and visibly verify the exact server/channel, +inspect the final body, and request action-time confirmation before entering or +sending it. Never use OpenClaw channel sends, bots, webhooks, relays, or tokens. diff --git a/.agents/skills/release-openclaw-mac/SKILL.md b/.agents/skills/release-openclaw-mac/SKILL.md index 496a4ae7cea5..ce345b1c58e7 100644 --- a/.agents/skills/release-openclaw-mac/SKILL.md +++ b/.agents/skills/release-openclaw-mac/SKILL.md @@ -36,8 +36,8 @@ Do not update these from mixed sources. All three ASC fields must come from the ## Workflow Shape - Public release branch may carry mac-only packaging fixes after the stable tag/npm are already live. -- Use `source_ref=release/YYYY.M.D` for private mac preflight/validation when building that branch variation. -- Keep `tag=vYYYY.M.D` pointing at the original stable release commit. +- Use `source_ref=release/YYYY.M.PATCH` for private mac preflight/validation when building that branch variation. +- Keep `tag=vYYYY.M.PATCH` pointing at the original stable release commit. - Real mac publish must reuse: - a successful private mac preflight run for the same tag/source SHA - a successful private mac validation run for the same tag/source SHA @@ -56,37 +56,37 @@ Private preflight: ```bash gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \ - -f tag=vYYYY.M.D \ - -f source_ref=release/YYYY.M.D \ + -f tag=vYYYY.M.PATCH \ + -f source_ref=release/YYYY.M.PATCH \ -f preflight_only=true \ -f smoke_test_only=false \ -f allow_late_calver_recovery=false \ - -f public_release_branch=release/YYYY.M.D + -f public_release_branch=release/YYYY.M.PATCH ``` Private validation for a branch-variation preflight: ```bash gh workflow run openclaw-macos-validate.yml --repo openclaw/releases-private --ref main \ - -f tag=vYYYY.M.D \ - -f source_ref=release/YYYY.M.D + -f tag=vYYYY.M.PATCH \ + -f source_ref=release/YYYY.M.PATCH ``` Real publish: ```bash gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \ - -f tag=vYYYY.M.D \ + -f tag=vYYYY.M.PATCH \ -f preflight_only=false \ -f smoke_test_only=false \ -f preflight_run_id= \ -f validate_run_id= \ -f allow_late_calver_recovery=false \ - -f public_release_branch=release/YYYY.M.D + -f public_release_branch=release/YYYY.M.PATCH ``` ## Verify -- `gh release view vYYYY.M.D --repo openclaw/openclaw` shows zip, dmg, dSYM zip, not draft, not prerelease. -- Public `main` `appcast.xml` points at `OpenClaw-YYYY.M.D.zip`. +- `gh release view vYYYY.M.PATCH --repo openclaw/openclaw` shows zip, dmg, dSYM zip, not draft, not prerelease. +- Public `main` `appcast.xml` points at `OpenClaw-YYYY.M.PATCH.zip`. - Appcast entry has `sparkle:version`, `sparkle:shortVersionString`, length, and `sparkle:edSignature`. diff --git a/.agents/skills/release-openclaw-maintainer/SKILL.md b/.agents/skills/release-openclaw-maintainer/SKILL.md index 661c7695a67e..6e84a798f51e 100644 --- a/.agents/skills/release-openclaw-maintainer/SKILL.md +++ b/.agents/skills/release-openclaw-maintainer/SKILL.md @@ -10,12 +10,15 @@ Use this skill for release and publish-time workflow. Load `$release-private` if ## Respect release guardrails - Do not change version numbers without explicit operator approval. +- Versions use `YYYY.M.PATCH`, where `PATCH` is the sequential release-train number within the month, not the calendar day. +- Choose a new beta train from stable and beta releases only. Alpha-only tags do not consume or advance the beta/stable patch number. Continue the highest existing unpublished/published beta train with the next `beta.N` when appropriate; otherwise increment the highest stable/beta patch by one and start at `beta.1`. +- Example: after stable `2026.6.5`, the next new beta train is `2026.6.6-beta.1`, even if automated alpha-only tags such as `2026.6.10-alpha.1` exist. - Ask permission before any npm publish or release step. - This skill should be sufficient to drive the normal release flow end-to-end. - Use the private maintainer release docs for credentials, recovery steps, and mac signing/notary specifics, and use `docs/reference/RELEASING.md` for public policy. - Core `openclaw` publish is manual `workflow_dispatch`; creating or pushing a tag does not publish by itself. - Normal release work happens on a branch cut from `main`, not directly on - `main`. Use `release/YYYY.M.D` for the branch name. + `main`. Use `release/YYYY.M.PATCH` for the branch name. - If the operator asks for a release without saying stable/full, default to beta only. Continue from beta to stable only when the operator explicitly asks for the full release or an automated beta-and-stable train. @@ -92,7 +95,7 @@ Use this skill for release and publish-time workflow. Load `$release-private` if ## Keep release channel naming aligned - `stable`: tagged releases only, published to npm `beta` by default; operators may target npm `latest` explicitly or promote later -- `beta`: prerelease tags like `vYYYY.M.D-beta.N`, with npm dist-tag `beta` +- `beta`: prerelease tags like `vYYYY.M.PATCH-beta.N`, with npm dist-tag `beta` - Prefer `-beta.N`; do not mint new `-1` or `-2` beta suffixes - `dev`: moving head on `main` - When using a beta Git tag, publish npm with the matching beta version suffix so the plain version is not consumed or blocked @@ -108,7 +111,7 @@ Use this skill for release and publish-time workflow. Load `$release-private` if - `docs/install/updating.md` - Peekaboo Xcode project and plist version fields - Before creating a release tag, make every version location above match the version encoded by that tag. -- For fallback correction tags like `vYYYY.M.D-N`, the repo version locations still stay at `YYYY.M.D`. +- For fallback correction tags like `vYYYY.M.PATCH-N`, the repo version locations still stay at `YYYY.M.PATCH`. - “Bump version everywhere” means all version locations above except `appcast.xml`. - Release signing and notary credentials live outside the repo in the private maintainer docs. - Every stable OpenClaw release ships the npm package, macOS app, and signed @@ -129,29 +132,41 @@ Use this skill for release and publish-time workflow. Load `$release-private` if tagged commit when the delta is mac packaging, signing, workflow, or validation-only release machinery. If mac packaging needs release-branch-only fixes after the stable npm package or GitHub tag is already published, do not - create a `vYYYY.M.D-N` correction tag just to change the workflow source. - Dispatch the private mac workflows for the original `tag=vYYYY.M.D` with - `source_ref=release/YYYY.M.D` and `public_release_branch=release/YYYY.M.D`; + create a `vYYYY.M.PATCH-N` correction tag just to change the workflow source. + Dispatch the private mac workflows for the original `tag=vYYYY.M.PATCH` with + `source_ref=release/YYYY.M.PATCH` and `public_release_branch=release/YYYY.M.PATCH`; provenance checks must prove the source SHA descends from the tag and - validation/preflight use the same source. Reserve `vYYYY.M.D-N` correction + validation/preflight use the same source. Reserve `vYYYY.M.PATCH-N` correction tags for emergency hotfixes that must publish a new npm package/release identity, not for ordinary mac-only packaging recovery. - The production Sparkle feed lives at `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`, and the canonical published file is `appcast.xml` on `main` in the `openclaw` repo. - That shared production Sparkle feed is stable-only. Beta mac releases may upload assets to the GitHub prerelease, but they must not replace the shared `appcast.xml` unless a separate beta feed exists. -- For fallback correction tags like `vYYYY.M.D-N`, the repo version still stays - at `YYYY.M.D`, but the mac release must use a strictly higher numeric +- For fallback correction tags like `vYYYY.M.PATCH-N`, the repo version still stays + at `YYYY.M.PATCH`, but the mac release must use a strictly higher numeric `APP_BUILD` / Sparkle build than the original release so existing installs see it as newer. - Stable Windows Hub release closeout requires the signed `OpenClawCompanion-Setup-x64.exe`, `OpenClawCompanion-Setup-arm64.exe`, and `OpenClawCompanion-SHA256SUMS.txt` assets on the canonical - `openclaw/openclaw` GitHub Release. Use the public `Windows Node Release` - workflow after the matching `openclaw/openclaw-windows-node` release exists; - it verifies Authenticode signatures on Windows before uploading assets. + `openclaw/openclaw` GitHub Release. Pass the exact signed + `openclaw/openclaw-windows-node` release tag as `windows_node_tag` to + `OpenClaw Release Publish`, together with the candidate-approved + `windows_node_installer_digests` map; it prevalidates the published source + release and required installers against that map before any publish child, + dispatches the public `Windows Node Release` workflow while the OpenClaw + release is still a draft, carries those pinned source asset digests + unchanged, verifies the expected OpenClaw Foundation Authenticode signer on + Windows, re-downloads and checksum-verifies the promoted asset contract, and + blocks publication until the canonical asset contract is present. Use direct + `Windows Node Release` dispatch only for recovery, always with an exact tag, + never `latest`, and the explicit `expected_installer_digests` JSON map from + the approved source release. Recovery rejects unexpected + `OpenClawCompanion-*` target asset names, then replaces the expected contract + assets with the pinned source bytes. - Website Windows Hub download links should target exact canonical - `openclaw/openclaw/releases/download/vYYYY.M.D/...` assets for the current + `openclaw/openclaw/releases/download/vYYYY.M.PATCH/...` assets for the current stable release, or `releases/latest/download/...` only after verifying the redirect resolves to that same tag, so the installable signed Windows artifact is visible from both the GitHub release page and openclaw.ai. @@ -165,7 +180,7 @@ Use this skill for release and publish-time workflow. Load `$release-private` if beta release tag as the base, then inspect every commit through the target release SHA. - The changelog rewrite is not optional for beta reruns: any `beta.N` after a - rebase or backport must refresh the same stable-base `## YYYY.M.D` section + rebase or backport must refresh the same stable-base `## YYYY.M.PATCH` section before the new version/tag commit. - Include both merged PR commits and direct commits on `main`. Direct commits matter: infer notes from their subject, body, touched files, linked issues, @@ -188,11 +203,11 @@ Use this skill for release and publish-time workflow. Load `$release-private` if - Changelog entries should be user-facing, not internal release-process notes. - GitHub release and prerelease bodies must use the full matching `CHANGELOG.md` version section, not highlights or an excerpt. When creating - or editing a release, extract from `## YYYY.M.D` through the line before the + or editing a release, extract from `## YYYY.M.PATCH` through the line before the next level-2 heading and use that complete block as the release notes. - To update an existing GitHub Release body, resolve the numeric release id and patch that resource with the notes file as the `body` field: - `gh api repos/openclaw/openclaw/releases/tags/vYYYY.M.D --jq .id`, then + `gh api repos/openclaw/openclaw/releases/tags/vYYYY.M.PATCH --jq .id`, then `gh api -X PATCH repos/openclaw/openclaw/releases/ -F body=@/tmp/notes.md`. Do not trust `gh release edit --notes-file` or `--input` JSON if verification disagrees; verify with `gh api repos/openclaw/openclaw/releases/` because @@ -205,10 +220,10 @@ Use this skill for release and publish-time workflow. Load `$release-private` if record's `docsPath` or `/plugins/compatibility` when no more specific deprecation page exists. - When cutting a mac release with a beta GitHub prerelease: - - tag `vYYYY.M.D-beta.N` from the release commit - - create a prerelease titled `openclaw YYYY.M.D-beta.N` + - tag `vYYYY.M.PATCH-beta.N` from the release commit + - create a prerelease titled `openclaw YYYY.M.PATCH-beta.N` - use release notes from the stable base `CHANGELOG.md` version section - (`## YYYY.M.D`), not a beta-specific heading + (`## YYYY.M.PATCH`), not a beta-specific heading - attach at least the zip and dSYM zip, plus dmg if available - Keep the top version entries in `CHANGELOG.md` sorted by impact: - `### Changes` first @@ -218,10 +233,10 @@ Use this skill for release and publish-time workflow. Load `$release-private` if Use the OpenClaw account's existing release-post style: -- Format: `OpenClaw YYYY.M.D 🦞` or `🦞 OpenClaw YYYY.M.D is live`, blank line, +- Format: `OpenClaw YYYY.M.PATCH 🦞` or `🦞 OpenClaw YYYY.M.PATCH is live`, blank line, then 3-4 emoji-led bullets, blank line, one short punchline, then the release link. -- For beta: say `OpenClaw YYYY.M.D-beta.N 🦞` or `OpenClaw YYYY.M.D beta N is +- For beta: say `OpenClaw YYYY.M.PATCH-beta.N 🦞` or `OpenClaw YYYY.M.PATCH beta N is live`; keep it clearly beta and avoid implying stable promotion. - Lead with user-visible capabilities, then important integrations, then reliability/security/install fixes. Compress "lots of fixes" into one @@ -306,6 +321,7 @@ Upgrade with the beta channel. Before tagging or publishing, run: ```bash +pnpm release:fast-pretag-check pnpm check:architecture pnpm build pnpm ui:build @@ -314,6 +330,38 @@ pnpm release:check pnpm test:install:smoke ``` +- Treat `pnpm release:fast-pretag-check` as a hard packaging gate. Every + publishable plugin must have a non-empty package-root `README.md`, build its + package-local runtime, and pass the npm and ClawHub release metadata checks + before a tag or publish workflow can start. Do not defer README, entrypoint, + or packed-artifact failures to postpublish verification. +- Before tagging, require green CI for the exact release-candidate SHA, not an + earlier branch SHA. Heal every related red CI, release-check, packaging, or + root-Dockerfile lane on the release branch, forward-port the fix to `main`, + and rerun the affected exact-SHA gates. Never waive a red Docker lane because + npm preflight passed. +- Root Dockerfile proof is mandatory before every beta and stable tag. Run the + release `install-smoke` group or equivalent root Dockerfile build for the + exact candidate SHA and require it to pass. The tag-triggered Docker Release + workflow is post-tag publishing, not the first valid proof that the root + Dockerfile can build. +- Before tagging, diff publishable plugin package manifests against the last + reachable stable/beta release tag. For every newly publishable package + (`openclaw.release.publishToNpm: true` or `publishToClawHub: true`) whose + package name did not exist in the base tag, verify the target registry package + already exists in npm/ClawHub or stop and help the owner mint/prepublish the + package first. Do not hide or disable release surfaces just to unblock a + train unless the owner explicitly decides the plugin should not ship in that + release; first-package registry ownership is release prep, not product + rollback. The mint/prepublish path must either be the real release publish + path for the auto-bumped beta version, or a deliberately non-consuming + registry-prep step that cannot occupy the next beta version/tag. Confirm + registry owner, npm scope/package-creation permission, provenance path, and + first-package publish plan before the full release publish continues. Useful + npm probe: + `npm view version dist-tags --json --prefer-online`; a 404 for + a package newly added to the release is a release-prep blocker, not something + to discover from the publish job. - Use `pnpm qa:otel:smoke` when release validation needs telemetry coverage. It starts a local OTLP/HTTP trace receiver, runs QA-lab's `otel-trace-smoke`, and checks span names plus content/identifier redaction @@ -332,8 +380,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts ``` - This verifies the published registry install path in a fresh temp prefix. -- For stable correction releases like `YYYY.M.D-N`, it also verifies the - upgrade path from `YYYY.M.D` to `YYYY.M.D-N` so a correction publish cannot +- For stable correction releases like `YYYY.M.PATCH-N`, it also verifies the + upgrade path from `YYYY.M.PATCH` to `YYYY.M.PATCH-N` so a correction publish cannot silently leave existing global installs on the old base stable payload. - Treat install smoke as a pack-budget gate too. `pnpm test:install:smoke` now fails the candidate update tarball when npm reports an oversized @@ -480,7 +528,7 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts `npm login --auth-type=legacy`, then confirm `npm whoami` reports `steipete`. - Promote with a fresh OTP: - `npm dist-tag add openclaw@YYYY.M.D latest --otp "$OTP"`. + `npm dist-tag add openclaw@YYYY.M.PATCH latest --otp "$OTP"`. - Verify with a cache-bypassed registry read, for example: `npm view openclaw dist-tags --json --prefer-online --cache /tmp/openclaw-npm-cache-verify-$$` and `npm view openclaw@latest version dist.tarball --json --prefer-online`. @@ -506,7 +554,7 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts the npm version is already published. - npm validation-only preflight may still be dispatched from ordinary branches when testing workflow changes before merge. Release checks and real publish - use only `main` or `release/YYYY.M.D`. + use only `main` or `release/YYYY.M.PATCH`. - `.github/workflows/macos-release.yml` in `openclaw/openclaw` is now a public validation-only handoff. It validates the tag/release state and points operators to the private repo. It still rebuilds the JS outputs needed for @@ -531,7 +579,7 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts waives the full gate; mac beta validation is still only required when requested. - Real publish runs may be dispatched from `main` or from a - `release/YYYY.M.D` branch. For release-branch runs, the tag must be contained + `release/YYYY.M.PATCH` branch. For release-branch runs, the tag must be contained in that release branch, and the real publish must reuse a successful preflight from the same branch. - The release workflows stay tag-based; rely on the documented release sequence @@ -559,7 +607,11 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts - Use `NPM_TOKEN` only for explicit npm dist-tag management modes, because npm does not support trusted publishing for `npm dist-tag add`. - `@openclaw/*` plugin publishes use a separate maintainer-only flow. -- Only publish plugins that already exist on npm; bundled disk-tree-only plugins stay unpublished. +- Publishable plugins that are new to npm require owner-led first-package + minting before the full release publish. Do not consume the next beta version + with an ad-hoc manual package publish; use the release-owned auto-bumped + version path, or a non-consuming registry setup/preflight step. Bundled + disk-tree-only plugins stay unpublished. ## Fallback local mac publish @@ -599,8 +651,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts 4. Pull latest `main` and confirm current `main` CI is green. 5. Run `/changelog` for the stable base target version on `main`, commit the changelog rewrite immediately, push, and pull/rebase. For beta releases, - keep the changelog heading as `## YYYY.M.D`, not `## YYYY.M.D-beta.N`. -6. Create `release/YYYY.M.D` from that post-changelog `main` commit. + keep the changelog heading as `## YYYY.M.PATCH`, not `## YYYY.M.PATCH-beta.N`. +6. Create `release/YYYY.M.PATCH` from that post-changelog `main` commit. 7. Make every repo version location match the beta tag before creating it. 8. Commit release preparation changes on the release branch and push the branch. 9. Immediately dispatch Actions > `OpenClaw Performance` from `main` with @@ -608,15 +660,18 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts off, live OpenAI off, and regression failure off. Let it run in parallel with preflight and validation work. 10. Run the fast local beta preflight from the release branch before any npm - preflight or publish. Keep expensive Docker, Parallels, and published-package - install/update lanes for after the beta is live unless the operator asks to - run them before beta publication. + preflight or publish. Require exact-SHA CI and root Dockerfile install-smoke + to be green before tagging. Keep the remaining expensive Docker, Parallels, + and published-package install/update lanes for after the beta is live unless + the operator asks to run them before beta publication. 11. For beta releases, skip mac app build/sign/notarize unless beta scope or a release blocker specifically requires it. For stable releases, include the mac app, signing, notarization, and appcast path. 12. Confirm the target npm version is not already published. 13. Create and push the git tag from the release branch. -14. Create or refresh the matching GitHub release. +14. Do not create or publish the matching GitHub release page yet. The real + publish workflow creates or undrafts it only after postpublish verification + and release evidence upload pass. 15. Dispatch Actions > `QA-Lab - All Lanes` against the release tag and wait for the mock parity, live Matrix, and live Telegram credentialed-channel lanes to pass. @@ -639,20 +694,33 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts with `preflight_only=true` and wait for it to pass. Save that run id because the real publish requires it to reuse the notarized mac artifacts. 21. If any preflight or validation run fails, fix the issue on a new commit, - delete the tag and matching GitHub release, recreate them from the fixed - commit, and rerun all relevant preflights from scratch before continuing. - Never reuse old preflight results after the commit changes. For pushed or - published beta tags, do not delete/recreate; increment to the next beta tag. - For preflight-only failures where npm did not publish the beta version, - delete/recreate the same beta tag and prerelease at the fixed commit instead - of skipping a prerelease number. -22. Start `.github/workflows/openclaw-npm-release.yml` from the same branch with + delete the tag and any accidental draft/incomplete GitHub release, recreate + the tag from the fixed commit, and rerun all relevant preflights from + scratch before continuing. Never reuse old preflight results after the + commit changes. Once the npm version exists, do not rerun the publish + workflow for that same version; finalize the existing draft/evidence state + manually or cut a correction tag. For pushed or published beta tags, do not + delete/recreate; increment to the next beta tag. For preflight-only failures + where npm did not publish the beta version, delete/recreate the same beta + tag and any accidental draft/incomplete prerelease at the fixed commit + instead of skipping a prerelease number. +22. Start `.github/workflows/openclaw-release-publish.yml` from the same branch with the same tag for the real publish, choose `npm_dist_tag` (`beta` default, `latest` only when you intentionally want direct stable publish), keep it the same as the preflight run, and pass the successful npm - `preflight_run_id`. + `preflight_run_id` plus the successful `full_release_validation_run_id`. + For stable publish, also pass the exact non-prerelease + `openclaw/openclaw-windows-node` tag as `windows_node_tag` and its + candidate-approved installer digest map as `windows_node_installer_digests`. 23. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`. -24. Run postpublish verification: +24. Wait for the real publish workflow to run postpublish verification, + create or update the GitHub release as a draft, upload dependency evidence, + promote and verify the required Windows Hub assets for stable releases, + append release verification proof, and only then undraft/publish it. If a + waited plugin publish or Windows Hub promotion fails after OpenClaw npm + succeeds, the workflow keeps the release draft with OpenClaw npm evidence + and exits red; do not undraft until the gap is repaired. The standalone + verifier command remains the recovery probe: `node --import tsx scripts/openclaw-npm-postpublish-verify.ts `. 25. Run the post-published beta verification roster. First scan current `main` for critical fixes that landed after the release branch cut; backport only diff --git a/.agents/skills/release-openclaw-nightly/SKILL.md b/.agents/skills/release-openclaw-nightly/SKILL.md index 2b51adffc551..e22c86b7fb24 100644 --- a/.agents/skills/release-openclaw-nightly/SKILL.md +++ b/.agents/skills/release-openclaw-nightly/SKILL.md @@ -37,9 +37,11 @@ This is good for auditability if commits are clearly machine-authored and gated - Branch name: `tideclaw/alpha/YYYY-MM-DD-HHMMZ` - Base: current `origin/main` SHA at trigger time. - State file: resolve from `$release-private` on the Tideclaw host. -- Release tag: `vYYYY.M.D-alpha.N` +- Release tag: `vYYYY.M.PATCH-alpha.N` - npm dist-tag: `alpha` +`PATCH` is a sequential monthly release-train number, never the calendar day. Determine the alpha train from stable and beta releases; ignore alpha-only patch numbers when choosing the next train. Use one greater than the highest stable/beta patch for the month, then increment only `alpha.N` for repeated nightlies on that train. If a beta exists on that next patch, move alpha to the following train. Legacy alpha-only tags with inflated patch numbers do not advance beta/stable numbering. + Do not reuse old alpha branches for a new run. If rerunning the same base SHA, create a new timestamped branch and record why. ## Start @@ -98,7 +100,7 @@ Tideclaw may run beta releases from `#releases` or mentioned `#maintainers` comm Accepted shapes: ```text -@Tideclaw beta release from vYYYY.M.D-alpha.N +@Tideclaw beta release from vYYYY.M.PATCH-alpha.N @Tideclaw beta release from tideclaw/alpha/YYYY-MM-DD-HHMMZ @Tideclaw beta release from latest proven alpha ``` @@ -110,7 +112,7 @@ Rules: 3. Verify the source alpha first: GitHub release, npm `alpha` package, release CI, recorded state file, and branch/tag SHA. 4. Create a fresh beta branch `tideclaw/beta/YYYY-MM-DD-HHMMZ` from the proven alpha source, not directly from a moving `main`. 5. Reuse/squash only stabilization fixes already proven on alpha. Do not import unrelated alpha release mechanics unless the beta release docs require them. -6. Compute beta as `vYYYY.M.D-beta.N`, matching npm `--tag beta`. +6. Compute beta as `vYYYY.M.PATCH-beta.N`, matching npm `--tag beta`. Ignore alpha-only patch numbers when selecting the beta train. 7. Run beta release validation/preflight/full release CI and fix failures on the beta branch. 8. Publish beta only after green beta gates. Use GitHub Actions/OIDC, never direct npm publish from the host. 9. Final Discord summary must include source alpha, beta tag/version, branch, fix commits, workflow run IDs, npm/GitHub proof, and any skipped/blocked reason. @@ -165,7 +167,7 @@ git push -u origin "$BRANCH" After local proof: -1. Compute the next `vYYYY.M.D-alpha.N` from existing git tags, npm versions, and GitHub releases. +1. Compute the next `vYYYY.M.PATCH-alpha.N` from existing git tags, npm versions, and GitHub releases. Select `PATCH` from stable/beta trains, not the date or the highest alpha-only patch. Reuse the same alpha train and increment `alpha.N` until that patch has a beta; after a beta exists, use the following patch for new alpha builds. 2. Make the alpha branch package version and release metadata match that tag, commit it, and push the branch. 3. Run release validation from the alpha branch, using GitHub CLI, not browser/fetch tools. On the Tideclaw host, bare `gh` is a read-only Codex sandbox wrapper; use `/usr/local/bin/gh-tideclaw-write` for write-capable commands such as `workflow run`, `run cancel`, and publish dispatch: diff --git a/.github/labeler.yml b/.github/labeler.yml index 8a359fea3b96..a169ec108c3a 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -293,6 +293,10 @@ - changed-files: - any-glob-to-any-file: - "extensions/lobster/**" +"extensions: llama-cpp": + - changed-files: + - any-glob-to-any-file: + - "extensions/llama-cpp/**" "extensions: memory-core": - changed-files: - any-glob-to-any-file: @@ -574,6 +578,10 @@ - changed-files: - any-glob-to-any-file: - "extensions/openshell/**" +"extensions: parallel": + - changed-files: + - any-glob-to-any-file: + - "extensions/parallel/**" "extensions: perplexity": - changed-files: - any-glob-to-any-file: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d325fb5e84ee..06c78d2d180b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,19 +2,14 @@ What problem does this PR solve? - Why does this matter now? - What is the intended outcome? - What is intentionally out of scope? - What does success look like? - What should reviewers focus on?
@@ -75,13 +70,10 @@ Be mindful of private information like IP addresses, API keys, phone numbers, no Which commands did you run? - What regression coverage was added or updated? - What failed before this fix, if known? - If no test was added, why not?
@@ -95,16 +87,12 @@ List focused commands, not every incidental check. CI is useful support, but ext Did user-visible behavior change? (`Yes/No`) - Did config, environment, or migration behavior change? (`Yes/No`) - Did security, auth, secrets, network, or tool execution behavior change? (`Yes/No`) - What is the highest-risk area? - How is that risk mitigated?
@@ -118,10 +106,8 @@ Use this for author judgment that is not obvious from the diff. ClawSweeper can What is the next action? - What is still waiting on author, maintainer, CI, or external proof? - Which bot or reviewer comments were addressed?
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1675b8822cd4..f1b47d01c302 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -722,7 +722,7 @@ jobs: if [ "$RUN_GATEWAY_WATCH" = "true" ]; then start_check "gateway-watch" \ - node scripts/check-gateway-watch-regression.mjs --skip-build --ready-timeout-ms 5000 + node scripts/check-gateway-watch-regression.mjs --skip-build fi for index in "${!pids[@]}"; do @@ -1288,6 +1288,7 @@ jobs: env: OPENCLAW_LOCAL_CHECK: "0" TASK: ${{ matrix.task }} + PR_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }} shell: bash run: | set -euo pipefail @@ -1297,6 +1298,10 @@ jobs: pnpm tool-display:check pnpm check:host-env-policy:swift pnpm dup:check:coverage + if [ -n "$PR_BASE_SHA" ]; then + git fetch --no-tags --depth=1 origin "+${PR_BASE_SHA}:refs/remotes/origin/pr-base" + node scripts/report-test-temp-creations.mjs --base refs/remotes/origin/pr-base --head HEAD --no-merge-base + fi pnpm deps:patches:check pnpm lint:webhook:no-low-level-body-read pnpm lint:auth:no-pairing-store-group @@ -1358,6 +1363,8 @@ jobs: - check_name: check-additional-boundaries-bcd group: boundaries boundary_shard: 2/4,3/4,4/4 + - check_name: check-session-accessor-boundary + group: session-accessor-boundary - check_name: check-additional-extension-channels group: extension-channels - check_name: check-additional-extension-bundled @@ -1504,6 +1511,15 @@ jobs: boundaries) node scripts/run-additional-boundary-checks.mjs ;; + session-accessor-boundary) + if [ ! -f scripts/check-session-accessor-boundary.mjs ]; then + echo "[skip] session accessor boundary check is not present in this checkout" + elif ! node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["lint:tmp:session-accessor-boundary"] ? 0 : 1);'; then + echo "[skip] session accessor boundary script is not present in package.json" + else + run_check "lint:tmp:session-accessor-boundary" pnpm run lint:tmp:session-accessor-boundary + fi + ;; extension-channels) run_check "lint:extensions:channels" pnpm run lint:extensions:channels ;; @@ -2093,7 +2109,7 @@ jobs: uses: actions/cache@v5 with: path: ~/.android-sdk - key: ${{ runner.os }}-android-sdk-v1-cmdline-12266719-platform-36-build-tools-36.0.0 + key: ${{ runner.os }}-android-sdk-v1-cmdline-14742923-platform-37.0-build-tools-36.0.0 restore-keys: | ${{ runner.os }}-android-sdk-v1- @@ -2101,7 +2117,7 @@ jobs: run: | set -euo pipefail ANDROID_SDK_ROOT="$HOME/.android-sdk" - CMDLINE_TOOLS_VERSION="12266719" + CMDLINE_TOOLS_VERSION="14742923" ARCHIVE="commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip" URL="https://dl.google.com/android/repository/${ARCHIVE}" @@ -2123,7 +2139,7 @@ jobs: yes | sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --licenses >/dev/null sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --install \ "platform-tools" \ - "platforms;android-36" \ + "platforms;android-37.0" \ "build-tools;36.0.0" - name: Run Android ${{ matrix.task }} diff --git a/.github/workflows/codeql-android-critical-security.yml b/.github/workflows/codeql-android-critical-security.yml index 8ac806564344..c0911020c48c 100644 --- a/.github/workflows/codeql-android-critical-security.yml +++ b/.github/workflows/codeql-android-critical-security.yml @@ -35,7 +35,7 @@ jobs: java-version: "21" - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: languages: java-kotlin build-mode: manual @@ -46,6 +46,6 @@ jobs: run: ./gradlew --no-daemon :app:assemblePlayDebug - name: Analyze - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: category: "/codeql-critical-security/android" diff --git a/.github/workflows/codeql-critical-quality.yml b/.github/workflows/codeql-critical-quality.yml index d883449e0b65..1e03f5bbd334 100644 --- a/.github/workflows/codeql-critical-quality.yml +++ b/.github/workflows/codeql-critical-quality.yml @@ -342,13 +342,13 @@ jobs: submodules: false - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: languages: javascript-typescript config-file: ./.github/codeql/codeql-core-auth-secrets-critical-quality.yml - name: Analyze - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: category: "/codeql-critical-quality/core-auth-secrets" @@ -365,13 +365,13 @@ jobs: submodules: false - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: languages: javascript-typescript config-file: ./.github/codeql/codeql-config-boundary-critical-quality.yml - name: Analyze - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: category: "/codeql-critical-quality/config-boundary" @@ -388,13 +388,13 @@ jobs: submodules: false - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: languages: javascript-typescript config-file: ./.github/codeql/codeql-gateway-runtime-boundary-critical-quality.yml - name: Analyze - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: category: "/codeql-critical-quality/gateway-runtime-boundary" @@ -411,13 +411,13 @@ jobs: submodules: false - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: languages: javascript-typescript config-file: ./.github/codeql/codeql-channel-runtime-boundary-critical-quality.yml - name: Analyze - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: category: "/codeql-critical-quality/channel-runtime-boundary" @@ -460,7 +460,7 @@ jobs: - name: Initialize CodeQL if: ${{ github.event_name != 'pull_request' }} - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: languages: javascript-typescript config-file: ./.github/codeql/codeql-network-runtime-boundary-critical-quality.yml @@ -468,7 +468,7 @@ jobs: - name: Analyze id: analyze if: ${{ github.event_name != 'pull_request' }} - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: output: sarif-results category: "/codeql-critical-quality/network-runtime-boundary" @@ -518,13 +518,13 @@ jobs: submodules: false - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: languages: javascript-typescript config-file: ./.github/codeql/codeql-agent-runtime-boundary-critical-quality.yml - name: Analyze - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: category: "/codeql-critical-quality/agent-runtime-boundary" @@ -541,13 +541,13 @@ jobs: submodules: false - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: languages: javascript-typescript config-file: ./.github/codeql/codeql-mcp-process-runtime-boundary-critical-quality.yml - name: Analyze - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: category: "/codeql-critical-quality/mcp-process-runtime-boundary" @@ -564,13 +564,13 @@ jobs: submodules: false - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: languages: javascript-typescript config-file: ./.github/codeql/codeql-memory-runtime-boundary-critical-quality.yml - name: Analyze - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: category: "/codeql-critical-quality/memory-runtime-boundary" @@ -587,13 +587,13 @@ jobs: submodules: false - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: languages: javascript-typescript config-file: ./.github/codeql/codeql-session-diagnostics-boundary-critical-quality.yml - name: Analyze - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: category: "/codeql-critical-quality/session-diagnostics-boundary" @@ -610,13 +610,13 @@ jobs: submodules: false - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: languages: javascript-typescript config-file: ./.github/codeql/codeql-plugin-sdk-reply-runtime-critical-quality.yml - name: Analyze - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: category: "/codeql-critical-quality/plugin-sdk-reply-runtime" @@ -633,13 +633,13 @@ jobs: submodules: false - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: languages: javascript-typescript config-file: ./.github/codeql/codeql-provider-runtime-boundary-critical-quality.yml - name: Analyze - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: category: "/codeql-critical-quality/provider-runtime-boundary" @@ -655,13 +655,13 @@ jobs: submodules: false - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: languages: javascript-typescript config-file: ./.github/codeql/codeql-ui-control-plane-critical-quality.yml - name: Analyze - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: category: "/codeql-critical-quality/ui-control-plane" @@ -677,13 +677,13 @@ jobs: submodules: false - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: languages: javascript-typescript config-file: ./.github/codeql/codeql-web-media-runtime-boundary-critical-quality.yml - name: Analyze - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: category: "/codeql-critical-quality/web-media-runtime-boundary" @@ -700,13 +700,13 @@ jobs: submodules: false - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: languages: javascript-typescript config-file: ./.github/codeql/codeql-plugin-boundary-critical-quality.yml - name: Analyze - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: category: "/codeql-critical-quality/plugin-boundary" @@ -723,12 +723,12 @@ jobs: submodules: false - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: languages: javascript-typescript config-file: ./.github/codeql/codeql-plugin-sdk-package-contract-critical-quality.yml - name: Analyze - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: category: "/codeql-critical-quality/plugin-sdk-package-contract" diff --git a/.github/workflows/codeql-macos-critical-security.yml b/.github/workflows/codeql-macos-critical-security.yml index 1619b56cb7a0..5d439d6bbeee 100644 --- a/.github/workflows/codeql-macos-critical-security.yml +++ b/.github/workflows/codeql-macos-critical-security.yml @@ -35,7 +35,7 @@ jobs: swift --version - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: languages: swift build-mode: manual @@ -46,7 +46,7 @@ jobs: - name: Analyze id: analyze - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: output: sarif-results upload: failure-only @@ -83,7 +83,7 @@ jobs: done - name: Upload filtered SARIF - uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: sarif_file: sarif-results-filtered category: "/codeql-critical-security/macos" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c1b5aa0fb5c2..85970bbf7913 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -101,12 +101,12 @@ jobs: .github/codeql - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: languages: ${{ matrix.language }} config-file: ${{ matrix.config_file }} - name: Analyze - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: category: "/codeql-security-high/${{ matrix.category }}" diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index d986108f0877..981e210cd7ad 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -88,11 +88,30 @@ jobs: ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} fetch-depth: 0 + - name: Pre-pull BuildKit image + shell: bash + env: + BUILDKIT_IMAGE: moby/buildkit:buildx-stable-1 + run: | + set -euo pipefail + for attempt in 1 2 3 4; do + if docker pull "${BUILDKIT_IMAGE}"; then + exit 0 + fi + if [[ "${attempt}" == "4" ]]; then + echo "::error::Failed to pull ${BUILDKIT_IMAGE} after ${attempt} attempts" + exit 1 + fi + sleep_seconds=$((attempt * 10)) + echo "BuildKit image pull failed; retrying in ${sleep_seconds}s (${attempt}/4)." + sleep "${sleep_seconds}" + done + - name: Set up Docker Builder - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 - name: Login to GitHub Container Registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.repository_owner }} @@ -161,7 +180,7 @@ jobs: - name: Build and push amd64 image id: build # WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY. - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . platforms: linux/amd64 @@ -179,7 +198,7 @@ jobs: id: build-browser if: steps.tags.outputs.browser != '' # WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY. - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . platforms: linux/amd64 @@ -279,11 +298,30 @@ jobs: ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} fetch-depth: 0 + - name: Pre-pull BuildKit image + shell: bash + env: + BUILDKIT_IMAGE: moby/buildkit:buildx-stable-1 + run: | + set -euo pipefail + for attempt in 1 2 3 4; do + if docker pull "${BUILDKIT_IMAGE}"; then + exit 0 + fi + if [[ "${attempt}" == "4" ]]; then + echo "::error::Failed to pull ${BUILDKIT_IMAGE} after ${attempt} attempts" + exit 1 + fi + sleep_seconds=$((attempt * 10)) + echo "BuildKit image pull failed; retrying in ${sleep_seconds}s (${attempt}/4)." + sleep "${sleep_seconds}" + done + - name: Set up Docker Builder - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 - name: Login to GitHub Container Registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.repository_owner }} @@ -352,7 +390,7 @@ jobs: - name: Build and push arm64 image id: build # WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY. - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . platforms: linux/arm64 @@ -370,7 +408,7 @@ jobs: id: build-browser if: steps.tags.outputs.browser != '' # WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY. - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . platforms: linux/arm64 @@ -468,7 +506,7 @@ jobs: fetch-depth: 0 - name: Login to GitHub Container Registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.repository_owner }} @@ -561,11 +599,30 @@ jobs: with: fetch-depth: 1 + - name: Pre-pull BuildKit image + shell: bash + env: + BUILDKIT_IMAGE: moby/buildkit:buildx-stable-1 + run: | + set -euo pipefail + for attempt in 1 2 3 4; do + if docker pull "${BUILDKIT_IMAGE}"; then + exit 0 + fi + if [[ "${attempt}" == "4" ]]; then + echo "::error::Failed to pull ${BUILDKIT_IMAGE} after ${attempt} attempts" + exit 1 + fi + sleep_seconds=$((attempt * 10)) + echo "BuildKit image pull failed; retrying in ${sleep_seconds}s (${attempt}/4)." + sleep "${sleep_seconds}" + done + - name: Set up Docker Builder - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 - name: Login to GitHub Container Registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.repository_owner }} diff --git a/.github/workflows/docs-agent.yml b/.github/workflows/docs-agent.yml index a939f9c0af61..6df42c682a67 100644 --- a/.github/workflows/docs-agent.yml +++ b/.github/workflows/docs-agent.yml @@ -149,7 +149,7 @@ jobs: - name: Run Codex docs agent if: steps.gate.outputs.run_agent == 'true' - uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02 + uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1 env: DOCS_AGENT_BASE_SHA: ${{ steps.gate.outputs.review_base_sha }} DOCS_AGENT_HEAD_SHA: ${{ steps.gate.outputs.review_head_sha }} diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index 2f3a283a4c8a..2e7dff1d0fbf 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -783,7 +783,7 @@ jobs: fi args=( - -f ref="$TARGET_SHA" + -f ref="$TARGET_REF" -f expected_sha="$TARGET_SHA" -f provider="$PROVIDER" -f mode="$MODE" @@ -1139,7 +1139,16 @@ jobs: summary: name: Verify full validation - needs: [resolve_target, docker_runtime_assets_preflight, normal_ci, plugin_prerelease, release_checks, npm_telegram, performance] + needs: + [ + resolve_target, + docker_runtime_assets_preflight, + normal_ci, + plugin_prerelease, + release_checks, + npm_telegram, + performance, + ] if: always() runs-on: ubuntu-24.04 timeout-minutes: 5 diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index f12ce3e8d35c..185aebf110b1 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -112,7 +112,7 @@ jobs: persist-credentials: false - name: Set up Blacksmith Docker Builder - uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1 + uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1 with: max-cache-size-mb: 800000 @@ -223,7 +223,7 @@ jobs: persist-credentials: false - name: Log in to GHCR - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -245,7 +245,7 @@ jobs: - name: Set up Blacksmith Docker Builder if: steps.existing.outputs.exists != 'true' - uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1 + uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1 with: max-cache-size-mb: 800000 @@ -311,7 +311,7 @@ jobs: persist-credentials: false - name: Log in to GHCR - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -417,7 +417,7 @@ jobs: persist-credentials: false - name: Log in to GHCR - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -429,7 +429,7 @@ jobs: run: timeout --kill-after=30s 600s docker pull "$IMAGE_REF" - name: Set up Blacksmith Docker Builder - uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1 + uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1 with: max-cache-size-mb: 800000 @@ -503,7 +503,7 @@ jobs: persist-credentials: false - name: Log in to GHCR - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -542,7 +542,7 @@ jobs: persist-credentials: false - name: Set up Blacksmith Docker Builder - uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1 + uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1 with: max-cache-size-mb: 800000 diff --git a/.github/workflows/ios-periphery-comment.yml b/.github/workflows/ios-periphery-comment.yml new file mode 100644 index 000000000000..608d2d529be2 --- /dev/null +++ b/.github/workflows/ios-periphery-comment.yml @@ -0,0 +1,447 @@ +name: iOS Periphery Dead Code Comment + +on: + workflow_run: # zizmor: ignore[dangerous-triggers] trusted PR commenter; job gates repository, source event, workflow name, live open PR, and exact current head before reading artifacts or writing comments + workflows: ["iOS Periphery Dead Code"] + types: [completed] + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +permissions: + actions: read + contents: read + issues: write + pull-requests: read + +jobs: + comment: + name: Comment on PR + runs-on: ubuntu-24.04 + if: > + github.repository == 'openclaw/openclaw' && + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.name == 'iOS Periphery Dead Code' + steps: + - name: Upsert Periphery PR comment + uses: actions/github-script@v9 + with: + script: | + const fs = require("node:fs"); + const os = require("node:os"); + const path = require("node:path"); + const childProcess = require("node:child_process"); + + const marker = ""; + const run = context.payload.workflow_run; + const pr = run.pull_requests?.[0]; + if (!pr) { + core.info("No pull request attached to workflow_run."); + return; + } + + const { owner, repo } = context.repo; + const repository = `${owner}/${repo}`; + if (run.repository?.full_name !== repository) { + core.info(`Skipping workflow_run from ${run.repository?.full_name ?? "unknown repository"}.`); + return; + } + if (run.event !== "pull_request") { + core.info(`Skipping workflow_run for ${run.event ?? "unknown"} event.`); + return; + } + if (run.name !== "iOS Periphery Dead Code") { + core.info(`Skipping unexpected workflow ${run.name ?? "unknown"}.`); + return; + } + + const livePull = await github.rest.pulls.get({ + owner, + repo, + pull_number: pr.number, + }); + if (livePull.data.state !== "open") { + core.info(`Skipping closed PR #${pr.number}.`); + return; + } + if (livePull.data.base?.repo?.full_name !== repository) { + core.info(`Skipping PR #${pr.number} targeting ${livePull.data.base?.repo?.full_name ?? "unknown repository"}.`); + return; + } + if (livePull.data.head?.sha !== run.head_sha) { + core.info(`Skipping stale run ${run.id}; PR #${pr.number} is now at ${livePull.data.head?.sha}.`); + return; + } + + const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { + owner, + repo, + run_id: run.id, + filter: "latest", + per_page: 100, + }); + const scopeJob = jobs.find((job) => job.name === "Detect iOS scan scope"); + const scanJob = jobs.find((job) => job.name === "Scan iOS dead code"); + const scanSkipped = + scopeJob?.conclusion === "success" && scanJob?.conclusion === "skipped"; + if (scanSkipped) { + core.info(`Skipping intentionally omitted Periphery scan for PR #${pr.number}.`); + } + + const artifacts = scanSkipped + ? [] + : await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner, + repo, + run_id: run.id, + per_page: 100, + }); + + const readReport = async () => { + if (scanSkipped) { + return; + } + const artifactName = `ios-periphery-dead-code-${run.id}-${run.run_attempt}`; + const artifact = artifacts.find((item) => item.name === artifactName); + if (!artifact) { + core.warning(`No ${artifactName} artifact found.`); + return; + } + if (artifact.expired) { + core.warning(`${artifactName} artifact expired.`); + return; + } + + const maxArchiveBytes = 1024 * 1024; + const archiveSize = Number(artifact.size_in_bytes); + if (!Number.isSafeInteger(archiveSize) || archiveSize < 0 || archiveSize > maxArchiveBytes) { + core.warning(`Skipping ${artifactName}; compressed artifact size ${artifact.size_in_bytes ?? "unknown"} exceeds the ${maxArchiveBytes} byte limit.`); + return; + } + + const archive = await github.rest.actions.downloadArtifact({ + owner, + repo, + artifact_id: artifact.id, + archive_format: "zip", + }); + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ios-periphery-")); + const archivePath = path.join(dir, "artifact.zip"); + const archiveBuffer = Buffer.from(archive.data); + fs.writeFileSync(archivePath, archiveBuffer); + + const allowedArtifactFiles = new Set([ + "periphery.json", + "periphery.status", + "periphery.stderr.log", + "periphery.stdout.json", + "should-fail.txt", + ]); + const maxEntries = allowedArtifactFiles.size; + const maxEntryBytes = 2 * 1024 * 1024; + const maxTotalBytes = 4 * 1024 * 1024; + + const readUInt16 = (offset) => archiveBuffer.readUInt16LE(offset); + const readUInt32 = (offset) => archiveBuffer.readUInt32LE(offset); + const findEndOfCentralDirectoryOffset = () => { + const minimumOffset = Math.max(0, archiveBuffer.length - 0xffff - 22); + for (let offset = archiveBuffer.length - 22; offset >= minimumOffset; offset -= 1) { + if (readUInt32(offset) === 0x06054b50) { + return offset; + } + } + return -1; + }; + + const endOfCentralDirectoryOffset = findEndOfCentralDirectoryOffset(); + if (endOfCentralDirectoryOffset < 0) { + core.warning(`Skipping ${artifactName}; ZIP end-of-central-directory record was not found.`); + return; + } + const entryCount = readUInt16(endOfCentralDirectoryOffset + 10); + const centralDirectorySize = readUInt32(endOfCentralDirectoryOffset + 12); + const centralDirectoryOffset = readUInt32(endOfCentralDirectoryOffset + 16); + if (entryCount < 1 || entryCount > maxEntries) { + core.warning(`Skipping ${artifactName}; artifact has ${entryCount} entries.`); + return; + } + if ( + centralDirectoryOffset + centralDirectorySize > archiveBuffer.length || + readUInt32(centralDirectoryOffset) !== 0x02014b50 + ) { + core.warning(`Skipping ${artifactName}; invalid ZIP central directory.`); + return; + } + + const entries = new Map(); + let totalUncompressedSize = 0; + let offset = centralDirectoryOffset; + for (let index = 0; index < entryCount; index += 1) { + if (offset + 46 > archiveBuffer.length || readUInt32(offset) !== 0x02014b50) { + core.warning(`Skipping ${artifactName}; invalid central directory entry.`); + return; + } + + const compressionMethod = readUInt16(offset + 10); + const generalPurposeBitFlag = readUInt16(offset + 8); + const compressedSize = readUInt32(offset + 20); + const uncompressedSize = readUInt32(offset + 24); + const fileNameLength = readUInt16(offset + 28); + const extraLength = readUInt16(offset + 30); + const commentLength = readUInt16(offset + 32); + const externalAttributes = readUInt32(offset + 38); + const nameStart = offset + 46; + const nameEnd = nameStart + fileNameLength; + const nextOffset = nameEnd + extraLength + commentLength; + if (nextOffset > archiveBuffer.length) { + core.warning(`Skipping ${artifactName}; central directory entry exceeds archive bounds.`); + return; + } + + const name = archiveBuffer.toString("utf8", nameStart, nameEnd); + const mode = externalAttributes >>> 16; + const fileType = mode & 0o170000; + const isRegularFile = fileType === 0 || fileType === 0o100000; + const invalidName = + !allowedArtifactFiles.has(name) || + name.includes("/") || + name.includes("\\") || + name.includes("..") || + path.isAbsolute(name); + if (invalidName) { + core.warning(`Skipping ${artifactName}; unexpected artifact entry ${name}.`); + return; + } + if (!isRegularFile || name.endsWith("/")) { + core.warning(`Skipping ${artifactName}; ${name} is not a regular file.`); + return; + } + if (entries.has(name)) { + core.warning(`Skipping ${artifactName}; duplicate artifact entry ${name}.`); + return; + } + if (![0, 8].includes(compressionMethod)) { + core.warning(`Skipping ${artifactName}; ${name} uses unsupported ZIP compression method ${compressionMethod}.`); + return; + } + if ((generalPurposeBitFlag & 0x1) !== 0) { + core.warning(`Skipping ${artifactName}; ${name} is encrypted.`); + return; + } + if (compressedSize > maxEntryBytes || uncompressedSize > maxEntryBytes) { + core.warning(`Skipping ${artifactName}; ${name} exceeds the per-file size limit.`); + return; + } + + totalUncompressedSize += uncompressedSize; + if (totalUncompressedSize > maxTotalBytes) { + core.warning(`Skipping ${artifactName}; artifact exceeds the aggregate size limit.`); + return; + } + + entries.set(name, { uncompressedSize }); + offset = nextOffset; + } + + const files = new Map(); + for (const [name, entry] of entries) { + const contents = childProcess.execFileSync("unzip", ["-p", archivePath, name], { + encoding: "utf8", + maxBuffer: Math.max(1, entry.uncompressedSize + 1024), + timeout: 5000, + }); + if (Buffer.byteLength(contents, "utf8") > maxEntryBytes) { + core.warning(`Skipping ${artifactName}; ${name} exceeded the per-file size limit while reading.`); + return; + } + files.set(name, contents); + } + + const read = (name) => { + return files.get(name) ?? ""; + }; + + const status = Number(read("periphery.status").trim() || "1"); + let findings = null; + for (const name of ["periphery.json", "periphery.stdout.json"]) { + try { + const parsed = JSON.parse(read(name)); + const validFindings = + Array.isArray(parsed) && + parsed.every( + (finding) => + finding !== null && + typeof finding === "object" && + !Array.isArray(finding), + ); + if (validFindings) { + findings = parsed; + break; + } + } catch {} + } + return { findings, status }; + }; + const report = await readReport(); + const status = report?.status ?? 1; + const findings = report?.findings ?? null; + + const sanitizeCell = (value) => { + const normalized = String(value ?? "") + .replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ") + .replace(/[\u200b-\u200f\u202a-\u202e\u2060\u2066-\u2069\ufeff]/gu, "") + .replace(/\s+/gu, " ") + .trim(); + const maxEncodedLength = 180; + let escaped = ""; + for (const character of normalized) { + const encoded = + character === "`" + ? "'" + : character === "|" + ? "\\|" + : character; + if (escaped.length + encoded.length > maxEncodedLength) { + break; + } + escaped += encoded; + } + return `\`${escaped || "-"}\``; + }; + + const rows = (findings ?? []).map((finding) => { + const location = String(finding.location ?? ""); + const [file, line] = location.split(":"); + return { + file: file ? `apps/ios/${file}` : "", + line: line || "", + kind: String(finding.kind ?? ""), + name: String(finding.name ?? ""), + }; + }); + + let mode = "failure"; + let body = `${marker}\n`; + if (scanSkipped) { + mode = "skipped"; + body += [ + "### iOS Periphery", + "", + "Periphery scan skipped because the pull request is a draft or no longer touches iOS scan scope.", + ].join("\n"); + } else if (findings === null) { + body += [ + "### iOS Periphery", + "", + "Periphery did not complete or its report could not be safely read. Check the workflow run for details.", + ].join("\n"); + } else if (rows.length === 0 && status === 0) { + mode = "success"; + body += [ + "### iOS Periphery", + "", + "No dead Swift code found.", + ].join("\n"); + } else if (rows.length > 0) { + const shown = rows.slice(0, 50); + body += [ + "### iOS Periphery", + "", + `Found ${rows.length} dead Swift code ${rows.length === 1 ? "symbol" : "symbols"}. Remove the code or add a narrow Periphery exemption with a comment explaining why it must stay.`, + "", + "| File | Line | Kind | Name |", + "| --- | ---: | --- | --- |", + ...shown.map((row) => `| ${sanitizeCell(row.file)} | ${sanitizeCell(row.line)} | ${sanitizeCell(row.kind)} | ${sanitizeCell(row.name)} |`), + rows.length > shown.length ? "" : null, + rows.length > shown.length ? `Showing first ${shown.length}; full JSON is in the workflow artifact.` : null, + ].filter(Boolean).join("\n"); + } else { + body += [ + "### iOS Periphery", + "", + "Periphery exited with a non-zero status before producing findings. Check the workflow artifact for stdout/stderr.", + ].join("\n"); + } + body += "\n"; + const maxCommentChars = 60_000; + if (body.length > maxCommentChars) { + body = [ + marker, + "### iOS Periphery", + "", + `Found ${rows.length} dead Swift code ${rows.length === 1 ? "symbol" : "symbols"}. The rendered report exceeded the safe comment limit; use the workflow artifact for details.`, + "", + ].join("\n"); + } + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: livePull.data.number, + per_page: 100, + }); + const existing = comments.find( + (comment) => + comment.user?.login === "github-actions[bot]" && + comment.body?.includes(marker), + ); + + if (!existing && ["skipped", "success"].includes(mode)) { + core.info(`No existing Periphery comment and scan ${mode}; skipping comment.`); + return; + } + + const currentPull = await github.rest.pulls.get({ + owner, + repo, + pull_number: pr.number, + }); + if ( + currentPull.data.state !== "open" || + currentPull.data.base?.repo?.full_name !== repository || + currentPull.data.head?.sha !== run.head_sha + ) { + core.info(`Skipping stale run ${run.id}; PR #${pr.number} changed before comment update.`); + return; + } + + const workflowRuns = await github.paginate(github.rest.actions.listWorkflowRuns, { + owner, + repo, + workflow_id: run.workflow_id, + event: "pull_request", + head_sha: run.head_sha, + per_page: 100, + }); + const supersedingRun = workflowRuns.find( + (candidate) => + (candidate.id === run.id || + candidate.pull_requests?.some( + (candidatePull) => candidatePull.number === pr.number, + )) && + (candidate.run_number > run.run_number || + (candidate.run_number === run.run_number && + candidate.run_attempt > run.run_attempt)), + ); + if (supersedingRun) { + core.info(`Skipping superseded run ${run.id} attempt ${run.run_attempt}; run ${supersedingRun.id} attempt ${supersedingRun.run_attempt} is newer.`); + return; + } + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + return; + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: livePull.data.number, + body, + }); diff --git a/.github/workflows/ios-periphery.yml b/.github/workflows/ios-periphery.yml new file mode 100644 index 000000000000..4aa58289e73e --- /dev/null +++ b/.github/workflows/ios-periphery.yml @@ -0,0 +1,229 @@ +name: iOS Periphery Dead Code + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] + workflow_dispatch: + +concurrency: + group: ios-periphery-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +permissions: + contents: read + pull-requests: read + +jobs: + scope: + name: Detect iOS scan scope + runs-on: ubuntu-24.04 + outputs: + should-scan: ${{ steps.scope.outputs.should-scan }} + steps: + - name: Detect changed paths + id: scope + uses: actions/github-script@v9 + with: + script: | + if (context.eventName === "workflow_dispatch") { + core.setOutput("should-scan", "true"); + return; + } + if (context.payload.pull_request?.draft) { + core.setOutput("should-scan", "false"); + return; + } + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + per_page: 100, + }); + const isScanPath = (filename) => + typeof filename === "string" && ( + filename.startsWith("apps/ios/") || + filename === ".github/workflows/ios-periphery.yml" || + filename === ".github/workflows/ios-periphery-comment.yml" || + filename === "config/swiftformat" || + filename === "config/swiftlint.yml" + ); + const shouldScan = files.some( + ({ filename, previous_filename: previousFilename }) => + isScanPath(filename) || isScanPath(previousFilename) + ); + core.setOutput("should-scan", String(shouldScan)); + + scan: + name: Scan iOS dead code + needs: scope + if: ${{ needs.scope.outputs.should-scan == 'true' }} + runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }} + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 1 + fetch-tags: false + persist-credentials: false + submodules: false + + - name: Verify Xcode + run: | + set -euo pipefail + for xcode_app in /Applications/Xcode_26.5.app /Applications/Xcode-26.5.0.app; do + if [ -d "$xcode_app/Contents/Developer" ]; then + sudo xcode-select -s "$xcode_app/Contents/Developer" + break + fi + done + xcodebuild -version + xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')" + if [[ "$xcode_version" != 26.* ]]; then + echo "error: expected Xcode 26.x, got $xcode_version" >&2 + exit 1 + fi + swift --version + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + + - name: Install iOS Swift tooling + run: brew install xcodegen swiftformat swiftlint periphery + + - name: Generate iOS project + run: | + set -euo pipefail + ./scripts/ios-configure-signing.sh + ./scripts/ios-write-version-xcconfig.sh + cd apps/ios + xcodegen generate + + - name: Run Periphery + run: | + set -euo pipefail + output_dir="$RUNNER_TEMP/ios-periphery" + mkdir -p "$output_dir" + cd apps/ios + set +e + periphery scan \ + --config .periphery.yml \ + --strict \ + --format json \ + --write-results "$output_dir/periphery.json" \ + >"$output_dir/periphery.stdout.json" \ + 2>"$output_dir/periphery.stderr.log" + periphery_status="$?" + set -e + printf '%s\n' "$periphery_status" >"$output_dir/periphery.status" + if [ ! -s "$output_dir/periphery.json" ]; then + cp "$output_dir/periphery.stdout.json" "$output_dir/periphery.json" + fi + + - name: Build Periphery report + run: | + set -euo pipefail + node <<'NODE' + const fs = require("node:fs"); + const path = require("node:path"); + + const outputDir = path.join(process.env.RUNNER_TEMP, "ios-periphery"); + const read = (name) => { + const file = path.join(outputDir, name); + return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : ""; + }; + + const status = Number(read("periphery.status").trim() || "1"); + let findings = null; + for (const name of ["periphery.json", "periphery.stdout.json"]) { + try { + const parsed = JSON.parse(read(name)); + if (Array.isArray(parsed)) { + findings = parsed; + break; + } + } catch {} + } + + const escapeCommandData = (value) => + String(value ?? "") + .replaceAll("%", "%25") + .replaceAll("\r", "%0D") + .replaceAll("\n", "%0A"); + const escapeCommandProperty = (value) => + escapeCommandData(value) + .replaceAll(":", "%3A") + .replaceAll(",", "%2C"); + + const rows = (findings ?? []).map((finding) => { + const location = String(finding.location ?? ""); + const [file, line] = location.split(":"); + const repoFile = file ? `apps/ios/${file}` : ""; + return { + file: repoFile, + line: line || "", + kind: String(finding.kind ?? ""), + name: String(finding.name ?? ""), + }; + }); + + for (const row of rows) { + if (!row.file) continue; + const line = row.line ? `,line=${escapeCommandProperty(row.line)}` : ""; + const title = `${row.kind || "Unused code"} ${row.name}`.trim(); + console.log(`::error file=${escapeCommandProperty(row.file)}${line},title=Dead Swift code::${escapeCommandData(title)}`); + } + + let shouldFail = "1"; + let summary = ""; + + if (findings === null) { + summary = [ + "### iOS Periphery", + "", + "Periphery did not complete. Check the workflow artifact for stdout/stderr.", + ].join("\n"); + } else if (rows.length === 0 && status === 0) { + shouldFail = "0"; + summary = [ + "### iOS Periphery", + "", + "No dead Swift code found.", + ].join("\n"); + } else if (rows.length > 0) { + summary = [ + "### iOS Periphery", + "", + `Found ${rows.length} dead Swift code ${rows.length === 1 ? "symbol" : "symbols"}. See the PR comment or workflow artifact for details.`, + ].join("\n"); + } else { + summary = [ + "### iOS Periphery", + "", + "Periphery exited with a non-zero status before producing findings. Check the workflow artifact for stdout/stderr.", + ].join("\n"); + } + + fs.writeFileSync(path.join(outputDir, "should-fail.txt"), `${shouldFail}\n`); + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary.trim()}\n`); + NODE + + - name: Upload Periphery report + if: always() + uses: actions/upload-artifact@v7 + with: + name: ios-periphery-dead-code-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/ios-periphery + if-no-files-found: warn + retention-days: 14 + + - name: Fail on dead code + run: | + set -euo pipefail + test "$(cat "$RUNNER_TEMP/ios-periphery/should-fail.txt")" = "0" diff --git a/.github/workflows/live-media-runner-image.yml b/.github/workflows/live-media-runner-image.yml index 3e01f6347090..02297559e370 100644 --- a/.github/workflows/live-media-runner-image.yml +++ b/.github/workflows/live-media-runner-image.yml @@ -29,14 +29,14 @@ jobs: uses: actions/checkout@v6 - name: Login to GHCR - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ github.token }} - name: Set up Blacksmith Docker Builder - uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1 + uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1 with: max-cache-size-mb: 800000 diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml index fef082a9ba94..c0f0bbc22b81 100644 --- a/.github/workflows/macos-release.yml +++ b/.github/workflows/macos-release.yml @@ -13,7 +13,7 @@ on: default: true type: boolean public_release_branch: - description: Public branch that contains the release tag commit, usually main or release/YYYY.M.D + description: Public branch that contains the release tag commit, usually main or release/YYYY.M.PATCH required: false default: main type: string @@ -73,7 +73,7 @@ jobs: run: | set -euo pipefail if [[ "${PUBLIC_RELEASE_BRANCH}" != "main" && ! "${PUBLIC_RELEASE_BRANCH}" =~ ^release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]]; then - echo "public_release_branch must be main or release/YYYY.M.D, got ${PUBLIC_RELEASE_BRANCH}." >&2 + echo "public_release_branch must be main or release/YYYY.M.PATCH, got ${PUBLIC_RELEASE_BRANCH}." >&2 exit 1 fi RELEASE_SHA=$(git rev-parse HEAD) diff --git a/.github/workflows/mantis-discord-smoke.yml b/.github/workflows/mantis-discord-smoke.yml index 091c814ef73f..438d58260ffa 100644 --- a/.github/workflows/mantis-discord-smoke.yml +++ b/.github/workflows/mantis-discord-smoke.yml @@ -37,7 +37,7 @@ jobs: steps: - name: Require maintainer-level repository access id: permission - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const allowed = new Set(["admin", "maintain", "write"]); diff --git a/.github/workflows/mantis-discord-status-reactions.yml b/.github/workflows/mantis-discord-status-reactions.yml index 984b8f807869..41e2fc421be3 100644 --- a/.github/workflows/mantis-discord-status-reactions.yml +++ b/.github/workflows/mantis-discord-status-reactions.yml @@ -56,7 +56,7 @@ jobs: steps: - name: Require maintainer-level repository access id: permission - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const allowed = new Set(["admin", "maintain", "write"]); @@ -91,7 +91,7 @@ jobs: steps: - name: Resolve refs and target PR id: resolve - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const defaultBaseline = "0bf06e953fdda290799fc9fb9244a8f67fdae593"; @@ -437,8 +437,17 @@ jobs: echo "::warning::Could not generate motion-trimmed desktop previews; continuing with screenshots and full MP4 links." fi - baseline_status="$(jq -r '.scenarios[0].status' "$root/baseline/discord-qa-summary.json")" - candidate_status="$(jq -r '.scenarios[0].status' "$root/candidate/discord-qa-summary.json")" + read_discord_status_reaction_status() { + local lane="$1" + if [[ -f "$root/$lane/qa-evidence.json" ]]; then + jq -r '.entries[0].result.status' "$root/$lane/qa-evidence.json" + return + fi + jq -r '.scenarios[0].status' "$root/$lane/discord-qa-summary.json" + } + + baseline_status="$(read_discord_status_reaction_status baseline)" + candidate_status="$(read_discord_status_reaction_status candidate)" jq -n \ --arg baseline_status "$baseline_status" \ @@ -581,7 +590,7 @@ jobs: issues: write steps: - name: Remove workflow eyes reaction - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const { owner, repo } = context.repo; diff --git a/.github/workflows/mantis-discord-thread-attachment.yml b/.github/workflows/mantis-discord-thread-attachment.yml index c896c5b61381..49d43896f603 100644 --- a/.github/workflows/mantis-discord-thread-attachment.yml +++ b/.github/workflows/mantis-discord-thread-attachment.yml @@ -56,7 +56,7 @@ jobs: steps: - name: Require maintainer-level repository access id: permission - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const allowed = new Set(["admin", "maintain", "write"]); @@ -91,7 +91,7 @@ jobs: steps: - name: Resolve refs and target PR id: resolve - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const defaultBaseline = "synthetic-reverted-thread-filepath-fix"; @@ -451,8 +451,17 @@ jobs: capture_candidate_discord_web - baseline_status="$(jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/baseline/discord-qa-summary.json")" - candidate_status="$(jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/candidate/discord-qa-summary.json")" + read_discord_thread_attachment_status() { + local lane="$1" + if [[ -f "$root/$lane/qa-evidence.json" ]]; then + jq -r '.entries[] | select(.test.id == "discord-thread-reply-filepath-attachment") | .result.status' "$root/$lane/qa-evidence.json" + return + fi + jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/$lane/discord-qa-summary.json" + } + + baseline_status="$(read_discord_thread_attachment_status baseline)" + candidate_status="$(read_discord_thread_attachment_status candidate)" comparison_status="fail" if [[ "$baseline_status" == "fail" && "$candidate_status" == "pass" ]]; then comparison_status="pass" @@ -603,7 +612,7 @@ jobs: issues: write steps: - name: Remove workflow eyes reaction - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const { owner, repo } = context.repo; diff --git a/.github/workflows/mantis-slack-desktop-smoke.yml b/.github/workflows/mantis-slack-desktop-smoke.yml index 0f8927049d7b..cb27624a5e1b 100644 --- a/.github/workflows/mantis-slack-desktop-smoke.yml +++ b/.github/workflows/mantis-slack-desktop-smoke.yml @@ -81,7 +81,7 @@ jobs: steps: - name: Require maintainer-level repository access id: permission - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const allowed = new Set(["admin", "maintain", "write"]); @@ -180,7 +180,7 @@ jobs: run: pnpm build - name: Cache Mantis candidate pnpm store - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.local/share/pnpm/store diff --git a/.github/workflows/mantis-telegram-desktop-proof.yml b/.github/workflows/mantis-telegram-desktop-proof.yml index a0f7ab6f4ba5..390508084fab 100644 --- a/.github/workflows/mantis-telegram-desktop-proof.yml +++ b/.github/workflows/mantis-telegram-desktop-proof.yml @@ -79,7 +79,7 @@ jobs: steps: - name: Require maintainer-level repository access id: permission - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | if (context.eventName === "pull_request_target") { @@ -125,7 +125,7 @@ jobs: steps: - name: Resolve refs and target PR id: resolve - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const eventName = context.eventName; @@ -445,7 +445,7 @@ jobs: sudo chown -R codex:codex "$GITHUB_WORKSPACE" - name: Run Codex Mantis Telegram agent - uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02 + uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1 env: BASELINE_REF: ${{ needs.resolve_request.outputs.baseline_ref }} BASELINE_SHA: ${{ needs.validate_refs.outputs.baseline_revision }} @@ -709,7 +709,7 @@ jobs: issues: write steps: - name: Remove workflow eyes reaction - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const { owner, repo } = context.repo; diff --git a/.github/workflows/mantis-telegram-live.yml b/.github/workflows/mantis-telegram-live.yml index dd20ce628c21..0fbb9c8eea4d 100644 --- a/.github/workflows/mantis-telegram-live.yml +++ b/.github/workflows/mantis-telegram-live.yml @@ -68,7 +68,7 @@ jobs: steps: - name: Require maintainer-level repository access id: permission - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const allowed = new Set(["admin", "maintain", "write"]); @@ -105,7 +105,7 @@ jobs: steps: - name: Resolve refs and target PR id: resolve - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const eventName = context.eventName; @@ -327,7 +327,7 @@ jobs: run: pnpm build - name: Cache Mantis candidate pnpm store - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.local/share/pnpm/store @@ -379,7 +379,6 @@ jobs: OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }} OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000" OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1" - OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1" CRABBOX_COORDINATOR: ${{ secrets.CRABBOX_COORDINATOR }} CRABBOX_COORDINATOR_TOKEN: ${{ secrets.CRABBOX_COORDINATOR_TOKEN }} OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR: ${{ secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR }} @@ -445,8 +444,8 @@ jobs: telegram_exit=$? set -e - if [[ ! -f "$root/telegram-qa-summary.json" ]]; then - echo "Telegram live QA did not produce a summary." >&2 + if [[ ! -f "$root/qa-evidence.json" && ! -f "$root/telegram-qa-summary.json" ]]; then + echo "Telegram live QA did not produce an evidence summary." >&2 exit "$telegram_exit" fi echo "telegram_exit=${telegram_exit}" >> "$GITHUB_OUTPUT" @@ -573,7 +572,7 @@ jobs: issues: write steps: - name: Remove workflow eyes reaction - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const { owner, repo } = context.repo; diff --git a/.github/workflows/npm-telegram-beta-e2e.yml b/.github/workflows/npm-telegram-beta-e2e.yml index a82250731e0f..e88a128c638a 100644 --- a/.github/workflows/npm-telegram-beta-e2e.yml +++ b/.github/workflows/npm-telegram-beta-e2e.yml @@ -126,7 +126,7 @@ jobs: fetch-depth: 1 - name: Set up Blacksmith Docker Builder - uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1 + uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1 with: max-cache-size-mb: 800000 @@ -220,7 +220,6 @@ jobs: OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }} OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000" OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1" - OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1" INPUT_SCENARIO: ${{ inputs.scenario }} PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || '' }} run: | diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml index 344d1320df6c..b924d9b054d3 100644 --- a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml +++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml @@ -420,6 +420,7 @@ jobs: add_suite live-cache add_profile_suite native-live-src-agents "stable full" + add_profile_suite native-live-src-agents-zai-coding "stable full" add_profile_suite native-live-src-gateway-core "beta minimum stable full" add_profile_suite native-live-src-gateway-profiles-anthropic "stable full" add_profile_suite native-live-src-gateway-profiles-anthropic-smoke "stable" @@ -887,7 +888,7 @@ jobs: summary=".artifacts/docker-tests/release-${DOCKER_E2E_CHUNK}/summary.json" if [[ ! -f "$summary" ]]; then echo "Docker chunk summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY" - exit 0 + exit 1 fi node .release-harness/scripts/docker-e2e.mjs summary "$summary" "Docker E2E chunk: ${DOCKER_E2E_CHUNK:-unknown}" >> "$GITHUB_STEP_SUMMARY" @@ -897,7 +898,7 @@ jobs: with: name: docker-e2e-${{ matrix.chunk_id }} path: .artifacts/docker-tests/ - if-no-files-found: ignore + if-no-files-found: error plan_docker_lane_groups: needs: validate_selected_ref @@ -1147,7 +1148,7 @@ jobs: summary=".artifacts/docker-tests/targeted-${{ steps.plan.outputs.artifact_suffix }}/summary.json" if [[ ! -f "$summary" ]]; then echo "Docker targeted summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY" - exit 0 + exit 1 fi node .release-harness/scripts/docker-e2e.mjs summary "$summary" "Docker E2E targeted lanes" >> "$GITHUB_STEP_SUMMARY" @@ -1157,7 +1158,7 @@ jobs: with: name: docker-e2e-${{ steps.plan.outputs.artifact_suffix }} path: .artifacts/docker-tests/ - if-no-files-found: ignore + if-no-files-found: error validate_docker_openwebui: needs: [validate_selected_ref, prepare_docker_e2e_image] @@ -1274,7 +1275,7 @@ jobs: summary=".artifacts/docker-tests/release-openwebui/summary.json" if [[ ! -f "$summary" ]]; then echo "Docker Open WebUI summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY" - exit 0 + exit 1 fi node .release-harness/scripts/docker-e2e.mjs summary "$summary" "Docker E2E chunk: openwebui" >> "$GITHUB_STEP_SUMMARY" @@ -1284,7 +1285,7 @@ jobs: with: name: docker-e2e-openwebui path: .artifacts/docker-tests/ - if-no-files-found: ignore + if-no-files-found: error prepare_docker_e2e_image: needs: validate_selected_ref @@ -1497,37 +1498,72 @@ jobs: - name: Setup Docker builder if: steps.image_exists.outputs.needs_build == '1' - uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1 + uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1 with: max-cache-size-mb: 800000 - name: Build and push bare Docker E2E image if: steps.plan.outputs.needs_bare_image == '1' && steps.image_exists.outputs.bare_exists != '1' - uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2 - with: - context: . - file: ./scripts/e2e/Dockerfile - target: bare - platforms: linux/amd64 - tags: ${{ steps.image.outputs.bare_image }} - sbom: true - provenance: mode=max - push: true + shell: bash + env: + IMAGE_REF: ${{ steps.image.outputs.bare_image }} + run: | + set -euo pipefail + build_cmd=( + docker buildx build + --file ./scripts/e2e/Dockerfile + --target bare + --platform linux/amd64 + --tag "$IMAGE_REF" + --sbom=true + --provenance=mode=max + --push + . + ) + for attempt in 1 2 3 4; do + if "${build_cmd[@]}"; then + exit 0 + fi + if [[ "$attempt" == "4" ]]; then + echo "::error::Failed to build Docker E2E bare image after ${attempt} attempts" + exit 1 + fi + sleep_seconds=$((attempt * 20)) + echo "Docker E2E bare image build failed; retrying in ${sleep_seconds}s (${attempt}/4)." + sleep "$sleep_seconds" + done - name: Build and push functional Docker E2E image if: steps.plan.outputs.needs_functional_image == '1' && steps.image_exists.outputs.functional_exists != '1' - uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2 - with: - context: . - file: ./scripts/e2e/Dockerfile - target: functional - build-contexts: | - openclaw_package=.artifacts/docker-e2e-package - platforms: linux/amd64 - tags: ${{ steps.image.outputs.functional_image }} - sbom: true - provenance: mode=max - push: true + shell: bash + env: + IMAGE_REF: ${{ steps.image.outputs.functional_image }} + run: | + set -euo pipefail + build_cmd=( + docker buildx build + --file ./scripts/e2e/Dockerfile + --target functional + --build-context openclaw_package=.artifacts/docker-e2e-package + --platform linux/amd64 + --tag "$IMAGE_REF" + --sbom=true + --provenance=mode=max + --push + . + ) + for attempt in 1 2 3 4; do + if "${build_cmd[@]}"; then + exit 0 + fi + if [[ "$attempt" == "4" ]]; then + echo "::error::Failed to build Docker E2E functional image after ${attempt} attempts" + exit 1 + fi + sleep_seconds=$((attempt * 20)) + echo "Docker E2E functional image build failed; retrying in ${sleep_seconds}s (${attempt}/4)." + sleep "$sleep_seconds" + done prepare_live_test_image: needs: validate_selected_ref @@ -1558,8 +1594,11 @@ jobs: run: | set -euo pipefail repository="${GITHUB_REPOSITORY,,}" - live_image="ghcr.io/${repository}-live-test:${SELECTED_SHA}" + live_image_extensions="matrix,acpx" + live_image_tag_suffix="${live_image_extensions//,/-}" + live_image="ghcr.io/${repository}-live-test:${SELECTED_SHA}-${live_image_tag_suffix}" echo "live_image=${live_image}" >> "$GITHUB_OUTPUT" + echo "live_image_extensions=${live_image_extensions}" >> "$GITHUB_OUTPUT" echo "Shared live-test image: \`${live_image}\`" >> "$GITHUB_STEP_SUMMARY" - name: Log in to GHCR @@ -1582,7 +1621,7 @@ jobs: - name: Setup Docker builder if: steps.image_exists.outputs.exists != '1' - uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1 + uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1 with: max-cache-size-mb: 800000 @@ -1594,7 +1633,7 @@ jobs: file: ./Dockerfile target: build build-args: | - OPENCLAW_EXTENSIONS=matrix + OPENCLAW_EXTENSIONS=${{ steps.image.outputs.live_image_extensions }} platforms: linux/amd64 tags: ${{ steps.image.outputs.live_image }} sbom: true @@ -1710,6 +1749,7 @@ jobs: anthropic) require_any Anthropic ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;; google) require_any Google GEMINI_API_KEY GOOGLE_API_KEY ;; minimax) require_any MiniMax MINIMAX_API_KEY ;; + moonshot) require_any Moonshot MOONSHOT_API_KEY KIMI_API_KEY ;; openai) require_any OpenAI OPENAI_API_KEY ;; opencode-go) require_any OpenCode OPENCODE_API_KEY OPENCODE_ZEN_API_KEY ;; openrouter) require_any OpenRouter OPENROUTER_API_KEY ;; @@ -1798,7 +1838,7 @@ jobs: run: | set -euo pipefail - all_providers=(anthropic google minimax openai opencode-go openrouter xai zai fireworks) + all_providers=(anthropic google minimax moonshot openai opencode-go openrouter xai zai fireworks) normalize_provider() { local value="${1,,}" @@ -1884,6 +1924,7 @@ jobs: anthropic) require_any Anthropic ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;; google) require_any Google GEMINI_API_KEY GOOGLE_API_KEY ;; minimax) require_any MiniMax MINIMAX_API_KEY ;; + moonshot) require_any Moonshot MOONSHOT_API_KEY KIMI_API_KEY ;; openai) require_any OpenAI OPENAI_API_KEY ;; opencode-go) require_any OpenCode OPENCODE_API_KEY OPENCODE_ZEN_API_KEY ;; openrouter) require_any OpenRouter OPENROUTER_API_KEY ;; @@ -1916,9 +1957,15 @@ jobs: timeout_minutes: 60 profile_env_only: false profiles: stable full + - suite_id: native-live-src-agents-zai-coding + label: Native live Z.AI Coding Plan + command: ZAI_CODING_LIVE_TEST=1 node .release-harness/scripts/test-live-shard.mjs native-live-src-agents-zai-coding + timeout_minutes: 15 + profile_env_only: false + profiles: stable full - suite_id: native-live-src-gateway-core label: Native live gateway core - command: node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-core + command: OPENCLAW_LIVE_CODEX_HARNESS=1 OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-core timeout_minutes: 60 profile_env_only: false profiles: beta minimum stable full @@ -2038,7 +2085,7 @@ jobs: profiles: full - suite_id: native-live-src-gateway-backends label: Native live gateway backends - command: node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-backends + command: OPENCLAW_LIVE_CODEX_HARNESS=1 OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-backends timeout_minutes: 60 profile_env_only: false profiles: stable full diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index bbfb6bb3e0f0..865b73cecdf4 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -391,7 +391,7 @@ jobs: tideclaw_alpha_publish=true fi if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ "${tideclaw_alpha_publish}" != "true" ]]; then - echo "Real publish runs must be dispatched from main, release/YYYY.M.D, or a Tideclaw alpha branch for alpha prereleases. Use preflight_only=true for other branch validation." + echo "Real publish runs must be dispatched from main, release/YYYY.M.PATCH, or a Tideclaw alpha branch for alpha prereleases. Use preflight_only=true for other branch validation." exit 1 fi diff --git a/.github/workflows/openclaw-performance.yml b/.github/workflows/openclaw-performance.yml index 2c18f281467e..e6eb81e0b123 100644 --- a/.github/workflows/openclaw-performance.yml +++ b/.github/workflows/openclaw-performance.yml @@ -244,8 +244,8 @@ jobs: run: | set -euo pipefail if [[ -z "${OPENAI_API_KEY:-}" ]]; then - echo "OPENAI_API_KEY is not configured; live GPT 5.5 lane will be skipped." >> "$GITHUB_STEP_SUMMARY" - exit 0 + echo "OPENAI_API_KEY is not configured; live GPT 5.5 lane cannot run without live evidence." >> "$GITHUB_STEP_SUMMARY" + exit 1 fi kova setup --ci --json kova setup --non-interactive --auth env-only --provider openai --env-var OPENAI_API_KEY --json @@ -262,11 +262,6 @@ jobs: set -euo pipefail mkdir -p "$REPORT_DIR" "$BUNDLE_DIR" "$SUMMARY_DIR" - if [[ "$MATRIX_LIVE" == "true" && -z "${OPENAI_API_KEY:-}" ]]; then - echo "skipped=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - repeat="$REQUESTED_REPEAT" if [[ "$MATRIX_REPEAT" != "input" ]]; then repeat="$MATRIX_REPEAT" @@ -309,24 +304,7 @@ jobs: report_md="${report_json%.json}.md" effective_status="$status" if [[ "$FAIL_ON_REGRESSION" == "true" && "$status" != "0" ]]; then - if REPORT_JSON="$report_json" node <<'NODE' - const fs = require("node:fs"); - const report = JSON.parse(fs.readFileSync(process.env.REPORT_JSON, "utf8")); - const statuses = report.summary?.statuses ?? {}; - const nonPassStatuses = Object.entries(statuses) - .filter(([status, count]) => status !== "PASS" && Number(count) > 0); - const baselineRegressionCount = - Number(report.baseline?.comparison?.regressionCount ?? report.gate?.baseline?.regressionCount ?? 0); - const gate = report.gate; - const toleratedPartial = - gate?.verdict === "PARTIAL" && - Number(gate.blockingCount ?? 0) === 0 && - baselineRegressionCount === 0 && - nonPassStatuses.length === 0; - if (!toleratedPartial) { - process.exit(1); - } - NODE + if node "$PERFORMANCE_HELPER_DIR/scripts/lib/kova-report-gate.mjs" "$report_json" then effective_status=0 { @@ -377,6 +355,28 @@ jobs: exit "$effective_status" fi + - name: Validate Kova evidence + if: ${{ always() && steps.lane.outputs.run == 'true' }} + shell: bash + run: | + set -euo pipefail + missing=0 + if ! find "$REPORT_DIR" -maxdepth 1 -type f -name '*.json' -size +0c -print -quit | grep -q .; then + echo "::error::Kova JSON report is missing for ${LANE_ID}." + missing=1 + fi + if [[ ! -s "$BUNDLE_DIR/bundle.json" ]]; then + echo "::error::Kova bundle evidence is missing for ${LANE_ID}." + missing=1 + fi + if [[ ! -s "$SUMMARY_DIR/${LANE_ID}.md" ]]; then + echo "::error::Kova summary evidence is missing for ${LANE_ID}." + missing=1 + fi + if [[ "$missing" != "0" ]]; then + exit 1 + fi + - name: Fetch previous source performance baseline if: ${{ steps.lane.outputs.run == 'true' && matrix.lane == 'mock-provider' && steps.clawgrit.outputs.present == 'true' }} env: @@ -527,6 +527,13 @@ jobs: cleanup_gateway trap - EXIT + if node -e "const fs=require('node:fs'); const scripts=require('./package.json').scripts||{}; process.exit(scripts['test:sqlite:perf:smoke'] && fs.existsSync('scripts/bench-sqlite-state.ts') ? 0 : 1)"; then + pnpm test:sqlite:perf:smoke + cp .artifacts/sqlite-perf/smoke.json "$SOURCE_PERF_DIR/sqlite-perf-smoke.json" + else + echo "SQLite state smoke probe is not available in ${TESTED_REF}; continuing with the remaining source probes." >> "$GITHUB_STEP_SUMMARY" + fi + summary_args=(node "$PERFORMANCE_HELPER_DIR/scripts/openclaw-performance-source-summary.mjs" \ --source-dir "$SOURCE_PERF_DIR" \ --output "$SOURCE_PERF_DIR/index.md") @@ -547,7 +554,7 @@ jobs: .artifacts/kova/bundles/${{ matrix.lane }} .artifacts/kova/summaries/${{ matrix.lane }}.md .artifacts/openclaw-performance/source/${{ matrix.lane }} - if-no-files-found: ignore + if-no-files-found: error retention-days: ${{ matrix.deep_profile == 'true' && 14 || 30 }} - name: Prepare clawgrit reports checkout @@ -604,7 +611,7 @@ jobs: ## Source probes - Additional gateway boot, memory, plugin pressure, mock hello-loop, and CLI startup numbers are in [source/index.md](source/index.md). + Additional gateway boot, memory, plugin pressure, mock hello-loop, CLI startup, and SQLite state smoke numbers are in [source/index.md](source/index.md). EOF fi fi diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index 4e02edc7a020..80fbadc337ae 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -132,7 +132,7 @@ jobs: fi fi if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release-ci/[0-9a-f]{12}-[0-9]+$ ]] && [[ "${tideclaw_alpha_check}" != "true" ]]; then - echo "Release checks must be dispatched from main, release/YYYY.M.D, a Full Release Validation release-ci/- ref, or a Tideclaw alpha branch for alpha prereleases." >&2 + echo "Release checks must be dispatched from main, release/YYYY.M.PATCH, a Full Release Validation release-ci/- ref, or a Tideclaw alpha branch for alpha prereleases." >&2 exit 1 fi @@ -346,6 +346,7 @@ jobs: discord_selected=false whatsapp_selected=false slack_selected=false + disabled_required_lanes=() IFS=', ' read -r -a filter_tokens <<< "$filter" for token in "${filter_tokens[@]}"; do @@ -361,6 +362,9 @@ jobs: discord_selected="$qa_live_discord_ci_enabled" whatsapp_selected="$qa_live_whatsapp_ci_enabled" slack_selected="$qa_live_slack_ci_enabled" + [[ "$qa_live_discord_ci_enabled" == "true" ]] || disabled_required_lanes+=("qa-live-discord") + [[ "$qa_live_whatsapp_ci_enabled" == "true" ]] || disabled_required_lanes+=("qa-live-whatsapp") + [[ "$qa_live_slack_ci_enabled" == "true" ]] || disabled_required_lanes+=("qa-live-slack") ;; qa-live-non-slack|qa-non-slack|non-slack|no-slack|without-slack) qa_filter_seen=true @@ -368,6 +372,8 @@ jobs: telegram_selected=true discord_selected="$qa_live_discord_ci_enabled" whatsapp_selected="$qa_live_whatsapp_ci_enabled" + [[ "$qa_live_discord_ci_enabled" == "true" ]] || disabled_required_lanes+=("qa-live-discord") + [[ "$qa_live_whatsapp_ci_enabled" == "true" ]] || disabled_required_lanes+=("qa-live-whatsapp") ;; qa-live-matrix|qa-matrix|matrix) qa_filter_seen=true @@ -380,18 +386,27 @@ jobs: qa-live-discord|qa-discord|discord) qa_filter_seen=true discord_selected="$qa_live_discord_ci_enabled" + [[ "$qa_live_discord_ci_enabled" == "true" ]] || disabled_required_lanes+=("qa-live-discord") ;; qa-live-whatsapp|qa-whatsapp|whatsapp) qa_filter_seen=true whatsapp_selected="$qa_live_whatsapp_ci_enabled" + [[ "$qa_live_whatsapp_ci_enabled" == "true" ]] || disabled_required_lanes+=("qa-live-whatsapp") ;; qa-live-slack|qa-slack|slack) qa_filter_seen=true slack_selected="$qa_live_slack_ci_enabled" + [[ "$qa_live_slack_ci_enabled" == "true" ]] || disabled_required_lanes+=("qa-live-slack") ;; esac done + if [[ "${#disabled_required_lanes[@]}" -gt 0 ]]; then + echo "live_suite_filter explicitly requested disabled QA live lane(s): ${disabled_required_lanes[*]}" >&2 + echo "Enable the matching OPENCLAW_RELEASE_QA_*_LIVE_CI_ENABLED repo variable or remove the lane from live_suite_filter." >&2 + exit 1 + fi + if [[ "$qa_filter_seen" == "true" ]]; then qa_live_matrix_enabled="$matrix_selected" qa_live_telegram_enabled="$telegram_selected" @@ -801,6 +816,7 @@ jobs: run: node scripts/build-all.mjs qaRuntime - name: Run parity lane + id: run_lane env: QA_PARITY_LANE: ${{ matrix.lane }} QA_PARITY_OUTPUT_DIR: ${{ matrix.output_dir }} @@ -831,6 +847,7 @@ jobs: --output-dir ".artifacts/qa-e2e/${QA_PARITY_OUTPUT_DIR}" - name: Upload parity lane artifacts + id: upload_parity_lane_artifacts if: always() uses: actions/upload-artifact@v7 with: @@ -839,6 +856,52 @@ jobs: retention-days: 14 if-no-files-found: warn + - name: Record advisory status + if: always() + shell: bash + env: + RELEASE_CHECK_JOB: qa_lab_parity_lane_release_checks + RELEASE_CHECK_VARIANT: ${{ matrix.lane }} + JOB_STATUS: ${{ job.status }} + RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_lane.outcome }} ${{ steps.upload_parity_lane_artifacts.outcome }} + run: | + set -euo pipefail + status="success" + mark_status() { + case "$1" in + failure) status="failure" ;; + cancelled) + if [[ "$status" != "failure" ]]; then + status="cancelled" + fi + ;; + success|skipped|"") ;; + *) status="failure" ;; + esac + } + mark_status "${JOB_STATUS:-}" + for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do + mark_status "$outcome" + done + mkdir -p .artifacts/release-check-status + status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}-${RELEASE_CHECK_VARIANT}.env" + { + printf 'job=%s\n' "$RELEASE_CHECK_JOB" + printf 'variant=%s\n' "$RELEASE_CHECK_VARIANT" + printf 'status=%s\n' "$status" + printf 'job_status=%s\n' "${JOB_STATUS:-}" + printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}" + } > "$status_path" + + - name: Upload advisory status + if: always() + uses: actions/upload-artifact@v7 + with: + name: release-check-status-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.revision }} + path: .artifacts/release-check-status/qa_lab_parity_lane_release_checks-${{ matrix.lane }}.env + retention-days: 14 + if-no-files-found: error + qa_lab_parity_report_release_checks: name: Run QA Lab parity report needs: [resolve_target, qa_lab_parity_lane_release_checks] @@ -879,6 +942,7 @@ jobs: run: node scripts/build-all.mjs qaRuntime - name: Generate parity report + id: generate_report run: | pnpm openclaw qa parity-report \ --repo-root . \ @@ -889,6 +953,7 @@ jobs: --output-dir .artifacts/qa-e2e/parity - name: Upload parity artifacts + id: upload_parity_artifacts if: always() uses: actions/upload-artifact@v7 with: @@ -897,6 +962,50 @@ jobs: retention-days: 14 if-no-files-found: warn + - name: Record advisory status + if: always() + shell: bash + env: + RELEASE_CHECK_JOB: qa_lab_parity_report_release_checks + JOB_STATUS: ${{ job.status }} + RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.generate_report.outcome }} ${{ steps.upload_parity_artifacts.outcome }} + run: | + set -euo pipefail + status="success" + mark_status() { + case "$1" in + failure) status="failure" ;; + cancelled) + if [[ "$status" != "failure" ]]; then + status="cancelled" + fi + ;; + success|skipped|"") ;; + *) status="failure" ;; + esac + } + mark_status "${JOB_STATUS:-}" + for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do + mark_status "$outcome" + done + mkdir -p .artifacts/release-check-status + status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env" + { + printf 'job=%s\n' "$RELEASE_CHECK_JOB" + printf 'status=%s\n' "$status" + printf 'job_status=%s\n' "${JOB_STATUS:-}" + printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}" + } > "$status_path" + + - name: Upload advisory status + if: always() + uses: actions/upload-artifact@v7 + with: + name: release-check-status-qa-parity-report-${{ needs.resolve_target.outputs.revision }} + path: .artifacts/release-check-status/qa_lab_parity_report_release_checks.env + retention-days: 14 + if-no-files-found: error + qa_lab_runtime_parity_release_checks: name: Run QA Lab runtime parity lane needs: [resolve_target] @@ -950,6 +1059,7 @@ jobs: --output-dir ".artifacts/qa-e2e/runtime-parity" - name: Run standard runtime parity tier + id: runtime_parity_standard_lane if: ${{ always() && steps.runtime_parity_lane.outcome != 'skipped' && steps.runtime_parity_lane.outcome != 'cancelled' }} run: | set -euo pipefail @@ -977,6 +1087,7 @@ jobs: --output-dir ".artifacts/qa-e2e/runtime-parity-soak" - name: Generate runtime parity report + id: generate_runtime_parity_report if: always() run: | set -euo pipefail @@ -987,6 +1098,7 @@ jobs: --output-dir .artifacts/qa-e2e/runtime-parity-report - name: Generate standard runtime parity report + id: generate_runtime_parity_standard_report if: always() run: | set -euo pipefail @@ -997,6 +1109,7 @@ jobs: --output-dir .artifacts/qa-e2e/runtime-parity-standard-report - name: Generate soak runtime parity report + id: generate_runtime_parity_soak_report if: ${{ always() && needs.resolve_target.outputs.run_release_soak == 'true' && steps.runtime_parity_soak_lane.outcome != 'skipped' && steps.runtime_parity_soak_lane.outcome != 'cancelled' }} run: | set -euo pipefail @@ -1012,6 +1125,7 @@ jobs: --output-dir .artifacts/qa-e2e/runtime-parity-soak-report - name: Upload runtime parity artifacts + id: upload_runtime_parity_artifacts if: always() uses: actions/upload-artifact@v7 with: @@ -1020,10 +1134,54 @@ jobs: retention-days: 14 if-no-files-found: warn + - name: Record advisory status + if: always() + shell: bash + env: + RELEASE_CHECK_JOB: qa_lab_runtime_parity_release_checks + JOB_STATUS: ${{ job.status }} + RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.runtime_parity_lane.outcome }} ${{ steps.runtime_parity_standard_lane.outcome }} ${{ steps.runtime_parity_soak_lane.outcome }} ${{ steps.generate_runtime_parity_report.outcome }} ${{ steps.generate_runtime_parity_standard_report.outcome }} ${{ steps.generate_runtime_parity_soak_report.outcome }} ${{ steps.upload_runtime_parity_artifacts.outcome }} + run: | + set -euo pipefail + status="success" + mark_status() { + case "$1" in + failure) status="failure" ;; + cancelled) + if [[ "$status" != "failure" ]]; then + status="cancelled" + fi + ;; + success|skipped|"") ;; + *) status="failure" ;; + esac + } + mark_status "${JOB_STATUS:-}" + for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do + mark_status "$outcome" + done + mkdir -p .artifacts/release-check-status + status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env" + { + printf 'job=%s\n' "$RELEASE_CHECK_JOB" + printf 'status=%s\n' "$status" + printf 'job_status=%s\n' "${JOB_STATUS:-}" + printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}" + } > "$status_path" + + - name: Upload advisory status + if: always() + uses: actions/upload-artifact@v7 + with: + name: release-check-status-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }} + path: .artifacts/release-check-status/qa_lab_runtime_parity_release_checks.env + retention-days: 14 + if-no-files-found: error + runtime_tool_coverage_release_checks: name: Enforce QA Lab runtime tool coverage needs: [resolve_target, qa_lab_runtime_parity_release_checks] - if: always() && contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group) + if: contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group) runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: @@ -1046,13 +1204,35 @@ jobs: node-version: ${{ env.NODE_VERSION }} install-bun: "true" + - name: Download runtime parity status + uses: actions/download-artifact@v8 + with: + name: release-check-status-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }} + path: .artifacts/release-check-status/ + + - name: Verify runtime parity producer status + id: verify_runtime_parity_status + shell: bash + run: | + set -euo pipefail + status_path=".artifacts/release-check-status/qa_lab_runtime_parity_release_checks.env" + status="$(sed -n 's/^status=//p' "$status_path" | tail -n 1)" + if [[ "$status" != "success" ]]; then + echo "Runtime parity producer status is ${status:-missing}; skipping coverage artifact consumer." + echo "ready=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "ready=true" >> "$GITHUB_OUTPUT" + - name: Download runtime parity artifacts + if: steps.verify_runtime_parity_status.outputs.ready == 'true' uses: actions/download-artifact@v8 with: name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }} path: .artifacts/qa-e2e/ - name: Enforce standard runtime tool coverage + if: steps.verify_runtime_parity_status.outputs.ready == 'true' run: | set -euo pipefail pnpm openclaw qa coverage \ @@ -1141,6 +1321,7 @@ jobs: done - name: Upload Matrix QA artifacts + id: upload_matrix_qa_artifacts if: always() uses: actions/upload-artifact@v7 with: @@ -1149,6 +1330,50 @@ jobs: retention-days: 14 if-no-files-found: warn + - name: Record advisory status + if: always() + shell: bash + env: + RELEASE_CHECK_JOB: qa_live_matrix_release_checks + JOB_STATUS: ${{ job.status }} + RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_lane.outcome }} ${{ steps.upload_matrix_qa_artifacts.outcome }} + run: | + set -euo pipefail + status="success" + mark_status() { + case "$1" in + failure) status="failure" ;; + cancelled) + if [[ "$status" != "failure" ]]; then + status="cancelled" + fi + ;; + success|skipped|"") ;; + *) status="failure" ;; + esac + } + mark_status "${JOB_STATUS:-}" + for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do + mark_status "$outcome" + done + mkdir -p .artifacts/release-check-status + status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env" + { + printf 'job=%s\n' "$RELEASE_CHECK_JOB" + printf 'status=%s\n' "$status" + printf 'job_status=%s\n' "${JOB_STATUS:-}" + printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}" + } > "$status_path" + + - name: Upload advisory status + if: always() + uses: actions/upload-artifact@v7 + with: + name: release-check-status-qa-live-matrix-${{ needs.resolve_target.outputs.revision }} + path: .artifacts/release-check-status/qa_live_matrix_release_checks.env + retention-days: 14 + if-no-files-found: error + qa_live_telegram_release_checks: name: Run QA Lab live Telegram lane needs: [resolve_target] @@ -1209,7 +1434,6 @@ jobs: OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }} OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000" OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1" - OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1" run: | set -euo pipefail @@ -1237,6 +1461,7 @@ jobs: done - name: Upload Telegram QA artifacts + id: upload_telegram_qa_artifacts if: always() uses: actions/upload-artifact@v7 with: @@ -1245,10 +1470,54 @@ jobs: retention-days: 14 if-no-files-found: warn + - name: Record advisory status + if: always() + shell: bash + env: + RELEASE_CHECK_JOB: qa_live_telegram_release_checks + JOB_STATUS: ${{ job.status }} + RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_lane.outcome }} ${{ steps.upload_telegram_qa_artifacts.outcome }} + run: | + set -euo pipefail + status="success" + mark_status() { + case "$1" in + failure) status="failure" ;; + cancelled) + if [[ "$status" != "failure" ]]; then + status="cancelled" + fi + ;; + success|skipped|"") ;; + *) status="failure" ;; + esac + } + mark_status "${JOB_STATUS:-}" + for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do + mark_status "$outcome" + done + mkdir -p .artifacts/release-check-status + status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env" + { + printf 'job=%s\n' "$RELEASE_CHECK_JOB" + printf 'status=%s\n' "$status" + printf 'job_status=%s\n' "${JOB_STATUS:-}" + printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}" + } > "$status_path" + + - name: Upload advisory status + if: always() + uses: actions/upload-artifact@v7 + with: + name: release-check-status-qa-live-telegram-${{ needs.resolve_target.outputs.revision }} + path: .artifacts/release-check-status/qa_live_telegram_release_checks.env + retention-days: 14 + if-no-files-found: error + qa_live_discord_release_checks: name: Run QA Lab live Discord lane needs: [resolve_target] - if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_discord_enabled == 'true' && vars.OPENCLAW_RELEASE_QA_DISCORD_LIVE_CI_ENABLED == 'true' + if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_discord_enabled == 'true' continue-on-error: true runs-on: ubuntu-24.04 timeout-minutes: 60 @@ -1332,6 +1601,7 @@ jobs: done - name: Upload Discord QA artifacts + id: upload_discord_qa_artifacts if: always() uses: actions/upload-artifact@v7 with: @@ -1340,10 +1610,54 @@ jobs: retention-days: 14 if-no-files-found: warn + - name: Record advisory status + if: always() + shell: bash + env: + RELEASE_CHECK_JOB: qa_live_discord_release_checks + JOB_STATUS: ${{ job.status }} + RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_lane.outcome }} ${{ steps.upload_discord_qa_artifacts.outcome }} + run: | + set -euo pipefail + status="success" + mark_status() { + case "$1" in + failure) status="failure" ;; + cancelled) + if [[ "$status" != "failure" ]]; then + status="cancelled" + fi + ;; + success|skipped|"") ;; + *) status="failure" ;; + esac + } + mark_status "${JOB_STATUS:-}" + for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do + mark_status "$outcome" + done + mkdir -p .artifacts/release-check-status + status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env" + { + printf 'job=%s\n' "$RELEASE_CHECK_JOB" + printf 'status=%s\n' "$status" + printf 'job_status=%s\n' "${JOB_STATUS:-}" + printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}" + } > "$status_path" + + - name: Upload advisory status + if: always() + uses: actions/upload-artifact@v7 + with: + name: release-check-status-qa-live-discord-${{ needs.resolve_target.outputs.revision }} + path: .artifacts/release-check-status/qa_live_discord_release_checks.env + retention-days: 14 + if-no-files-found: error + qa_live_whatsapp_release_checks: name: Run QA Lab live WhatsApp lane needs: [resolve_target] - if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_whatsapp_enabled == 'true' && vars.OPENCLAW_RELEASE_QA_WHATSAPP_LIVE_CI_ENABLED == 'true' + if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_whatsapp_enabled == 'true' continue-on-error: true runs-on: ubuntu-24.04 timeout-minutes: 60 @@ -1430,6 +1744,7 @@ jobs: done - name: Upload WhatsApp QA artifacts + id: upload_whatsapp_qa_artifacts if: always() uses: actions/upload-artifact@v7 with: @@ -1438,10 +1753,54 @@ jobs: retention-days: 14 if-no-files-found: warn + - name: Record advisory status + if: always() + shell: bash + env: + RELEASE_CHECK_JOB: qa_live_whatsapp_release_checks + JOB_STATUS: ${{ job.status }} + RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_lane.outcome }} ${{ steps.upload_whatsapp_qa_artifacts.outcome }} + run: | + set -euo pipefail + status="success" + mark_status() { + case "$1" in + failure) status="failure" ;; + cancelled) + if [[ "$status" != "failure" ]]; then + status="cancelled" + fi + ;; + success|skipped|"") ;; + *) status="failure" ;; + esac + } + mark_status "${JOB_STATUS:-}" + for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do + mark_status "$outcome" + done + mkdir -p .artifacts/release-check-status + status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env" + { + printf 'job=%s\n' "$RELEASE_CHECK_JOB" + printf 'status=%s\n' "$status" + printf 'job_status=%s\n' "${JOB_STATUS:-}" + printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}" + } > "$status_path" + + - name: Upload advisory status + if: always() + uses: actions/upload-artifact@v7 + with: + name: release-check-status-qa-live-whatsapp-${{ needs.resolve_target.outputs.revision }} + path: .artifacts/release-check-status/qa_live_whatsapp_release_checks.env + retention-days: 14 + if-no-files-found: error + qa_live_slack_release_checks: name: Run QA Lab live Slack lane needs: [resolve_target] - if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_slack_enabled == 'true' && vars.OPENCLAW_RELEASE_QA_SLACK_LIVE_CI_ENABLED == 'true' + if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_slack_enabled == 'true' continue-on-error: true runs-on: ubuntu-24.04 timeout-minutes: 60 @@ -1525,6 +1884,7 @@ jobs: done - name: Upload Slack QA artifacts + id: upload_slack_qa_artifacts if: always() uses: actions/upload-artifact@v7 with: @@ -1533,6 +1893,50 @@ jobs: retention-days: 14 if-no-files-found: warn + - name: Record advisory status + if: always() + shell: bash + env: + RELEASE_CHECK_JOB: qa_live_slack_release_checks + JOB_STATUS: ${{ job.status }} + RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_lane.outcome }} ${{ steps.upload_slack_qa_artifacts.outcome }} + run: | + set -euo pipefail + status="success" + mark_status() { + case "$1" in + failure) status="failure" ;; + cancelled) + if [[ "$status" != "failure" ]]; then + status="cancelled" + fi + ;; + success|skipped|"") ;; + *) status="failure" ;; + esac + } + mark_status "${JOB_STATUS:-}" + for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do + mark_status "$outcome" + done + mkdir -p .artifacts/release-check-status + status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env" + { + printf 'job=%s\n' "$RELEASE_CHECK_JOB" + printf 'status=%s\n' "$status" + printf 'job_status=%s\n' "${JOB_STATUS:-}" + printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}" + } > "$status_path" + + - name: Upload advisory status + if: always() + uses: actions/upload-artifact@v7 + with: + name: release-check-status-qa-live-slack-${{ needs.resolve_target.outputs.revision }} + path: .artifacts/release-check-status/qa_live_slack_release_checks.env + retention-days: 14 + if-no-files-found: error + summary: name: Verify release checks needs: @@ -1553,9 +1957,19 @@ jobs: - qa_live_slack_release_checks if: always() runs-on: ubuntu-24.04 - permissions: {} + permissions: + actions: read timeout-minutes: 5 steps: + - name: Download advisory status artifacts + if: always() + continue-on-error: true + uses: actions/download-artifact@v8 + with: + pattern: release-check-status-* + path: .artifacts/release-check-status + merge-multiple: true + - name: Verify release check results shell: bash env: @@ -1567,6 +1981,49 @@ jobs: if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then tideclaw_alpha=true fi + release_check_result() { + local name="$1" + local fallback="$2" + local status_dir=".artifacts/release-check-status" + local saw=0 + local saw_failure=0 + local saw_cancelled=0 + if [[ -d "$status_dir" ]]; then + while IFS= read -r -d '' file; do + saw=1 + status="$(sed -n 's/^status=//p' "$file" | tail -n 1)" + case "$status" in + success|skipped) ;; + cancelled) saw_cancelled=1 ;; + failure|"") saw_failure=1 ;; + *) saw_failure=1 ;; + esac + done < <(find "$status_dir" -type f -name "${name}*.env" -print0) + fi + if [[ "$saw_failure" == "1" ]]; then + printf 'failure\n' + elif [[ "$saw_cancelled" == "1" ]]; then + printf 'cancelled\n' + elif [[ "$fallback" != "success" && "$fallback" != "skipped" ]]; then + printf '%s\n' "$fallback" + elif [[ "$saw" == "1" ]]; then + printf 'success\n' + elif [[ "$fallback" == "success" ]]; then + printf 'failure\n' + else + printf '%s\n' "$fallback" + fi + } + advisory_status_override_allowed() { + case "$1" in + qa_lab_parity_lane_release_checks|qa_lab_parity_report_release_checks|qa_lab_runtime_parity_release_checks|qa_live_matrix_release_checks|qa_live_telegram_release_checks|qa_live_discord_release_checks|qa_live_whatsapp_release_checks|qa_live_slack_release_checks) + return 0 + ;; + *) + return 1 + ;; + esac + } for item in \ "prepare_release_package=${{ needs.prepare_release_package.result }}" \ "install_smoke_release_checks=${{ needs.install_smoke_release_checks.result }}" \ @@ -1585,7 +2042,12 @@ jobs: "qa_live_slack_release_checks=${{ needs.qa_live_slack_release_checks.result }}" do name="${item%%=*}" - result="${item#*=}" + raw_result="${item#*=}" + if advisory_status_override_allowed "$name"; then + result="$(release_check_result "$name" "$raw_result")" + else + result="$raw_result" + fi if [[ "$result" != "success" && "$result" != "skipped" ]]; then if [[ "$tideclaw_alpha" == "true" ]]; then case "$name" in @@ -1596,10 +2058,6 @@ jobs: ;; esac fi - if [[ "$name" == qa_* ]]; then - echo "::warning::${name} ended with ${result}; QA release-check lanes are advisory and do not block release validation." - continue - fi echo "::error::${name} ended with ${result}" failed=1 fi diff --git a/.github/workflows/openclaw-release-publish.yml b/.github/workflows/openclaw-release-publish.yml index b8fb1ec55c99..3a58135bef0b 100644 --- a/.github/workflows/openclaw-release-publish.yml +++ b/.github/workflows/openclaw-release-publish.yml @@ -15,6 +15,14 @@ on: description: Successful Full Release Validation run id for this tag/SHA, required when publish_openclaw_npm=true required: false type: string + windows_node_tag: + description: Exact openclaw-windows-node release tag, required for stable OpenClaw publish + required: false + type: string + windows_node_installer_digests: + description: Candidate-approved compact JSON map of Windows installer names to pinned sha256 digests + required: false + type: string npm_telegram_run_id: description: Optional successful NPM Telegram Beta E2E run id to include in final release evidence required: false @@ -81,12 +89,15 @@ jobs: outputs: sha: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }} preflight_artifact_name: ${{ steps.preflight_artifact.outputs.name }} + windows_node_installer_digests: ${{ steps.windows_source.outputs.installer_digests }} steps: - name: Validate inputs env: RELEASE_TAG: ${{ inputs.tag }} PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }} FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }} + WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }} + WINDOWS_NODE_INSTALLER_DIGESTS: ${{ inputs.windows_node_installer_digests }} PUBLISH_OPENCLAW_NPM: ${{ inputs.publish_openclaw_npm && 'true' || 'false' }} PLUGIN_PUBLISH_SCOPE: ${{ inputs.plugin_publish_scope }} PLUGINS: ${{ inputs.plugins }} @@ -115,12 +126,28 @@ jobs: echo "publish_openclaw_npm=true requires full_release_validation_run_id." >&2 exit 1 fi + stable_release=true + if [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then + stable_release=false + fi + if [[ -n "${WINDOWS_NODE_TAG}" && ! "${WINDOWS_NODE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]]; then + echo "windows_node_tag must be an explicit openclaw-windows-node release tag, not latest: ${WINDOWS_NODE_TAG}" >&2 + exit 1 + fi + if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${stable_release}" == "true" && -z "${WINDOWS_NODE_TAG}" ]]; then + echo "Stable OpenClaw publish requires an explicit windows_node_tag." >&2 + exit 1 + fi + if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${stable_release}" == "true" && -z "${WINDOWS_NODE_INSTALLER_DIGESTS}" ]]; then + echo "Stable OpenClaw publish requires candidate-approved windows_node_installer_digests." >&2 + exit 1 + fi tideclaw_alpha_publish=false if [[ "${RELEASE_TAG}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" == "alpha" && "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then tideclaw_alpha_publish=true fi if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${WORKFLOW_REF}" != "refs/heads/main" && ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ && "${tideclaw_alpha_publish}" != "true" ]]; then - echo "publish_openclaw_npm=true requires dispatching this workflow from main, release/YYYY.M.D, or a Tideclaw alpha branch for alpha prereleases." >&2 + echo "publish_openclaw_npm=true requires dispatching this workflow from main, release/YYYY.M.PATCH, or a Tideclaw alpha branch for alpha prereleases." >&2 exit 1 fi if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${PLUGIN_PUBLISH_SCOPE}" != "all-publishable" ]]; then @@ -143,6 +170,73 @@ jobs: ;; esac + - name: Validate stable Windows source release + id: windows_source + if: ${{ inputs.publish_openclaw_npm }} + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }} + APPROVED_INSTALLER_DIGESTS: ${{ inputs.windows_node_installer_digests }} + run: | + set -euo pipefail + if [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then + exit 0 + fi + + source_json="$(gh release view "${WINDOWS_NODE_TAG}" \ + --repo openclaw/openclaw-windows-node \ + --json tagName,isDraft,isPrerelease,assets,url)" + if [[ "$(printf '%s' "${source_json}" | jq -r '.tagName')" != "${WINDOWS_NODE_TAG}" ]]; then + echo "Windows source release tag does not match ${WINDOWS_NODE_TAG}." >&2 + exit 1 + fi + if [[ "$(printf '%s' "${source_json}" | jq -r '.isDraft')" == "true" ]]; then + echo "Stable OpenClaw publish requires a published Windows source release." >&2 + exit 1 + fi + if [[ "$(printf '%s' "${source_json}" | jq -r '.isPrerelease')" == "true" ]]; then + echo "Stable OpenClaw publish requires a non-prerelease Windows source release." >&2 + exit 1 + fi + + required_assets=( + "OpenClawCompanion-Setup-x64.exe" + "OpenClawCompanion-Setup-arm64.exe" + ) + required_assets_json="$(printf '%s\n' "${required_assets[@]}" | jq -R . | jq -sc .)" + if ! approved_installer_digests="$(printf '%s' "${APPROVED_INSTALLER_DIGESTS}" | jq -ce --argjson names "${required_assets_json}" ' + if type == "object" and + (keys | sort) == ($names | sort) and + all(.[]; type == "string" and test("^sha256:[a-f0-9]{64}$")) + then . + else error("invalid candidate-approved Windows installer digest map") + end + ')"; then + echo "windows_node_installer_digests must contain exactly the candidate-approved current installer asset contract." >&2 + exit 1 + fi + for asset_name in "${required_assets[@]}"; do + asset_matches="$(printf '%s' "${source_json}" | jq -c --arg name "${asset_name}" '[.assets[]? | select(.name == $name)]')" + asset_match_count="$(printf '%s' "${asset_matches}" | jq 'length')" + if [[ "${asset_match_count}" != "1" ]]; then + echo "Windows source release ${WINDOWS_NODE_TAG} must contain exactly one required asset ${asset_name}; found ${asset_match_count}." >&2 + exit 1 + fi + asset_digest="$(printf '%s' "${asset_matches}" | jq -r '.[0].digest // empty')" + if [[ ! "${asset_digest}" =~ ^sha256:[a-f0-9]{64}$ ]]; then + echo "Windows source release ${WINDOWS_NODE_TAG} asset ${asset_name} is missing its immutable SHA-256 digest." >&2 + exit 1 + fi + approved_digest="$(printf '%s' "${approved_installer_digests}" | jq -r --arg name "${asset_name}" '.[$name]')" + if [[ "${asset_digest}" != "${approved_digest}" ]]; then + echo "Windows source release ${WINDOWS_NODE_TAG} asset ${asset_name} no longer matches its candidate-approved digest." >&2 + exit 1 + fi + done + echo "installer_digests=${approved_installer_digests}" >> "$GITHUB_OUTPUT" + echo "- Windows Node source release: prevalidated \`${WINDOWS_NODE_TAG}\`" >> "$GITHUB_STEP_SUMMARY" + - name: Download OpenClaw npm preflight manifest id: preflight_artifact if: ${{ inputs.publish_openclaw_npm }} @@ -337,6 +431,7 @@ jobs: TARGET_SHA: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }} RELEASE_PROFILE: ${{ steps.full_manifest.outputs.release_profile || inputs.release_profile }} FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }} + WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }} run: | { echo "### Release target" @@ -347,13 +442,16 @@ jobs: if [[ -n "${FULL_RELEASE_VALIDATION_RUN_ID// }" ]]; then echo "- Full release validation: \`${FULL_RELEASE_VALIDATION_RUN_ID}\`" fi + if [[ -n "${WINDOWS_NODE_TAG// }" ]]; then + echo "- Windows Node source release: \`${WINDOWS_NODE_TAG}\`" + fi } >> "$GITHUB_STEP_SUMMARY" publish: name: Publish plugins, then OpenClaw needs: [resolve_release_target] runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 120 environment: npm-release steps: - name: Checkout release SHA @@ -383,11 +481,19 @@ jobs: WAIT_FOR_CLAWHUB: ${{ inputs.wait_for_clawhub && 'true' || 'false' }} PREFLIGHT_ARTIFACT_NAME: ${{ needs.resolve_release_target.outputs.preflight_artifact_name }} NPM_TELEGRAM_RUN_ID: ${{ inputs.npm_telegram_run_id }} + WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }} + WINDOWS_NODE_INSTALLER_DIGESTS: ${{ needs.resolve_release_target.outputs.windows_node_installer_digests }} POSTPUBLISH_EVIDENCE_DIR: ${{ runner.temp }}/openclaw-release-postpublish-evidence run: | set -euo pipefail - dispatch_workflow() { + is_stable_release() { + [[ "${RELEASE_TAG}" != *"-alpha."* && "${RELEASE_TAG}" != *"-beta."* ]] + } + + dispatch_workflow_at_ref() { + local workflow_ref="$1" + shift local workflow="$1" shift @@ -397,7 +503,7 @@ jobs: -F per_page=100 \ --jq '[.workflow_runs[].id]')" - dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)" + dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$workflow_ref" "$@" 2>&1)" printf '%s\n' "$dispatch_output" >&2 run_id="$( printf '%s\n' "$dispatch_output" | @@ -432,6 +538,10 @@ jobs: printf '%s\n' "${run_id}" } + dispatch_workflow() { + dispatch_workflow_at_ref "$CHILD_WORKFLOW_REF" "$@" + } + print_pending_deployments() { local workflow="$1" local run_id="$2" @@ -653,6 +763,128 @@ jobs: done } + guard_existing_public_release() { + local release_version asset_name release_json is_draft has_sha has_proof has_asset release_url + + if [[ "${PUBLISH_OPENCLAW_NPM}" != "true" ]]; then + return 0 + fi + + if ! release_json="$(gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json isDraft,assets,body,url 2>/dev/null)"; then + return 0 + fi + + is_draft="$(printf '%s' "${release_json}" | jq -r '.isDraft')" + if [[ "${is_draft}" == "true" ]]; then + return 0 + fi + + release_version="${RELEASE_TAG#v}" + asset_name="openclaw-${release_version}-dependency-evidence.zip" + has_sha="$(printf '%s' "${release_json}" | jq --arg sha "${TARGET_SHA}" -r '.body | contains($sha)')" + has_proof="$(printf '%s' "${release_json}" | jq -r '.body | contains("### Release verification")')" + has_asset="$(printf '%s' "${release_json}" | jq --arg name "${asset_name}" -r 'any(.assets[]?; .name == $name)')" + release_url="$(printf '%s' "${release_json}" | jq -r '.url')" + + if [[ "${has_sha}" == "true" && "${has_proof}" == "true" && "${has_asset}" == "true" ]]; then + return 0 + fi + + { + echo "Release ${RELEASE_TAG} already has a public GitHub release page without complete postpublish evidence for ${TARGET_SHA}." + echo "Refusing to reuse a public prerelease tag after publication started: ${release_url}" + echo "Create a new beta tag or delete/draft the incomplete public release before retrying." + } >&2 + exit 1 + } + + guard_openclaw_npm_not_already_published() { + local release_version release_url + + if [[ "${PUBLISH_OPENCLAW_NPM}" != "true" ]]; then + return 0 + fi + + release_version="${RELEASE_TAG#v}" + if ! npm view "openclaw@${release_version}" version >/dev/null 2>&1; then + return 0 + fi + + release_url="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" + { + echo "openclaw@${release_version} is already published on npm." + echo "Refusing to dispatch publish child workflows for an already-published version." + echo "If this is recovery from a failed postpublish evidence or draft-release step, repair/finalize the existing draft or create a correction tag; do not rerun the publish workflow for the same npm version." + echo "Release page, if present: ${release_url}" + } >&2 + exit 1 + } + + resolve_clawhub_release_plan() { + local -a plan_args + + clawhub_plan_path="${RUNNER_TEMP}/openclaw-release-clawhub-plan.json" + plan_args=( + --release-tag "${RELEASE_TAG}" + --release-publish-branch "${CHILD_WORKFLOW_REF}" + --release-publish-run-id "${GITHUB_RUN_ID}" + --plugin-publish-scope "${PLUGIN_PUBLISH_SCOPE}" + ) + if [[ -n "${PLUGINS// }" ]]; then + plan_args+=(--plugins "${PLUGINS}") + fi + + CLAWHUB_REGISTRY="${CLAWHUB_REGISTRY:-https://clawhub.ai}" \ + node --import tsx scripts/openclaw-release-clawhub-plan.ts "${plan_args[@]}" > "${clawhub_plan_path}" + + echo "Resolved OpenClaw release ClawHub dispatch plan:" + cat "${clawhub_plan_path}" + + clawhub_workflow_ref="$(jq -r '.clawHubWorkflowRef' "${clawhub_plan_path}")" + normal_plugins="$(jq -r '.summary.normalPlugins' "${clawhub_plan_path}")" + bootstrap_plugins="$(jq -r '.summary.bootstrapPlugins' "${clawhub_plan_path}")" + missing_trusted_plugins="$(jq -r '.summary.missingTrustedPlugins' "${clawhub_plan_path}")" + normal_plugin_count="$(jq -r '.summary.normalCount' "${clawhub_plan_path}")" + bootstrap_plugin_count="$(jq -r '.summary.bootstrapCount' "${clawhub_plan_path}")" + missing_trusted_plugin_count="$(jq -r '.summary.missingTrustedPublisherCount' "${clawhub_plan_path}")" + + { + echo "### ClawHub release plan" + echo + echo "- Normal OIDC candidates: \`${normal_plugin_count}\`" + echo "- Bootstrap/repair candidates: \`${bootstrap_plugin_count}\`" + echo "- Existing-package trusted-publisher repairs: \`${missing_trusted_plugin_count}\`" + if [[ -n "${normal_plugins}" ]]; then + echo "- Normal plugins: \`${normal_plugins}\`" + fi + if [[ -n "${bootstrap_plugins}" ]]; then + echo "- Bootstrap/repair plugins: \`${bootstrap_plugins}\`" + fi + if [[ -n "${missing_trusted_plugins}" ]]; then + echo "- Trusted-publisher repair plugins: \`${missing_trusted_plugins}\`" + fi + } >> "$GITHUB_STEP_SUMMARY" + } + + append_clawhub_dispatch_args() { + local target="$1" + while IFS=$'\t' read -r key value; do + clawhub_dispatch_args+=(-f "${key}=${value}") + done < <(jq -r --arg target "${target}" '.[$target].inputs | to_entries[] | [.key, .value] | @tsv' "${clawhub_plan_path}") + } + + write_clawhub_runtime_state() { + local force_skip_clawhub="$1" + local output_path="$2" + node --import tsx scripts/openclaw-release-clawhub-runtime-state.ts \ + --repository "${GITHUB_REPOSITORY}" \ + --wait-for-clawhub "${WAIT_FOR_CLAWHUB}" \ + --force-skip-clawhub "${force_skip_clawhub}" \ + --normal-run-id "${plugin_clawhub_run_id:-}" \ + --bootstrap-run-id "${plugin_clawhub_bootstrap_run_id:-}" \ + --bootstrap-completed "${plugin_clawhub_bootstrap_completed:-false}" > "${output_path}" + } + create_or_update_github_release() { local release_version notes_version title notes_file changelog_file latest_arg prerelease_args release_version="${RELEASE_TAG#v}" @@ -698,14 +930,115 @@ jobs: else gh release create "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" \ --verify-tag \ + --draft \ --title "${title}" \ --notes-file "${notes_file}" \ "${prerelease_args[@]}" \ "${latest_arg}" fi + echo "- GitHub release draft: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY" + } + + publish_github_release() { + if is_stable_release; then + verify_windows_release_asset_contract + fi + gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --draft=false echo "- GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY" } + verify_windows_release_asset_contract() { + local actual_companion_assets actual_digest asset_name expected_companion_assets expected_digest expected_hash expected_installer_names manifest_dir manifest_json manifest_path release_json + # Add future promoted installer names, such as MSIX x64/ARM64, here. + local -a installer_assets=( + "OpenClawCompanion-Setup-x64.exe" + "OpenClawCompanion-Setup-arm64.exe" + ) + local -a required_assets=( + "${installer_assets[@]}" + "OpenClawCompanion-SHA256SUMS.txt" + ) + + release_json="$(gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json assets,url)" + expected_companion_assets="$(printf '%s\n' "${required_assets[@]}" | jq -R . | jq -sc 'sort')" + actual_companion_assets="$(printf '%s' "${release_json}" | jq -c ' + [.assets[]? | select(.name | startswith("OpenClawCompanion-")) | .name] | sort + ')" + if [[ "${actual_companion_assets}" != "${expected_companion_assets}" ]]; then + echo "Stable release OpenClawCompanion asset names do not exactly match the current contract." >&2 + return 1 + fi + for asset_name in "${required_assets[@]}"; do + if ! printf '%s' "${release_json}" | jq -e --arg name "${asset_name}" 'any(.assets[]?; .name == $name)' >/dev/null; then + echo "Stable release is missing required Windows asset ${asset_name}." >&2 + return 1 + fi + done + + manifest_dir="${RUNNER_TEMP}/openclaw-windows-release-contract" + manifest_path="${manifest_dir}/OpenClawCompanion-SHA256SUMS.txt" + rm -rf "${manifest_dir}" + mkdir -p "${manifest_dir}" + gh release download "${RELEASE_TAG}" \ + --repo "$GITHUB_REPOSITORY" \ + --pattern "OpenClawCompanion-SHA256SUMS.txt" \ + --dir "${manifest_dir}" + if ! manifest_json="$(jq -Rsc ' + split("\n") as $lines | + (if $lines[-1] == "" then $lines[0:-1] else $lines end) | + map(sub("\r$"; "")) | + if all(.[]; test("^(?[a-f0-9]{64}) (?[^/\\\\]+)$")) + then map(capture("^(?[a-f0-9]{64}) (?[^/\\\\]+)$")) + else error("malformed Windows checksum manifest entry") + end + ' "${manifest_path}")"; then + echo "Stable release Windows checksum manifest contains malformed entries." >&2 + return 1 + fi + expected_installer_names="$(printf '%s\n' "${installer_assets[@]}" | jq -R . | jq -sc 'sort')" + if ! printf '%s' "${manifest_json}" | jq -e --argjson expected "${expected_installer_names}" ' + length == ($expected | length) and + ([.[].name] | sort) == $expected and + ([.[].name] | unique | length) == length + ' >/dev/null; then + echo "Stable release Windows checksum manifest does not exactly match the installer asset contract." >&2 + return 1 + fi + for asset_name in "${installer_assets[@]}"; do + expected_digest="$(printf '%s' "${WINDOWS_NODE_INSTALLER_DIGESTS}" | jq -r --arg name "${asset_name}" '.[$name] // empty')" + actual_digest="$(printf '%s' "${release_json}" | jq -r --arg name "${asset_name}" '.assets[]? | select(.name == $name) | .digest // empty')" + if [[ -z "${expected_digest}" || "${actual_digest}" != "${expected_digest}" ]]; then + echo "Stable release Windows asset ${asset_name} does not match its pinned digest." >&2 + return 1 + fi + expected_hash="${expected_digest#sha256:}" + if ! printf '%s' "${manifest_json}" | jq -e --arg name "${asset_name}" --arg hash "${expected_hash}" ' + any(.[]; .name == $name and .hash == $hash) + ' >/dev/null; then + echo "Stable release Windows checksum manifest does not match pinned digest for ${asset_name}." >&2 + return 1 + fi + done + echo "- Windows Hub asset contract: verified" >> "$GITHUB_STEP_SUMMARY" + } + + promote_windows_release_assets() { + if ! is_stable_release; then + return 0 + fi + if [[ -z "${WINDOWS_NODE_INSTALLER_DIGESTS// }" ]]; then + echo "Stable release is missing prevalidated Windows installer digests." >&2 + return 1 + fi + + windows_node_run_id="$(dispatch_workflow windows-node-release.yml \ + -f tag="${RELEASE_TAG}" \ + -f windows_node_tag="${WINDOWS_NODE_TAG}" \ + -f expected_installer_digests="${WINDOWS_NODE_INSTALLER_DIGESTS}")" + echo "- Windows Node release run ID: \`${windows_node_run_id}\`" >> "$GITHUB_STEP_SUMMARY" + wait_for_run windows-node-release.yml "${windows_node_run_id}" + } + upload_dependency_evidence_release_asset() { local release_version download_dir asset_path asset_name artifact_name release_version="${RELEASE_TAG#v}" @@ -735,9 +1068,11 @@ jobs: } verify_published_release() { - local release_version evidence_path + local release_version evidence_path skip_clawhub clawhub_runtime_state_path local -a verify_args + skip_clawhub="${1:-false}" + release_version="${RELEASE_TAG#v}" evidence_path="${POSTPUBLISH_EVIDENCE_DIR}/release-postpublish-evidence.json" mkdir -p "${POSTPUBLISH_EVIDENCE_DIR}" @@ -750,16 +1085,18 @@ jobs: --dist-tag "${RELEASE_NPM_DIST_TAG}" --repo "${GITHUB_REPOSITORY}" --workflow-ref "${CHILD_WORKFLOW_REF}" + --clawhub-workflow-ref "${clawhub_workflow_ref}" --full-release-validation-run "${FULL_RELEASE_VALIDATION_RUN_ID}" --plugin-npm-run "${plugin_npm_run_id}" --openclaw-npm-run "${openclaw_npm_run_id}" --evidence-out "${evidence_path}" + --skip-github-release ) - if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then - verify_args+=(--plugin-clawhub-run "${plugin_clawhub_run_id}") - else - verify_args+=(--skip-clawhub) - fi + clawhub_runtime_state_path="${RUNNER_TEMP}/openclaw-release-clawhub-runtime-state-verify.json" + write_clawhub_runtime_state "${skip_clawhub}" "${clawhub_runtime_state_path}" + while IFS= read -r arg; do + verify_args+=("${arg}") + done < <(jq -r '.verifierArgs[]' "${clawhub_runtime_state_path}") if [[ -n "${PLUGINS// }" ]]; then verify_args+=(--plugins "${PLUGINS}") fi @@ -775,7 +1112,7 @@ jobs: } append_release_proof_to_github_release() { - local release_version body_file notes_file tarball integrity telegram_line clawhub_line + local release_version body_file notes_file tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path windows_line release_version="${RELEASE_TAG#v}" body_file="${RUNNER_TEMP}/release-body.md" @@ -789,16 +1126,20 @@ jobs: else telegram_line="- npm Telegram beta E2E: not supplied" fi - if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then - clawhub_line="- plugin ClawHub publish: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}" - else - clawhub_line="- plugin ClawHub publish: dispatched separately, not awaited by this proof: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}" + clawhub_runtime_state_path="${RUNNER_TEMP}/openclaw-release-clawhub-runtime-state-proof.json" + write_clawhub_runtime_state false "${clawhub_runtime_state_path}" + clawhub_line="$(jq -r '.proofLines.normal' "${clawhub_runtime_state_path}")" + clawhub_bootstrap_line="$(jq -r '.proofLines.bootstrap' "${clawhub_runtime_state_path}")" + windows_line="" + if [[ -n "${windows_node_run_id// }" ]]; then + windows_line="- Windows Hub promotion: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${windows_node_run_id} from openclaw/openclaw-windows-node@${WINDOWS_NODE_TAG}" fi RELEASE_BODY_FILE="${body_file}" \ RELEASE_NOTES_FILE="${notes_file}" \ RELEASE_VERSION="${release_version}" \ RELEASE_TAG="${RELEASE_TAG}" \ + RELEASE_SHA="${TARGET_SHA}" \ RELEASE_REPO="${GITHUB_REPOSITORY}" \ RELEASE_TARBALL="${tarball}" \ RELEASE_INTEGRITY="${integrity}" \ @@ -808,7 +1149,9 @@ jobs: PLUGIN_NPM_RUN_ID="${plugin_npm_run_id}" \ OPENCLAW_NPM_RUN_ID="${openclaw_npm_run_id}" \ CLAWHUB_LINE="${clawhub_line}" \ + CLAWHUB_BOOTSTRAP_LINE="${clawhub_bootstrap_line}" \ TELEGRAM_LINE="${telegram_line}" \ + WINDOWS_LINE="${windows_line}" \ node --input-type=module <<'NODE' import { readFileSync, writeFileSync } from "node:fs"; @@ -825,14 +1168,17 @@ jobs: `- npm package: https://www.npmjs.com/package/openclaw/v/${process.env.RELEASE_VERSION}`, `- registry tarball: ${process.env.RELEASE_TARBALL}`, `- integrity: \`${process.env.RELEASE_INTEGRITY}\``, + `- release SHA: \`${process.env.RELEASE_SHA}\``, `- full release CI report: https://github.com/openclaw/releases/blob/main/evidence/${process.env.RELEASE_VERSION}/release-evidence.md`, `- release publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.RELEASE_PUBLISH_RUN_ID}`, `- npm preflight: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PREFLIGHT_RUN_ID}`, `- full release validation: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.FULL_RELEASE_VALIDATION_RUN_ID}`, `- plugin npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PLUGIN_NPM_RUN_ID}`, process.env.CLAWHUB_LINE, + process.env.CLAWHUB_BOOTSTRAP_LINE, `- OpenClaw npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.OPENCLAW_NPM_RUN_ID}`, process.env.TELEGRAM_LINE, + ...(process.env.WINDOWS_LINE ? [process.env.WINDOWS_LINE] : []), ].join("\n"); const withoutOldProof = body.replace(/\n?### Release verification\n[\s\S]*?(?=\n### |\n## |$)/, ""); @@ -847,6 +1193,7 @@ jobs: echo "### Publish sequence" echo echo "- Workflow ref: \`${CHILD_WORKFLOW_REF}\`" + echo "- ClawHub workflow ref: release tag \`${RELEASE_TAG}\`" echo "- Release tag: \`${RELEASE_TAG}\`" echo "- Release SHA: \`${TARGET_SHA}\`" echo "- Release approval: this workflow job" @@ -856,6 +1203,9 @@ jobs: else echo "- OpenClaw npm publish: skipped by input" fi + if is_stable_release && [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then + echo "- Windows Hub promotion: required before the GitHub release can be published" + fi if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then echo "- Workflow completion waits for ClawHub" else @@ -863,26 +1213,68 @@ jobs: fi } >> "$GITHUB_STEP_SUMMARY" + guard_existing_public_release + guard_openclaw_npm_not_already_published + resolve_clawhub_release_plan + npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}") - clawhub_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}") if [[ -n "${PLUGINS}" ]]; then npm_args+=(-f plugins="${PLUGINS}") - clawhub_args+=(-f plugins="${PLUGINS}") fi plugin_npm_run_id="$(dispatch_workflow plugin-npm-release.yml "${npm_args[@]}")" - plugin_clawhub_run_id="$(dispatch_workflow plugin-clawhub-release.yml "${clawhub_args[@]}")" + plugin_clawhub_run_id="" + if [[ "$(jq -r '.normal.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then + clawhub_dispatch_args=() + append_clawhub_dispatch_args normal + plugin_clawhub_run_id="$(dispatch_workflow_at_ref \ + "$(jq -r '.normal.ref' "${clawhub_plan_path}")" \ + "$(jq -r '.normal.workflow' "${clawhub_plan_path}")" \ + "${clawhub_dispatch_args[@]}")" + else + echo "- plugin-clawhub-release.yml: no normal OIDC candidates" >> "$GITHUB_STEP_SUMMARY" + fi + plugin_clawhub_bootstrap_run_id="" + plugin_clawhub_bootstrap_completed="false" + if [[ "$(jq -r '.bootstrap.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then + clawhub_dispatch_args=() + append_clawhub_dispatch_args bootstrap + plugin_clawhub_bootstrap_run_id="$(dispatch_workflow_at_ref \ + "$(jq -r '.bootstrap.ref' "${clawhub_plan_path}")" \ + "$(jq -r '.bootstrap.workflow' "${clawhub_plan_path}")" \ + "${clawhub_dispatch_args[@]}")" + else + echo "- plugin-clawhub-new.yml: no bootstrap candidates" >> "$GITHUB_STEP_SUMMARY" + fi { echo "- Plugin npm run ID: \`${plugin_npm_run_id}\`" - echo "- Plugin ClawHub run ID: \`${plugin_clawhub_run_id}\`" + echo "- Plugin ClawHub run ID: \`${plugin_clawhub_run_id:-none}\`" + echo "- Plugin ClawHub bootstrap run ID: \`${plugin_clawhub_bootstrap_run_id:-none}\`" } >> "$GITHUB_STEP_SUMMARY" if ! wait_for_run plugin-npm-release.yml "${plugin_npm_run_id}"; then - echo "Plugin npm publish failed; cancelling ClawHub publish child ${plugin_clawhub_run_id}." >&2 - gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true + echo "Plugin npm publish failed; cancelling dispatched ClawHub child workflows." >&2 + if [[ -n "${plugin_clawhub_run_id}" ]]; then + gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true + fi + if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then + gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_bootstrap_run_id}" >/dev/null 2>&1 || true + fi exit 1 fi + if [[ -n "${plugin_clawhub_bootstrap_run_id}" && "${WAIT_FOR_CLAWHUB}" == "true" ]]; then + echo "Waiting for plugin-clawhub-new.yml bootstrap to finish before continuing release publish." + if wait_for_run plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}"; then + plugin_clawhub_bootstrap_completed="true" + else + if [[ -n "${plugin_clawhub_run_id}" ]]; then + gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true + fi + exit 1 + fi + fi + openclaw_npm_run_id="" if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then openclaw_npm_run_id="$(dispatch_workflow openclaw-npm-release.yml \ @@ -899,19 +1291,52 @@ jobs: clawhub_result="" clawhub_pid="" + clawhub_bootstrap_result="" + clawhub_bootstrap_pid="" if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then - clawhub_result="$RUNNER_TEMP/clawhub-result.txt" - wait_run_pid="" - wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}" - clawhub_pid="${wait_run_pid}" - else - wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval" - if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then - : - else - echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY" + if [[ -n "${plugin_clawhub_run_id}" ]]; then + clawhub_result="$RUNNER_TEMP/clawhub-result.txt" + wait_run_pid="" + wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}" + clawhub_pid="${wait_run_pid}" + fi + if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then + if [[ "${plugin_clawhub_bootstrap_completed}" == "true" ]]; then + echo "- plugin-clawhub-new.yml: bootstrap already completed before continuing" >> "$GITHUB_STEP_SUMMARY" + else + clawhub_bootstrap_result="$RUNNER_TEMP/clawhub-bootstrap-result.txt" + wait_run_pid="" + wait_for_run_background plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "${clawhub_bootstrap_result}" + clawhub_bootstrap_pid="${wait_run_pid}" + fi + fi + else + if [[ -n "${plugin_clawhub_run_id}" ]]; then + wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval" + if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then + : + else + echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY" + fi + echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY" + else + echo "- plugin-clawhub-release.yml: no normal OIDC publish to await" >> "$GITHUB_STEP_SUMMARY" + fi + if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then + if [[ "${plugin_clawhub_bootstrap_completed}" == "true" ]]; then + echo "- plugin-clawhub-new.yml: bootstrap already completed before continuing" >> "$GITHUB_STEP_SUMMARY" + else + wait_for_job_success plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "Validate release publish approval" + if approve_child_publish_environment plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}"; then + : + else + echo "- plugin-clawhub-new.yml: child environment gate not ready; bootstrap was left dispatched (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY" + fi + echo "- plugin-clawhub-new.yml: bootstrap not awaited (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY" + fi + else + echo "- plugin-clawhub-new.yml: no bootstrap publish to await" >> "$GITHUB_STEP_SUMMARY" fi - echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY" fi openclaw_result="" @@ -925,6 +1350,7 @@ jobs: failed=0 openclaw_failed=0 + windows_node_run_id="" if [[ -n "${openclaw_pid}" ]] && ! wait "${openclaw_pid}"; then failed=1 openclaw_failed=1 @@ -934,21 +1360,36 @@ jobs: openclaw_failed=1 fi - if [[ -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ]]; then - create_or_update_github_release - upload_dependency_evidence_release_asset - fi - if [[ -n "${clawhub_pid}" ]] && ! wait "${clawhub_pid}"; then failed=1 fi if [[ -f "${clawhub_result}" && "$(cat "${clawhub_result}")" != "success" ]]; then failed=1 fi + if [[ -n "${clawhub_bootstrap_pid}" ]] && ! wait "${clawhub_bootstrap_pid}"; then + failed=1 + fi + if [[ -f "${clawhub_bootstrap_result}" && "$(cat "${clawhub_bootstrap_result}")" != "success" ]]; then + failed=1 + fi - if [[ "${failed}" == "0" && -n "${openclaw_npm_run_id}" ]]; then - verify_published_release + if [[ -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ]]; then + if [[ "${failed}" == "0" ]]; then + verify_published_release + else + verify_published_release true + fi + create_or_update_github_release + upload_dependency_evidence_release_asset + if ! promote_windows_release_assets; then + failed=1 + fi append_release_proof_to_github_release + if [[ "${failed}" == "0" ]]; then + publish_github_release + else + echo "- GitHub release: left as draft because a required publish child failed" >> "$GITHUB_STEP_SUMMARY" + fi fi if [[ "${failed}" != "0" ]]; then exit 1 diff --git a/.github/workflows/opengrep-precise-full.yml b/.github/workflows/opengrep-precise-full.yml index 324e826f2410..288d08cd6427 100644 --- a/.github/workflows/opengrep-precise-full.yml +++ b/.github/workflows/opengrep-precise-full.yml @@ -53,7 +53,7 @@ jobs: scripts/run-opengrep.sh --sarif --error - name: Upload SARIF to GitHub Code Scanning - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@v4.36.2 # Only upload if the scan actually produced a SARIF file. if: always() && hashFiles('.opengrep-out/precise.sarif') != '' with: diff --git a/.github/workflows/opengrep-precise.yml b/.github/workflows/opengrep-precise.yml index cc2a3ffa5e7f..49419d8662f6 100644 --- a/.github/workflows/opengrep-precise.yml +++ b/.github/workflows/opengrep-precise.yml @@ -84,7 +84,7 @@ jobs: scripts/run-opengrep.sh --changed --sarif --error - name: Upload SARIF to GitHub Code Scanning - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@v4.36.2 # Only upload if the scan actually produced a SARIF file. if: always() && hashFiles('.opengrep-out/precise.sarif') != '' with: diff --git a/.github/workflows/plugin-clawhub-new.yml b/.github/workflows/plugin-clawhub-new.yml new file mode 100644 index 000000000000..7e2e6073a078 --- /dev/null +++ b/.github/workflows/plugin-clawhub-new.yml @@ -0,0 +1,504 @@ +name: Plugin ClawHub New + +on: + workflow_dispatch: + inputs: + plugins: + description: Comma-separated plugin package names to bootstrap on ClawHub + required: true + type: string + ref: + description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from; defaults to the workflow ref + required: false + default: "" + type: string + release_publish_run_id: + description: Approved OpenClaw Release Publish workflow run id + required: false + type: string + release_publish_branch: + description: Branch name of the approving OpenClaw Release Publish workflow run + required: false + type: string + dry_run: + description: Validate the token-gated ClawHub bootstrap handoff without publishing. + required: false + default: false + type: boolean + +concurrency: + group: plugin-clawhub-new-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }} + cancel-in-progress: false + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + NODE_VERSION: "24.15.0" + CLAWHUB_REGISTRY: "https://clawhub.ai" + CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0" + +jobs: + resolve_bootstrap_plan: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + ref_revision: ${{ steps.ref.outputs.sha }} + has_bootstrap_candidates: ${{ steps.plan.outputs.has_bootstrap_candidates }} + bootstrap_candidate_count: ${{ steps.plan.outputs.bootstrap_candidate_count }} + matrix: ${{ steps.plan.outputs.matrix }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + ref: ${{ github.ref }} + fetch-depth: 0 + + - name: Resolve checked-out ref + id: ref + env: + TARGET_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} + run: | + set -euo pipefail + git fetch --no-tags origin \ + +refs/heads/main:refs/remotes/origin/main \ + '+refs/heads/release/*:refs/remotes/origin/release/*' + if [[ -n "${TARGET_REF}" ]]; then + if git rev-parse --verify --quiet "${TARGET_REF}^{commit}" >/dev/null; then + target_sha="$(git rev-parse "${TARGET_REF}^{commit}")" + elif git rev-parse --verify --quiet "origin/${TARGET_REF}^{commit}" >/dev/null; then + target_sha="$(git rev-parse "origin/${TARGET_REF}^{commit}")" + else + echo "Unable to resolve requested publish ref: ${TARGET_REF}" >&2 + exit 1 + fi + git checkout --detach "${target_sha}" + fi + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Validate ref is on a trusted publish branch + env: + TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }} + run: | + set -euo pipefail + if git merge-base --is-ancestor HEAD origin/main; then + exit 0 + fi + while IFS= read -r release_ref; do + if git merge-base --is-ancestor HEAD "${release_ref}"; then + exit 0 + fi + done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release) + if [[ "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then + alpha_branch="${TRUSTED_PUBLISH_BRANCH}" + git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}" + if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then + exit 0 + fi + fi + echo "Plugin ClawHub bootstraps must target a commit reachable from main, release/*, or the matching Tideclaw alpha branch." >&2 + exit 1 + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + install-bun: "false" + + - name: Validate publishable plugin metadata + env: + RELEASE_PLUGINS: ${{ inputs.plugins }} + run: | + set -euo pipefail + if [[ -z "${RELEASE_PLUGINS// }" ]]; then + echo "Plugin ClawHub bootstrap requires at least one package name in plugins." >&2 + exit 1 + fi + pnpm release:plugins:clawhub:check -- --selection-mode selected --plugins "${RELEASE_PLUGINS}" + + - name: Resolve plugin bootstrap plan + id: plan + env: + RELEASE_PLUGINS: ${{ inputs.plugins }} + CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} + run: | + set -euo pipefail + mkdir -p .local + node --import tsx scripts/plugin-clawhub-release-plan.ts \ + --selection-mode selected \ + --plugins "${RELEASE_PLUGINS}" > .local/plugin-clawhub-release-plan.json + + cat .local/plugin-clawhub-release-plan.json + + bootstrap_candidate_count="$(jq -r '(.bootstrapCandidates | length) + (.missingTrustedPublisher | length)' .local/plugin-clawhub-release-plan.json)" + selected_count="$(jq -r '.all | length' .local/plugin-clawhub-release-plan.json)" + matrix_json="$( + jq -c ' + [ + .bootstrapCandidates[]? + { + bootstrapMode: "publish", + requiresManualOverride: false + }, + .missingTrustedPublisher[]? + { + bootstrapMode: (if .alreadyPublished then "configure-only" else "publish" end), + requiresManualOverride: true + } + ] + ' .local/plugin-clawhub-release-plan.json + )" + has_bootstrap_candidates="false" + if [[ "${bootstrap_candidate_count}" != "0" ]]; then + has_bootstrap_candidates="true" + fi + + invalid_scope="$( + jq -r ' + (.bootstrapCandidates[]?, .missingTrustedPublisher[]?) + | select(.packageName | startswith("@openclaw/") | not) + | "- \(.packageName)@\(.version)" + ' .local/plugin-clawhub-release-plan.json + )" + if [[ -n "${invalid_scope}" ]]; then + echo "Plugin ClawHub bootstrap only supports @openclaw/* packages." >&2 + printf '%s\n' "${invalid_scope}" >&2 + exit 1 + fi + + not_bootstrap="$( + jq -r ' + (.bootstrapCandidates | map(.packageName)) as $bootstrapNames + | (.missingTrustedPublisher | map(.packageName)) as $repairNames + | .all[]? + | select(.packageName as $name | ($bootstrapNames + $repairNames | index($name) | not)) + | "- \(.packageName)@\(.version)" + ' .local/plugin-clawhub-release-plan.json + )" + if [[ -n "${not_bootstrap}" ]]; then + echo "Selected packages must all be first-publish bootstrap candidates or trusted-publisher repair candidates." >&2 + printf '%s\n' "${not_bootstrap}" >&2 + exit 1 + fi + if [[ "${selected_count}" == "0" || "${bootstrap_candidate_count}" == "0" ]]; then + echo "No selected packages require ClawHub bootstrap." >&2 + exit 1 + fi + + { + echo "bootstrap_candidate_count=${bootstrap_candidate_count}" + echo "has_bootstrap_candidates=${has_bootstrap_candidates}" + echo "matrix=${matrix_json}" + } >> "$GITHUB_OUTPUT" + + echo "ClawHub bootstrap candidates:" + jq -r ' + .bootstrapCandidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)" + ' .local/plugin-clawhub-release-plan.json + echo "ClawHub trusted-publisher repair candidates:" + jq -r ' + .missingTrustedPublisher[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir), alreadyPublished=\(.alreadyPublished)" + ' .local/plugin-clawhub-release-plan.json + + - name: Validate Tideclaw alpha plugin channels + env: + TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }} + run: | + set -euo pipefail + if [[ ! "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then + exit 0 + fi + invalid="$( + jq -r ' + (.bootstrapCandidates[]?, .missingTrustedPublisher[]?) + | select(.publishTag != "alpha" or .channel != "alpha") + | "- \(.packageName)@\(.version) [\(.publishTag)]" + ' .local/plugin-clawhub-release-plan.json + )" + if [[ -n "${invalid}" ]]; then + echo "Tideclaw alpha ClawHub bootstraps may only publish alpha plugin versions." >&2 + printf '%s\n' "${invalid}" >&2 + exit 1 + fi + + validate_release_publish_approval: + name: Validate release publish approval + needs: resolve_bootstrap_plan + if: github.event_name == 'workflow_dispatch' && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Validate release publish approval run + env: + GH_TOKEN: ${{ github.token }} + RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }} + EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }} + run: | + set -euo pipefail + if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then + if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then + echo "Plugin ClawHub bootstrap dispatched by another workflow must include release_publish_run_id." >&2 + exit 1 + fi + echo "Direct Plugin ClawHub New dispatch; relying on this workflow's clawhub-plugin-bootstrap environment approval." + exit 0 + fi + direct_recovery=false + if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then + direct_recovery=true + echo "Direct Plugin ClawHub New recovery with release_publish_run_id; relying on this workflow's clawhub-plugin-bootstrap environment approval." + fi + RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)" + printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs + + validate_bootstrap_trusted_publisher_cli: + needs: [resolve_bootstrap_plan, validate_release_publish_approval] + if: always() && github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' && needs.validate_release_publish_approval.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Validate pinned ClawHub trusted publisher CLI support + env: + CLAWHUB_CLI_PACKAGE: ${{ env.CLAWHUB_CLI_PACKAGE }} + run: | + set -euo pipefail + help_output="$( + npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- \ + clawhub package trusted-publisher set --help 2>&1 || true + )" + printf '%s\n' "${help_output}" + if ! grep -Fq "Usage: clawhub package trusted-publisher set" <<<"${help_output}"; then + echo "::error::CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows requires ${CLAWHUB_CLI_PACKAGE} to expose 'package trusted-publisher set' before token bootstrap publish can run. The pinned CLI returned parent help or no set command, so this workflow is stopping before creating a ClawHub package row." + exit 1 + fi + for required_flag in --repository --workflow-filename; do + if ! grep -Fq -- "${required_flag}" <<<"${help_output}"; then + echo "::error::CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows requires ${CLAWHUB_CLI_PACKAGE} trusted-publisher set help to include ${required_flag}." + exit 1 + fi + done + + publish_bootstrap_plugins: + needs: + [ + resolve_bootstrap_plan, + validate_release_publish_approval, + validate_bootstrap_trusted_publisher_cli, + ] + if: always() && github.event_name == 'workflow_dispatch' && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' && needs.validate_release_publish_approval.result == 'success' && (inputs.dry_run == true || needs.validate_bootstrap_trusted_publisher_cli.result == 'success') + runs-on: ubuntu-latest + environment: clawhub-plugin-bootstrap + permissions: + contents: read + strategy: + fail-fast: false + max-parallel: 8 + matrix: + plugin: ${{ fromJson(needs.resolve_bootstrap_plan.outputs.matrix) }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + ref: ${{ github.ref }} + fetch-depth: 0 + + - name: Checkout target revision + env: + TARGET_SHA: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }} + run: | + set -euo pipefail + git fetch --no-tags origin \ + +refs/heads/main:refs/remotes/origin/main \ + '+refs/heads/release/*:refs/remotes/origin/release/*' + git checkout --detach "${TARGET_SHA}" + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + install-bun: "true" + install-deps: "true" + + - name: Verify package-local runtime build + run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}" + + - name: Install pinned ClawHub CLI wrapper + run: | + set -euo pipefail + cat > "${RUNNER_TEMP}/clawhub" <<'EOF' + #!/usr/bin/env bash + set -euo pipefail + exec npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- clawhub "$@" + EOF + chmod +x "${RUNNER_TEMP}/clawhub" + echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}" + + - name: Write ClawHub token config + if: inputs.dry_run != true + env: + CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} + CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }} + run: | + set -euo pipefail + config_path="${RUNNER_TEMP}/clawhub-config.json" + CONFIG_PATH="${config_path}" node --input-type=module <<'NODE' + import { writeFileSync } from "node:fs"; + + const registry = process.env.CLAWHUB_REGISTRY?.trim(); + const token = process.env.CLAWHUB_TOKEN?.trim(); + const configPath = process.env.CONFIG_PATH; + if (!registry) { + throw new Error("CLAWHUB_REGISTRY is required for token-gated ClawHub bootstrap."); + } + if (!token) { + throw new Error("CLAWHUB_TOKEN is required for token-gated ClawHub bootstrap."); + } + if (!configPath) { + throw new Error("CONFIG_PATH is required."); + } + + writeFileSync(configPath, `${JSON.stringify({ registry, token }, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + NODE + echo "CLAWHUB_CONFIG_PATH=${config_path}" >> "${GITHUB_ENV}" + + - name: Publish ClawHub bootstrap package + env: + CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} + SOURCE_REPO: ${{ github.repository }} + SOURCE_COMMIT: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }} + SOURCE_REF: ${{ github.ref }} + PACKAGE_TAG: ${{ matrix.plugin.publishTag }} + PACKAGE_DIR: ${{ matrix.plugin.packageDir }} + BOOTSTRAP_MODE: ${{ matrix.plugin.bootstrapMode }} + REQUIRES_MANUAL_OVERRIDE: ${{ matrix.plugin.requiresManualOverride && 'true' || 'false' }} + DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }} + OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0" + run: | + set -euo pipefail + if [[ "${BOOTSTRAP_MODE}" == "configure-only" ]]; then + echo "Skipping bootstrap publish because ${PACKAGE_DIR} version is already present on ClawHub; configuring trusted publisher only." + elif [[ "${DRY_RUN}" == "true" ]]; then + bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}" + else + if [[ "${REQUIRES_MANUAL_OVERRIDE}" == "true" ]]; then + export OPENCLAW_CLAWHUB_MANUAL_OVERRIDE_REASON="GitHub Actions trusted publisher repair before OIDC migration" + fi + bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}" + fi + + - name: Configure trusted publisher for normal OIDC releases + if: inputs.dry_run != true + env: + CLAWHUB_CLI_PACKAGE: ${{ env.CLAWHUB_CLI_PACKAGE }} + PACKAGE_NAME: ${{ matrix.plugin.packageName }} + run: | + set -euo pipefail + npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- \ + clawhub package trusted-publisher set "${PACKAGE_NAME}" \ + --repository openclaw/openclaw \ + --workflow-filename plugin-clawhub-release.yml + + verify_bootstrap_clawhub_package: + needs: [resolve_bootstrap_plan, publish_bootstrap_plugins] + if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + max-parallel: 8 + matrix: + plugin: ${{ fromJson(needs.resolve_bootstrap_plan.outputs.matrix) }} + steps: + - name: Verify bootstrap ClawHub package and trusted publisher + env: + CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} + PACKAGE_NAME: ${{ matrix.plugin.packageName }} + PACKAGE_VERSION: ${{ matrix.plugin.version }} + PACKAGE_TAG: ${{ matrix.plugin.publishTag }} + run: | + set -euo pipefail + node --input-type=module <<'EOF' + const registry = (process.env.CLAWHUB_REGISTRY ?? "https://clawhub.ai").replace(/\/+$/, ""); + const packageName = process.env.PACKAGE_NAME; + const packageVersion = process.env.PACKAGE_VERSION; + const packageTag = process.env.PACKAGE_TAG; + if (!packageName || !packageVersion || !packageTag) { + throw new Error("Missing ClawHub bootstrap verification env."); + } + const encodedName = encodeURIComponent(packageName); + const encodedVersion = encodeURIComponent(packageVersion); + const detailUrl = `${registry}/api/v1/packages/${encodedName}`; + const trustedPublisherUrl = `${detailUrl}/trusted-publisher`; + const versionUrl = `${detailUrl}/versions/${encodedVersion}`; + const artifactUrl = `${versionUrl}/artifact/download`; + + async function fetchWithRetry(url, options = {}) { + let lastStatus = "unknown"; + for (let attempt = 1; attempt <= 12; attempt += 1) { + try { + const response = await fetch(url, { redirect: "manual", ...options }); + lastStatus = response.status; + if (response.status !== 429 && response.status < 500) { + return response; + } + } catch (error) { + lastStatus = error instanceof Error ? error.message : String(error); + } + await new Promise((resolve) => setTimeout(resolve, attempt * 5000)); + } + throw new Error(`${url} did not stabilize; last status ${lastStatus}.`); + } + + const detailResponse = await fetchWithRetry(detailUrl, { + headers: { accept: "application/json" }, + }); + if (!detailResponse.ok) { + throw new Error(`${detailUrl} returned HTTP ${detailResponse.status}.`); + } + const detail = await detailResponse.json(); + const tags = detail?.package?.tags ?? {}; + if (tags[packageTag] !== packageVersion) { + throw new Error( + `${packageName}: ClawHub tag ${packageTag} points to ${tags[packageTag] ?? ""}, expected ${packageVersion}.`, + ); + } + + const trustedPublisherResponse = await fetchWithRetry(trustedPublisherUrl, { + headers: { accept: "application/json" }, + }); + if (!trustedPublisherResponse.ok) { + throw new Error(`${trustedPublisherUrl} returned HTTP ${trustedPublisherResponse.status}.`); + } + const trustedPublisherDetail = await trustedPublisherResponse.json(); + const trustedPublisher = trustedPublisherDetail?.trustedPublisher; + if ( + trustedPublisher?.repository !== "openclaw/openclaw" || + trustedPublisher?.workflowFilename !== "plugin-clawhub-release.yml" || + trustedPublisher?.environment != null + ) { + throw new Error( + `${packageName}: trusted publisher config did not match openclaw/openclaw plugin-clawhub-release.yml without an environment pin.`, + ); + } + + const versionResponse = await fetchWithRetry(versionUrl); + if (!versionResponse.ok) { + throw new Error(`${versionUrl} returned HTTP ${versionResponse.status}.`); + } + const artifactResponse = await fetchWithRetry(artifactUrl, { method: "HEAD" }); + if (artifactResponse.status < 200 || artifactResponse.status >= 400) { + throw new Error(`${artifactUrl} returned HTTP ${artifactResponse.status}.`); + } + console.log(`${packageName}@${packageVersion} bootstrap verified on ClawHub.`); + EOF diff --git a/.github/workflows/plugin-clawhub-release.yml b/.github/workflows/plugin-clawhub-release.yml index 5b552df8e751..da9645a2cdd3 100644 --- a/.github/workflows/plugin-clawhub-release.yml +++ b/.github/workflows/plugin-clawhub-release.yml @@ -16,7 +16,7 @@ on: required: false type: string ref: - description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from; defaults to the workflow ref + description: Dry-run target ref to validate; real OIDC publishes must dispatch the workflow with --ref set to the target release tag/ref required: false default: "" type: string @@ -24,6 +24,15 @@ on: description: Approved OpenClaw Release Publish workflow run id required: false type: string + release_publish_branch: + description: Branch name of the approving OpenClaw Release Publish workflow run + required: false + type: string + dry_run: + description: Validate the full ClawHub artifact handoff without publishing. + required: false + default: false + type: boolean concurrency: group: plugin-clawhub-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }} @@ -33,9 +42,7 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" NODE_VERSION: "24.15.0" CLAWHUB_REGISTRY: "https://clawhub.ai" - CLAWHUB_REPOSITORY: "openclaw/clawhub" - # Pinned to a reviewed ClawHub commit so release behavior stays reproducible. - CLAWHUB_REF: "facf20ceb6cc459e2872d941e71335a784bbc55c" + CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0" jobs: preview_plugins_clawhub: @@ -45,9 +52,15 @@ jobs: outputs: ref_revision: ${{ steps.ref.outputs.sha }} has_candidates: ${{ steps.plan.outputs.has_candidates }} + has_bootstrap_candidates: ${{ steps.plan.outputs.has_bootstrap_candidates }} + has_missing_trusted_publisher: ${{ steps.plan.outputs.has_missing_trusted_publisher }} candidate_count: ${{ steps.plan.outputs.candidate_count }} + bootstrap_candidate_count: ${{ steps.plan.outputs.bootstrap_candidate_count }} + missing_trusted_publisher_count: ${{ steps.plan.outputs.missing_trusted_publisher_count }} skipped_published_count: ${{ steps.plan.outputs.skipped_published_count }} matrix: ${{ steps.plan.outputs.matrix }} + bootstrap_matrix: ${{ steps.plan.outputs.bootstrap_matrix }} + missing_trusted_publisher_matrix: ${{ steps.plan.outputs.missing_trusted_publisher_matrix }} steps: - name: Checkout uses: actions/checkout@v6 @@ -56,12 +69,6 @@ jobs: ref: ${{ github.ref }} fetch-depth: 0 - - name: Setup Node environment - uses: ./.github/actions/setup-node-env - with: - node-version: ${{ env.NODE_VERSION }} - install-bun: "false" - - name: Resolve checked-out ref id: ref env: @@ -84,9 +91,27 @@ jobs: fi echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Validate OIDC source matches workflow ref + env: + TARGET_SHA: ${{ steps.ref.outputs.sha }} + WORKFLOW_SHA: ${{ github.sha }} + DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }} + run: | + set -euo pipefail + if [[ "${TARGET_SHA}" != "${WORKFLOW_SHA}" ]]; then + if [[ "${DRY_RUN}" == "true" ]]; then + echo "Dry-run publish target differs from workflow ref; allowing validation-only dispatch." + exit 0 + fi + echo "Plugin ClawHub OIDC publishes must run from the same ref that is being published." >&2 + echo "The ref input is only supported for dry_run=true." >&2 + echo "For real publishes, dispatch this workflow with --ref pointing at the target release tag/ref and omit the ref input." >&2 + exit 1 + fi + - name: Validate ref is on a trusted publish branch env: - WORKFLOW_REF: ${{ github.ref }} + TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }} run: | set -euo pipefail if git merge-base --is-ancestor HEAD origin/main; then @@ -97,8 +122,8 @@ jobs: exit 0 fi done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release) - if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then - alpha_branch="${WORKFLOW_REF#refs/heads/}" + if [[ "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then + alpha_branch="${TRUSTED_PUBLISH_BRANCH}" git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}" if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then exit 0 @@ -107,6 +132,12 @@ jobs: echo "Plugin ClawHub publishes must target a commit reachable from main, release/*, or the matching Tideclaw alpha branch." >&2 exit 1 + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + node-version: ${{ env.NODE_VERSION }} + install-bun: "false" + - name: Validate publishable plugin metadata env: PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }} @@ -153,36 +184,78 @@ jobs: cat .local/plugin-clawhub-release-plan.json candidate_count="$(jq -r '.candidates | length' .local/plugin-clawhub-release-plan.json)" + bootstrap_candidate_count="$(jq -r '.bootstrapCandidates | length' .local/plugin-clawhub-release-plan.json)" + missing_trusted_publisher_count="$(jq -r '.missingTrustedPublisher | length' .local/plugin-clawhub-release-plan.json)" skipped_published_count="$(jq -r '.skippedPublished | length' .local/plugin-clawhub-release-plan.json)" has_candidates="false" if [[ "${candidate_count}" != "0" ]]; then has_candidates="true" fi + has_bootstrap_candidates="false" + if [[ "${bootstrap_candidate_count}" != "0" ]]; then + has_bootstrap_candidates="true" + fi + has_missing_trusted_publisher="false" + if [[ "${missing_trusted_publisher_count}" != "0" ]]; then + has_missing_trusted_publisher="true" + fi matrix_json="$(jq -c '.candidates' .local/plugin-clawhub-release-plan.json)" + bootstrap_matrix_json="$(jq -c '.bootstrapCandidates' .local/plugin-clawhub-release-plan.json)" + missing_trusted_publisher_matrix_json="$(jq -c '.missingTrustedPublisher' .local/plugin-clawhub-release-plan.json)" { echo "candidate_count=${candidate_count}" + echo "bootstrap_candidate_count=${bootstrap_candidate_count}" + echo "missing_trusted_publisher_count=${missing_trusted_publisher_count}" echo "skipped_published_count=${skipped_published_count}" echo "has_candidates=${has_candidates}" + echo "has_bootstrap_candidates=${has_bootstrap_candidates}" + echo "has_missing_trusted_publisher=${has_missing_trusted_publisher}" echo "matrix=${matrix_json}" + echo "bootstrap_matrix=${bootstrap_matrix_json}" + echo "missing_trusted_publisher_matrix=${missing_trusted_publisher_matrix_json}" } >> "$GITHUB_OUTPUT" echo "Plugin release candidates:" jq -r '.candidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json + echo "Bootstrap candidates requiring token bootstrap:" + jq -r '.bootstrapCandidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json + + echo "Missing trusted publisher candidates:" + jq -r '.missingTrustedPublisher[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json + echo "Already published / skipped:" jq -r '.skippedPublished[]? | "- \(.packageName)@\(.version)"' .local/plugin-clawhub-release-plan.json + - name: Fail when trusted publisher is missing + if: steps.plan.outputs.missing_trusted_publisher_count != '0' + run: | + echo "::error::One or more ClawHub packages exist but do not have trusted publishing configured. Configure trusted publishing before running the normal OIDC publish workflow." + jq -r '.missingTrustedPublisher[]? | "::error::Missing trusted publisher: \(.packageName)@\(.version). Configure trusted publishing for openclaw/openclaw, workflow plugin-clawhub-release.yml."' .local/plugin-clawhub-release-plan.json + exit 1 + + - name: Fail normal publish when bootstrap is required + if: steps.plan.outputs.bootstrap_candidate_count != '0' + run: | + echo "::error::One or more ClawHub packages do not exist yet and require the token-gated Plugin ClawHub New bootstrap workflow before normal OIDC publish can run." + jq -r '.bootstrapCandidates[]? | "::error::Bootstrap required: \(.packageName)@\(.version). Dispatch plugin-clawhub-new.yml for this package, then rerun the normal release."' .local/plugin-clawhub-release-plan.json + exit 1 + - name: Fail manual publish when target versions already exist - if: github.event_name == 'workflow_dispatch' && inputs.publish_scope == 'selected' && steps.plan.outputs.skipped_published_count != '0' + if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && inputs.publish_scope == 'selected' && steps.plan.outputs.skipped_published_count != '0' run: | echo "::error::One or more selected plugin versions already exist on ClawHub. Bump the version before running a real publish." exit 1 - name: Validate Tideclaw alpha plugin channels - if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/') + env: + TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }} run: | set -euo pipefail + if [[ ! "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then + exit 0 + fi invalid="$( jq -r '.candidates[]? | select(.publishTag != "alpha" or .channel != "alpha") | "- \(.packageName)@\(.version) [\(.publishTag)]"' .local/plugin-clawhub-release-plan.json )" @@ -192,12 +265,6 @@ jobs: exit 1 fi - - name: Verify OpenClaw ClawHub package ownership - if: steps.plan.outputs.has_candidates == 'true' - env: - CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} - run: node --import tsx scripts/plugin-clawhub-owner-preflight.ts .local/plugin-clawhub-release-plan.json - validate_release_publish_approval: name: Validate release publish approval needs: preview_plugins_clawhub @@ -216,7 +283,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }} - EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }} + EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }} run: | set -euo pipefail if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then @@ -235,106 +302,12 @@ jobs: RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)" printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs - preview_plugin_pack: - needs: preview_plugins_clawhub - if: needs.preview_plugins_clawhub.outputs.has_candidates == 'true' - runs-on: ubuntu-latest - permissions: - contents: read - strategy: - fail-fast: false - max-parallel: 12 - matrix: - plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - persist-credentials: false - ref: ${{ github.ref }} - fetch-depth: 0 - - - name: Checkout target revision - env: - TARGET_SHA: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }} - run: | - set -euo pipefail - git fetch --no-tags origin \ - +refs/heads/main:refs/remotes/origin/main \ - '+refs/heads/release/*:refs/remotes/origin/release/*' - git checkout --detach "${TARGET_SHA}" - - - name: Setup Node environment - uses: ./.github/actions/setup-node-env - with: - node-version: ${{ env.NODE_VERSION }} - install-bun: "true" - install-deps: "true" - - - name: Checkout ClawHub CLI source - uses: actions/checkout@v6 - with: - persist-credentials: false - repository: ${{ env.CLAWHUB_REPOSITORY }} - ref: main - path: clawhub-source - fetch-depth: 0 - - - name: Checkout pinned ClawHub CLI revision - working-directory: clawhub-source - env: - CLAWHUB_REF: ${{ env.CLAWHUB_REF }} - run: git checkout --detach "${CLAWHUB_REF}" - - - name: Install ClawHub CLI dependencies - working-directory: clawhub-source - run: | - set -euo pipefail - for attempt in 1 2 3; do - if bun install --frozen-lockfile; then - exit 0 - fi - status="$?" - if [[ "${attempt}" == "3" ]]; then - exit "${status}" - fi - echo "bun install failed while preparing ClawHub CLI; retrying (${attempt}/3)." - rm -rf node_modules "${RUNNER_TEMP}/bun-install-cache" || true - sleep $((attempt * 15)) - done - - - name: Bootstrap ClawHub CLI - run: | - cat > "$RUNNER_TEMP/clawhub" <<'EOF' - #!/usr/bin/env bash - set -euo pipefail - exec bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" "$@" - EOF - chmod +x "$RUNNER_TEMP/clawhub" - echo "$RUNNER_TEMP" >> "$GITHUB_PATH" - - - name: Verify package-local runtime build - run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}" - - - name: Preview publish command - env: - CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} - SOURCE_REPO: ${{ github.repository }} - SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }} - SOURCE_REF: ${{ github.ref }} - PACKAGE_TAG: ${{ matrix.plugin.publishTag }} - PACKAGE_DIR: ${{ matrix.plugin.packageDir }} - run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}" - - publish_plugins_clawhub: - needs: [preview_plugins_clawhub, preview_plugin_pack, validate_release_publish_approval] + pack_plugins_clawhub_artifacts: + needs: [preview_plugins_clawhub, validate_release_publish_approval] if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' runs-on: ubuntu-latest - environment: clawhub-plugin-release permissions: - actions: read contents: read - id-token: write strategy: fail-fast: false max-parallel: 32 @@ -365,115 +338,21 @@ jobs: install-bun: "true" install-deps: "true" - - name: Checkout ClawHub CLI source - uses: actions/checkout@v6 - with: - persist-credentials: false - repository: ${{ env.CLAWHUB_REPOSITORY }} - ref: main - path: clawhub-source - fetch-depth: 0 + - name: Verify package-local runtime build + run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}" - - name: Checkout pinned ClawHub CLI revision - working-directory: clawhub-source - env: - CLAWHUB_REF: ${{ env.CLAWHUB_REF }} - run: git checkout --detach "${CLAWHUB_REF}" - - - name: Install ClawHub CLI dependencies - working-directory: clawhub-source + - name: Install pinned ClawHub CLI wrapper run: | set -euo pipefail - for attempt in 1 2 3; do - if bun install --frozen-lockfile; then - exit 0 - fi - status="$?" - if [[ "${attempt}" == "3" ]]; then - exit "${status}" - fi - echo "bun install failed while preparing ClawHub CLI; retrying (${attempt}/3)." - rm -rf node_modules "${RUNNER_TEMP}/bun-install-cache" || true - sleep $((attempt * 15)) - done - - - name: Bootstrap ClawHub CLI - run: | - cat > "$RUNNER_TEMP/clawhub" <<'EOF' + cat > "${RUNNER_TEMP}/clawhub" <<'EOF' #!/usr/bin/env bash set -euo pipefail - exec bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" "$@" + exec npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- clawhub "$@" EOF - chmod +x "$RUNNER_TEMP/clawhub" - echo "$RUNNER_TEMP" >> "$GITHUB_PATH" + chmod +x "${RUNNER_TEMP}/clawhub" + echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}" - - name: Write ClawHub token config - env: - CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }} - CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} - run: | - set -euo pipefail - if [[ -z "${CLAWHUB_TOKEN}" ]]; then - echo "No CLAWHUB_TOKEN secret configured; publish will rely on GitHub OIDC trusted publishing." - exit 0 - fi - node --input-type=module <<'EOF' - import { writeFileSync } from "node:fs"; - import { join } from "node:path"; - - const path = join(process.env.RUNNER_TEMP, "clawhub-config.json"); - writeFileSync( - path, - `${JSON.stringify( - { - registry: process.env.CLAWHUB_REGISTRY, - token: process.env.CLAWHUB_TOKEN, - }, - null, - 2, - )}\n`, - ); - console.log(path); - EOF - echo "CLAWHUB_CONFIG_PATH=${RUNNER_TEMP}/clawhub-config.json" >> "$GITHUB_ENV" - - - name: Check ClawHub package version - id: clawhub_package_version - env: - PACKAGE_NAME: ${{ matrix.plugin.packageName }} - PACKAGE_VERSION: ${{ matrix.plugin.version }} - CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} - run: | - set -euo pipefail - encoded_name="$(node -e 'console.log(encodeURIComponent(process.env.PACKAGE_NAME ?? ""))')" - encoded_version="$(node -e 'console.log(encodeURIComponent(process.env.PACKAGE_VERSION ?? ""))')" - url="${CLAWHUB_REGISTRY%/}/api/v1/packages/${encoded_name}/versions/${encoded_version}" - status="" - for attempt in $(seq 1 8); do - status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' "${url}")" - if [[ "${status}" == "404" || "${status}" =~ ^2 ]]; then - break - fi - if [[ "${status}" == "429" || "${status}" =~ ^5 ]]; then - echo "ClawHub availability check returned ${status} for ${PACKAGE_NAME}@${PACKAGE_VERSION}; retrying (${attempt}/8)." - sleep 60 - continue - fi - break - done - if [[ "${status}" =~ ^2 ]]; then - echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on ClawHub." - echo "already_published=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [[ "${status}" != "404" ]]; then - echo "Unexpected ClawHub response (${status}) for ${PACKAGE_NAME}@${PACKAGE_VERSION}." - exit 1 - fi - echo "already_published=false" >> "$GITHUB_OUTPUT" - - - name: Publish - if: steps.clawhub_package_version.outputs.already_published != 'true' + - name: Pack ClawHub package artifact env: CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} SOURCE_REPO: ${{ github.repository }} @@ -481,8 +360,68 @@ jobs: SOURCE_REF: ${{ github.ref }} PACKAGE_TAG: ${{ matrix.plugin.publishTag }} PACKAGE_DIR: ${{ matrix.plugin.packageDir }} - run: bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}" + OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR: ${{ runner.temp }}/clawhub-package-artifact + run: bash scripts/plugin-clawhub-publish.sh --pack "${PACKAGE_DIR}" + - name: Upload ClawHub package artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.plugin.artifactName }} + path: ${{ runner.temp }}/clawhub-package-artifact/*.tgz + if-no-files-found: error + retention-days: 7 + + approve_plugins_clawhub_release: + needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts] + if: always() && github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' + runs-on: ubuntu-latest + environment: clawhub-plugin-release + permissions: + contents: read + steps: + - name: Approve Plugin ClawHub release publish + run: | + echo "Approved CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows release publish gate." + + publish_plugins_clawhub: + needs: + [preview_plugins_clawhub, pack_plugins_clawhub_artifacts, approve_plugins_clawhub_release] + if: always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' && (inputs.dry_run == true || needs.approve_plugins_clawhub_release.result == 'success') + uses: openclaw/clawhub/.github/workflows/package-publish.yml@9d49df109d4ad3dc8a6ecf05d26b39f46d294721 + permissions: + actions: read + contents: read + id-token: write + strategy: + fail-fast: false + max-parallel: 32 + matrix: + plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }} + with: + package_artifact_name: ${{ matrix.plugin.artifactName }} + dry_run: ${{ inputs.dry_run }} + registry: https://clawhub.ai + site: https://clawhub.ai + tags: ${{ matrix.plugin.publishTag }} + source_repo: ${{ github.repository }} + source_commit: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }} + source_ref: ${{ github.ref }} + source_path: ${{ matrix.plugin.packageDir }} + inspector_artifact_name: ${{ matrix.plugin.artifactName }}-inspector + publish_json_artifact_name: ${{ matrix.plugin.artifactName }}-publish-json + + verify_published_clawhub_package: + needs: [preview_plugins_clawhub, publish_plugins_clawhub] + if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + max-parallel: 32 + matrix: + plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }} + steps: - name: Verify published ClawHub package env: CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }} diff --git a/.github/workflows/plugin-npm-release.yml b/.github/workflows/plugin-npm-release.yml index e7883771c1ea..aab8648d2a9d 100644 --- a/.github/workflows/plugin-npm-release.yml +++ b/.github/workflows/plugin-npm-release.yml @@ -288,6 +288,7 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + OPENCLAW_NPM_PUBLISH_AUTH_MODE: trusted-publisher run: bash scripts/plugin-npm-publish.sh --publish "${{ matrix.plugin.packageDir }}" - name: Verify published runtime diff --git a/.github/workflows/qa-live-transports-convex.yml b/.github/workflows/qa-live-transports-convex.yml index ed95de2ccde2..8faeccc660e2 100644 --- a/.github/workflows/qa-live-transports-convex.yml +++ b/.github/workflows/qa-live-transports-convex.yml @@ -65,7 +65,7 @@ jobs: steps: - name: Require maintainer-level repository access id: permission - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | if (context.eventName === "schedule") { @@ -159,7 +159,7 @@ jobs: run_mock_parity: name: Run QA Lab mock parity lane needs: [validate_selected_ref] - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 30 env: QA_PARITY_CONCURRENCY: "1" @@ -186,7 +186,7 @@ jobs: - name: Build private QA runtime env: - NODE_OPTIONS: --max-old-space-size=8192 + NODE_OPTIONS: --max-old-space-size=12288 run: pnpm build - name: Run OpenAI candidate lane @@ -232,7 +232,7 @@ jobs: name: Run live runtime token-efficiency lane needs: [authorize_actor, validate_selected_ref] if: github.event_name == 'schedule' - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 45 environment: qa-live-shared env: @@ -267,7 +267,7 @@ jobs: - name: Build private QA runtime env: - NODE_OPTIONS: --max-old-space-size=8192 + NODE_OPTIONS: --max-old-space-size=12288 run: pnpm build - name: Run live runtime parity lane @@ -321,7 +321,7 @@ jobs: name: Run Matrix live QA lane needs: [authorize_actor, validate_selected_ref] if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.matrix_profile == 'all') }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 60 environment: qa-live-shared steps: @@ -352,7 +352,7 @@ jobs: - name: Build private QA runtime env: - NODE_OPTIONS: --max-old-space-size=8192 + NODE_OPTIONS: --max-old-space-size=12288 run: pnpm build - name: Run Matrix live lane @@ -397,7 +397,7 @@ jobs: name: Run Matrix live QA lane (${{ matrix.profile }}) needs: [authorize_actor, validate_selected_ref] if: ${{ github.event_name == 'workflow_dispatch' && inputs.matrix_profile == 'all' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 60 environment: qa-live-shared strategy: @@ -437,7 +437,7 @@ jobs: - name: Build private QA runtime env: - NODE_OPTIONS: --max-old-space-size=8192 + NODE_OPTIONS: --max-old-space-size=12288 run: pnpm build - name: Run Matrix live lane shard @@ -480,7 +480,7 @@ jobs: run_live_telegram: name: Run Telegram live QA lane with Convex leases needs: [authorize_actor, validate_selected_ref] - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 60 environment: qa-live-shared steps: @@ -520,7 +520,7 @@ jobs: - name: Build private QA runtime env: - NODE_OPTIONS: --max-old-space-size=8192 + NODE_OPTIONS: --max-old-space-size=12288 run: pnpm build - name: Run Telegram live lane @@ -532,7 +532,6 @@ jobs: OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }} OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000" OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1" - OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1" INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.scenario || '' }} run: | set -euo pipefail @@ -575,7 +574,7 @@ jobs: run_live_discord: name: Run Discord live QA lane with Convex leases needs: [authorize_actor, validate_selected_ref] - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 60 environment: qa-live-shared steps: @@ -615,7 +614,7 @@ jobs: - name: Build private QA runtime env: - NODE_OPTIONS: --max-old-space-size=8192 + NODE_OPTIONS: --max-old-space-size=12288 run: pnpm build - name: Run Discord live lane @@ -669,7 +668,7 @@ jobs: run_live_whatsapp: name: Run WhatsApp live QA lane with Convex leases needs: [authorize_actor, validate_selected_ref] - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 60 concurrency: group: qa-live-whatsapp-shared @@ -712,7 +711,7 @@ jobs: - name: Build private QA runtime env: - NODE_OPTIONS: --max-old-space-size=8192 + NODE_OPTIONS: --max-old-space-size=12288 run: pnpm build - name: Run WhatsApp live lane @@ -766,7 +765,7 @@ jobs: run_live_slack: name: Run Slack live QA lane with Convex leases needs: [authorize_actor, validate_selected_ref] - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 60 environment: qa-live-shared steps: @@ -806,7 +805,7 @@ jobs: - name: Build private QA runtime env: - NODE_OPTIONS: --max-old-space-size=8192 + NODE_OPTIONS: --max-old-space-size=12288 run: pnpm build - name: Run Slack live lane diff --git a/.github/workflows/sandbox-common-smoke.yml b/.github/workflows/sandbox-common-smoke.yml index e7c529443dd9..aa92a617cf62 100644 --- a/.github/workflows/sandbox-common-smoke.yml +++ b/.github/workflows/sandbox-common-smoke.yml @@ -35,7 +35,7 @@ jobs: submodules: false - name: Set up Docker Builder - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 - name: Build minimal sandbox base (USER sandbox) shell: bash diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 3734768002d2..2e40405d6e06 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -68,7 +68,7 @@ jobs: days-before-pr-close: 7 stale-issue-label: stale stale-pr-label: stale - exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle + exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear exempt-pr-labels: maintainer,no-stale,bad-barnacle operations-per-run: 2000 ascending: true @@ -100,7 +100,7 @@ jobs: days-before-pr-stale: -1 days-before-pr-close: -1 stale-issue-label: stale - exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle + exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear operations-per-run: 2000 ascending: true include-only-assigned: true @@ -172,7 +172,7 @@ jobs: days-before-pr-close: 7 stale-issue-label: stale stale-pr-label: stale - exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle + exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear exempt-pr-labels: maintainer,no-stale,bad-barnacle operations-per-run: 2000 ascending: true @@ -203,7 +203,7 @@ jobs: days-before-pr-stale: -1 days-before-pr-close: -1 stale-issue-label: stale - exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle + exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear operations-per-run: 2000 ascending: true include-only-assigned: true @@ -277,6 +277,9 @@ jobs: "security", "no-stale", "bad-barnacle", + "clawsweeper:queueable-fix", + "clawsweeper:source-repro", + "clawsweeper:fix-shape-clear", ]); const prExemptLabels = new Set(["maintainer", "no-stale", "bad-barnacle"]); const maintainerAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); @@ -509,60 +512,62 @@ jobs: let locked = 0; let inspected = 0; + let cursor = null; - let page = 1; while (true) { - const { data: issues } = await github.rest.issues.listForRepo({ - owner, - repo, - state: "closed", - sort: "updated", - direction: "desc", - per_page: perPage, - page, - }); + const result = await github.graphql( + `query ClosedIssuesForLocking( + $owner: String! + $repo: String! + $cursor: String + $perPage: Int! + ) { + repository(owner: $owner, name: $repo) { + issues( + first: $perPage + after: $cursor + states: CLOSED + orderBy: { field: CREATED_AT, direction: ASC } + ) { + nodes { + number + locked + closedAt + comments(last: 1) { + nodes { + createdAt + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + }`, + { + owner, + repo, + cursor, + perPage, + }, + ); + const issues = result.repository.issues; - if (issues.length === 0) { - break; - } - - for (const issue of issues) { - if (issue.pull_request) { - continue; - } - if (issue.locked) { - continue; - } - if (!issue.closed_at) { + for (const issue of issues.nodes) { + if (issue.locked || !issue.closedAt) { continue; } inspected += 1; - const closedAtMs = Date.parse(issue.closed_at); - if (!Number.isFinite(closedAtMs)) { - continue; - } - if (closedAtMs > cutoffMs) { + const closedAtMs = Date.parse(issue.closedAt); + if (!Number.isFinite(closedAtMs) || closedAtMs > cutoffMs) { continue; } - let lastCommentMs = 0; - if (issue.comments > 0) { - const { data: comments } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issue.number, - per_page: 1, - page: 1, - sort: "created", - direction: "desc", - }); - - if (comments.length > 0) { - lastCommentMs = Date.parse(comments[0].created_at); - } - } - + const lastComment = issue.comments.nodes[0]; + const lastCommentMs = lastComment ? Date.parse(lastComment.createdAt) : 0; const lastActivityMs = Math.max(closedAtMs, lastCommentMs || 0); if (lastActivityMs > cutoffMs) { continue; @@ -578,7 +583,10 @@ jobs: locked += 1; } - page += 1; + if (!issues.pageInfo.hasNextPage || !issues.pageInfo.endCursor) { + break; + } + cursor = issues.pageInfo.endCursor; } core.info(`Inspected ${inspected} closed issues; locked ${locked}.`); diff --git a/.github/workflows/test-performance-agent.yml b/.github/workflows/test-performance-agent.yml index 2311912dba27..0ec19bd43995 100644 --- a/.github/workflows/test-performance-agent.yml +++ b/.github/workflows/test-performance-agent.yml @@ -129,7 +129,7 @@ jobs: - name: Run Codex test performance agent if: steps.gate.outputs.run_agent == 'true' - uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02 + uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1 with: openai-api-key: ${{ secrets.OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }} prompt-file: .github/codex/prompts/test-performance-agent.md diff --git a/.github/workflows/windows-node-release.yml b/.github/workflows/windows-node-release.yml index 61411a3cc7ca..edb6107b0f02 100644 --- a/.github/workflows/windows-node-release.yml +++ b/.github/workflows/windows-node-release.yml @@ -8,9 +8,12 @@ on: required: true type: string windows_node_tag: - description: openclaw-windows-node release tag to promote, or latest + description: Exact openclaw-windows-node release tag to promote, for example v0.6.3 + required: true + type: string + expected_installer_digests: + description: Compact JSON map of installer asset names to pinned source sha256 digests required: true - default: latest type: string permissions: @@ -31,46 +34,129 @@ jobs: env: RELEASE_TAG: ${{ inputs.tag }} WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }} + EXPECTED_INSTALLER_DIGESTS: ${{ inputs.expected_installer_digests }} GH_TOKEN: ${{ github.token }} run: | if ($env:RELEASE_TAG -notmatch '^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-(alpha|beta)\.[1-9][0-9]*)|(-[1-9][0-9]*))?$') { throw "Invalid OpenClaw release tag: $env:RELEASE_TAG" } - if ($env:WINDOWS_NODE_TAG -ne "latest" -and $env:WINDOWS_NODE_TAG -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$') { - throw "Invalid openclaw-windows-node release tag: $env:WINDOWS_NODE_TAG" + $stableRelease = -not ( + $env:RELEASE_TAG.Contains("-alpha.") -or + $env:RELEASE_TAG.Contains("-beta.") + ) + if ($env:WINDOWS_NODE_TAG -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$') { + throw "windows_node_tag must be an explicit openclaw-windows-node release tag, not latest: $env:WINDOWS_NODE_TAG" + } + + try { + $expectedDigests = $env:EXPECTED_INSTALLER_DIGESTS | ConvertFrom-Json -AsHashtable + } catch { + throw "expected_installer_digests must be a JSON object: $_" + } + # Add future signed installer names, such as MSIX x64/ARM64, here. + $requiredInstallerNames = @( + "OpenClawCompanion-Setup-x64.exe", + "OpenClawCompanion-Setup-arm64.exe" + ) + $allowedTargetCompanionAssetNames = @( + $requiredInstallerNames + "OpenClawCompanion-SHA256SUMS.txt" + ) + if ($expectedDigests.Count -ne $requiredInstallerNames.Count) { + throw "expected_installer_digests must contain exactly the current installer asset contract." + } + foreach ($name in $requiredInstallerNames) { + $digest = [string]$expectedDigests[$name] + if ($digest -notmatch '^sha256:[A-Fa-f0-9]{64}$') { + throw "expected_installer_digests is missing a valid pinned digest for $name." + } + } + + $targetRelease = gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY --json tagName,isDraft,isPrerelease,assets,url | ConvertFrom-Json + if ($targetRelease.tagName -ne $env:RELEASE_TAG) { + throw "OpenClaw release tag mismatch: expected $env:RELEASE_TAG, got $($targetRelease.tagName)" + } + $unexpectedTargetCompanionAssets = @( + $targetRelease.assets | + Where-Object { + $_.name.StartsWith("OpenClawCompanion-") -and + $_.name -notin $allowedTargetCompanionAssetNames + } | + ForEach-Object name | + Sort-Object + ) + if ($unexpectedTargetCompanionAssets.Count -ne 0) { + throw "Target OpenClaw release contains unexpected OpenClawCompanion assets before upload: $($unexpectedTargetCompanionAssets -join ', ')" + } + + $sourceRelease = gh release view $env:WINDOWS_NODE_TAG --repo openclaw/openclaw-windows-node --json tagName,isDraft,isPrerelease,assets,url | ConvertFrom-Json + if ($sourceRelease.tagName -ne $env:WINDOWS_NODE_TAG) { + throw "Windows source release tag mismatch: expected $env:WINDOWS_NODE_TAG, got $($sourceRelease.tagName)" + } + if ($sourceRelease.isDraft) { + throw "Windows source release must be published: $($sourceRelease.url)" + } + if ($stableRelease -and $sourceRelease.isPrerelease) { + throw "Stable OpenClaw releases require a non-prerelease Windows source release: $($sourceRelease.url)" + } + foreach ($name in $requiredInstallerNames) { + $sourceAssets = @($sourceRelease.assets | Where-Object name -eq $name) + if ($sourceAssets.Count -ne 1) { + throw "Windows source release must contain exactly one required asset $name; found $($sourceAssets.Count)." + } + if ([string]$sourceAssets[0].digest -ne [string]$expectedDigests[$name]) { + throw "Windows source release asset digest does not match the pinned digest: $name" + } } - gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY | Out-Null - name: Download Windows Hub release installers shell: pwsh env: WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }} + EXPECTED_INSTALLER_DIGESTS: ${{ inputs.expected_installer_digests }} GH_TOKEN: ${{ github.token }} run: | New-Item -ItemType Directory -Force -Path dist | Out-Null - $tagArgs = @() - if ($env:WINDOWS_NODE_TAG -ne "latest") { - $tagArgs += $env:WINDOWS_NODE_TAG - } - gh release download @tagArgs ` - --repo openclaw/openclaw-windows-node ` - --pattern "OpenClawCompanion-Setup-*.exe" ` - --dir dist - - $expected = @( - "dist/OpenClawCompanion-Setup-x64.exe", - "dist/OpenClawCompanion-Setup-arm64.exe" + # Add future signed installer patterns, such as MSIX x64/ARM64, here. + # Every matched installer is signature-checked, checksummed, and promoted. + $installerPatterns = @( + "OpenClawCompanion-Setup-x64.exe", + "OpenClawCompanion-Setup-arm64.exe" ) - foreach ($file in $expected) { - if (-not (Test-Path -LiteralPath $file)) { - throw "Missing expected Windows installer: $file" + $downloadArgs = @( + $env:WINDOWS_NODE_TAG, + "--repo", "openclaw/openclaw-windows-node", + "--dir", "dist" + ) + foreach ($pattern in $installerPatterns) { + $downloadArgs += @("--pattern", $pattern) + } + gh release download @downloadArgs + if ($LASTEXITCODE -ne 0) { + throw "Failed to download Windows release assets from $env:WINDOWS_NODE_TAG." + } + + foreach ($pattern in $installerPatterns) { + $patternMatches = @(Get-ChildItem -LiteralPath dist -File | Where-Object Name -Like $pattern) + if ($patternMatches.Count -ne 1) { + throw "Expected exactly one Windows installer matching '$pattern', found $($patternMatches.Count)." + } + } + + $expectedDigests = $env:EXPECTED_INSTALLER_DIGESTS | ConvertFrom-Json -AsHashtable + foreach ($file in Get-ChildItem -LiteralPath dist -File) { + $expectedHash = ([string]$expectedDigests[$file.Name]) -replace '^sha256:', '' + $actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash + if ($actualHash -ne $expectedHash) { + throw "Downloaded Windows source asset does not match pinned digest: $($file.Name)" } } - name: Verify Authenticode signatures shell: pwsh run: | - Get-ChildItem -LiteralPath dist -Filter "OpenClawCompanion-Setup-*.exe" | ForEach-Object { + $expectedSignerSubject = "CN=OpenClaw Foundation, O=OpenClaw Foundation, L=Mill Valley, S=California, C=US" + Get-ChildItem -LiteralPath dist -File | ForEach-Object { $signature = Get-AuthenticodeSignature -LiteralPath $_.FullName if ($signature.Status -ne "Valid") { throw "$($_.Name) Authenticode signature was $($signature.Status)." @@ -78,6 +164,9 @@ jobs: if (-not $signature.SignerCertificate) { throw "$($_.Name) has no signer certificate." } + if ($signature.SignerCertificate.Subject -ne $expectedSignerSubject) { + throw "$($_.Name) has unexpected signer subject $($signature.SignerCertificate.Subject)." + } [pscustomobject]@{ File = $_.Name Signer = $signature.SignerCertificate.Subject @@ -88,7 +177,7 @@ jobs: - name: Write SHA-256 manifest shell: pwsh run: | - Get-ChildItem -LiteralPath dist -Filter "OpenClawCompanion-Setup-*.exe" | + Get-ChildItem -LiteralPath dist -File | Sort-Object Name | ForEach-Object { $hash = Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName @@ -101,12 +190,81 @@ jobs: RELEASE_TAG: ${{ inputs.tag }} GH_TOKEN: ${{ github.token }} run: | - gh release upload $env:RELEASE_TAG ` - dist/OpenClawCompanion-Setup-x64.exe ` - dist/OpenClawCompanion-Setup-arm64.exe ` - dist/OpenClawCompanion-SHA256SUMS.txt ` - --repo $env:GITHUB_REPOSITORY ` - --clobber + $releaseAssets = @(Get-ChildItem -LiteralPath dist -File | Sort-Object Name | ForEach-Object FullName) + gh release upload $env:RELEASE_TAG @releaseAssets --repo $env:GITHUB_REPOSITORY --clobber + if ($LASTEXITCODE -ne 0) { + throw "Failed to upload Windows release assets to $env:RELEASE_TAG." + } + + - name: Verify promoted release asset contract + shell: pwsh + env: + RELEASE_TAG: ${{ inputs.tag }} + GH_TOKEN: ${{ github.token }} + run: | + New-Item -ItemType Directory -Force -Path verified | Out-Null + $expectedAssets = @(Get-ChildItem -LiteralPath dist -File | Sort-Object Name) + $expectedCompanionAssetNames = @($expectedAssets | ForEach-Object Name | Sort-Object) + $targetRelease = gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY --json assets | ConvertFrom-Json + $actualCompanionAssetNames = @( + $targetRelease.assets | + Where-Object { $_.name.StartsWith("OpenClawCompanion-") } | + ForEach-Object name | + Sort-Object + ) + $assetContractDiff = @( + Compare-Object ` + -ReferenceObject $expectedCompanionAssetNames ` + -DifferenceObject $actualCompanionAssetNames + ) + if ( + $actualCompanionAssetNames.Count -ne $expectedCompanionAssetNames.Count -or + $assetContractDiff.Count -ne 0 + ) { + throw "Promoted OpenClawCompanion asset names do not exactly match the current contract." + } + + foreach ($asset in $expectedAssets) { + gh release download $env:RELEASE_TAG ` + --repo $env:GITHUB_REPOSITORY ` + --pattern $asset.Name ` + --dir verified + if ($LASTEXITCODE -ne 0) { + throw "Failed to download promoted Windows release asset $($asset.Name)." + } + } + + $manifestPath = "verified/OpenClawCompanion-SHA256SUMS.txt" + $manifestEntries = @(Get-Content -LiteralPath $manifestPath | ForEach-Object { + if ($_ -notmatch '^([A-Fa-f0-9]{64}) ([^\\/]+)$') { + throw "Invalid Windows SHA-256 manifest entry: $_" + } + [PSCustomObject]@{ + Hash = $Matches[1] + Name = $Matches[2] + } + }) + $expectedInstallerNames = @( + $expectedAssets | + Where-Object Name -ne "OpenClawCompanion-SHA256SUMS.txt" | + ForEach-Object Name + ) + $manifestInstallerNames = @($manifestEntries | ForEach-Object Name | Sort-Object) + $contractDiff = @( + Compare-Object ` + -ReferenceObject $expectedInstallerNames ` + -DifferenceObject $manifestInstallerNames + ) + if ($contractDiff.Count -ne 0) { + throw "Promoted Windows SHA-256 manifest does not match the installer asset contract." + } + + foreach ($entry in $manifestEntries) { + $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath "verified/$($entry.Name)").Hash + if ($hash -ne $entry.Hash) { + throw "Promoted Windows release asset checksum mismatch: $($entry.Name)" + } + } - name: Summary shell: pwsh @@ -119,8 +277,9 @@ jobs: OpenClaw release: $env:RELEASE_TAG Source release: openclaw/openclaw-windows-node@$env:WINDOWS_NODE_TAG - - - https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-Setup-x64.exe - - https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-Setup-arm64.exe - - https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-SHA256SUMS.txt "@ >> $env:GITHUB_STEP_SUMMARY + Get-ChildItem -LiteralPath dist -File | + Sort-Object Name | + ForEach-Object { + "- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/$($_.Name)" + } >> $env:GITHUB_STEP_SUMMARY diff --git a/.github/workflows/workflow-sanity.yml b/.github/workflows/workflow-sanity.yml index da6fcdb73cd0..6199ede563c3 100644 --- a/.github/workflows/workflow-sanity.yml +++ b/.github/workflows/workflow-sanity.yml @@ -34,10 +34,25 @@ jobs: git init "$GITHUB_WORKSPACE" git -C "$GITHUB_WORKSPACE" config gc.auto 0 git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git" - timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \ - -c protocol.version=2 \ - fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \ - "+${CHECKOUT_SHA}:refs/remotes/origin/checkout" + fetch_checkout_ref() { + local fetch_status + for attempt in 1 2 3; do + timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \ + -c protocol.version=2 \ + fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \ + "+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0 + fetch_status="$?" + if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then + return "$fetch_status" + fi + if [ "$attempt" = "3" ]; then + return "$fetch_status" + fi + echo "::warning::checkout fetch for '$CHECKOUT_SHA' timed out on attempt $attempt; retrying" + sleep 5 + done + } + fetch_checkout_ref git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout - name: Fail on tabs in workflow files @@ -78,10 +93,25 @@ jobs: git init "$GITHUB_WORKSPACE" git -C "$GITHUB_WORKSPACE" config gc.auto 0 git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git" - timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \ - -c protocol.version=2 \ - fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \ - "+${CHECKOUT_SHA}:refs/remotes/origin/checkout" + fetch_checkout_ref() { + local fetch_status + for attempt in 1 2 3; do + timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \ + -c protocol.version=2 \ + fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \ + "+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0 + fetch_status="$?" + if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then + return "$fetch_status" + fi + if [ "$attempt" = "3" ]; then + return "$fetch_status" + fi + echo "::warning::checkout fetch for '$CHECKOUT_SHA' timed out on attempt $attempt; retrying" + sleep 5 + done + } + fetch_checkout_ref git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout - name: Setup Python @@ -190,10 +220,25 @@ jobs: git init "$GITHUB_WORKSPACE" git -C "$GITHUB_WORKSPACE" config gc.auto 0 git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git" - timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \ - -c protocol.version=2 \ - fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \ - "+${CHECKOUT_SHA}:refs/remotes/origin/checkout" + fetch_checkout_ref() { + local fetch_status + for attempt in 1 2 3; do + timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \ + -c protocol.version=2 \ + fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \ + "+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0 + fetch_status="$?" + if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then + return "$fetch_status" + fi + if [ "$attempt" = "3" ]; then + return "$fetch_status" + fi + echo "::warning::checkout fetch for '$CHECKOUT_SHA' timed out on attempt $attempt; retrying" + sleep 5 + done + } + fetch_checkout_ref git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout - name: Setup Node environment diff --git a/.gitignore b/.gitignore index 5f5a3d35eaa1..c19559651775 100644 --- a/.gitignore +++ b/.gitignore @@ -127,6 +127,8 @@ mantis/ !.agents/skills/clawdtributor/** !.agents/skills/control-ui-e2e/ !.agents/skills/control-ui-e2e/** +!.agents/skills/discord-user-post/ +!.agents/skills/discord-user-post/** !.agents/skills/gitcrawl/ !.agents/skills/gitcrawl/** !.agents/skills/technical-documentation/ diff --git a/AGENTS.md b/AGENTS.md index 3309487f3084..fba3ddf23a90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -214,6 +214,7 @@ Skills own workflows; root owns hard policy and routing. - Vitest. Colocated `*.test.ts`; e2e `*.e2e.test.ts`; example models `sonnet-4.6`, `gpt-5.5`; test GPT with 5.5 preferred, 5.4 ok; no GPT-4.x agent-smoke defaults. - Prefer behavior tests over workflow/docs string greps. Put operator policy reminders in AGENTS/docs. +- QA scenario sources are YAML only: `qa/scenarios/index.yaml` and `qa/scenarios//*.yaml`. Do not add fenced `qa-scenario`/`qa-flow` Markdown files under `qa/scenarios/`. - Clean timers/env/globals/mocks/sockets/temp dirs/module state; `--isolate=false` safe. - Prefer injection and narrow `*.runtime.ts` mocks over broad barrels or `openclaw/plugin-sdk/*`. - Do not edit baseline/inventory/ignore/snapshot/expected-failure files to silence checks without explicit approval. @@ -251,9 +252,11 @@ Skills own workflows; root owns hard policy and routing. - Lockfiles/shrinkwrap are security surface: review `pnpm-lock.yaml`, `npm-shrinkwrap.json`, `package-lock.json`; root/plugin npm packages ship shrinkwrap, not package-lock. - Carbon pins owner-only: do not change `@buape/carbon` unless Shadow (`@thewilloftheshadow`, verified by `gh`) asks. - Releases/publish/version bumps need explicit approval. Use `$release-openclaw-maintainer`. +- Release versions use `YYYY.M.PATCH`, where `PATCH` is a sequential monthly release-train number, never the calendar day. Stable and beta tags determine the current train; alpha-only tags do not consume or advance the beta/stable patch number. After `2026.6.5`, the next beta train is `2026.6.6-beta.1` even if higher alpha-only tags exist. +- Alpha/nightly versions use the next unreleased train plus an incrementing prerelease number. Repeated nightlies for the same train increment only `alpha.N`; they must not mint a new patch number from the date. - Backport means apply to newest open `release/` branch unless user names another target. - GHSA/advisories: `$openclaw-ghsa-maintainer` / `$security-triage`. Secret scanning: `$openclaw-secret-scanning-maintainer`. -- Beta tag/version match: `vYYYY.M.D-beta.N` -> npm `YYYY.M.D-beta.N --tag beta`. +- Beta tag/version match: `vYYYY.M.PATCH-beta.N` -> npm `YYYY.M.PATCH-beta.N --tag beta`. ## Platform / Ops diff --git a/CHANGELOG.md b/CHANGELOG.md index d66f0d6931c3..2e87a1fc77d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,120 @@ Docs: https://docs.openclaw.ai +## 2026.6.8 + +### Highlights + +- Telegram and WhatsApp channel delivery are richer and less brittle: Telegram can send structured rich text with tables, lists, expandable blockquotes, prompt-preserving CLI backend delivery, retired native draft migration, and safer rich-media boundaries, while WhatsApp now honors configured ACP bindings. (#92679, #84082, #89421, #92513) Thanks @obviyus, @jzakirov, @spacegeologist, and @TurboTheTurtle. +- Agent and Gateway recovery is sharper across account-scoped DM sends, generated media completions, restart shutdown aborts, yielded subagent pauses, yielded cron media, heartbeat dedupe, session identity prompts, and unknown OpenAI agent selector rejection. (#92788, #91246, #91357, #92631, #92146, #91287, #92468, #92510) Thanks @yetval, @TurboTheTurtle, @ooiuuii, @openperf, @IWhatsskill, @ZengWen-DT, and @zhangguiping-xydt. +- Provider/model handling expands and tightens with GLM-5.2, Claude Haiku 4.5 catalog rows, OpenRouter and Google Vertex provider-prefix normalization, managed SecretRef auth, bounded model browse discovery, storeless OpenAI Responses replay gating, and Claude 4.5 Copilot tool-streaming safety. (#92796, #90116, #92627, #91218, #90686, #92247, #90706, #75393) Thanks @arkyu2077, @liuhao1024, @bymle, @rohitjavvadi, @samson910022, @snowzlm, and @Kailigithub. +- `/usage` and reply payload hooks now have a native full footer renderer, default template, fixed-decimal formatting, credential-aware limits, better partial-count handling, and warnings for broken templates instead of silent bad output. (#92657, #89835, #89629) Thanks @Marvinthebored. +- UI and mobile flows are steadier: workspace files can collapse and start collapsed, WebChat backscroll survives streaming, the sidebar session picker remains interactive above the desktop workbench, reset soft args survive UI dispatch, stale dashboard session parent lineage is preserved, and iOS reconnects stale foreground gateways. (#92779, #92622, #92705, #91353, #90658, #92552) Thanks @shakkernerd, @TurboTheTurtle, @NianJiuZst, @zhouhe-xydt, @luoyanglang, and @Solvely-Colin. +- Memory, state, and diagnostics recover cleaner: oversized OpenAI embedding batches split before 431s, QMD memory search stays available in transient mode, SQLite avoids WAL on NFS state volumes, stuck-session recovery scheduling no longer resets warning backoff, and Infinity chunk limits stay genuinely unbounded. (#92650, #92618, #92639, #91247, #92752, #92735) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, @gnanam1990, and @yhterrance. + +### Changes + +- Providers/models: add GLM-5.2 support and Claude Haiku 4.5 catalog entries while keeping provider-qualified model IDs normalized across OpenRouter and Google Vertex paths. (#92796, #90116, #92627, #91218) Thanks @arkyu2077, @liuhao1024, and @bymle. +- Channel plugins: ship Telegram rich-message delivery and WhatsApp ACP binding support, including rich prompt handoff to CLI backends and transport fixtures for richer drafts. (#92679, #92513) Thanks @obviyus and @TurboTheTurtle. +- Agent commands: support `/btw` in CLI-backed sessions and keep CLI usage-error exits classified as usage failures instead of successful runs. (#92669, #92162) Thanks @joshavant and @Pandah97. +- Usage hooks: add built-in full footer rendering, default footer templates, per-turn usage state, credential-aware limits, and fixed-decimal formatting for usage-bar templates. (#92657, #89835, #89629) Thanks @Marvinthebored. +- Docs and operator guidance: document node config examples, clarify before-install hook scope, correct agent default concurrency comments, refresh ZAI provider docs, and update channel/group docs for current Telegram and WhatsApp behavior. (#92677, #92766, #92695) Thanks @liuhao1024, @sallyom, and @ArielSmoliar. + +### Fixes + +- Channels and delivery: preserve account-scoped DM channel send policy, rich Telegram final replies, rich Telegram tables and lists, Telegram thread-create CLI remapping, Slack outbound `message_sent` hooks, contributed message-tool schema optionality, same-channel generated media completions, and channel chunking around surrogate pairs and Infinity limits. (#92788, #92679, #89421, #89943, #91137, #91246, #92735) Thanks @yetval, @obviyus, @spacegeologist, @rishitamrakar, @lundog, @TurboTheTurtle, and @yhterrance. +- Discord: give generated auto-thread titles a 60-second timeout and 4,096-token reasoning-model output budget, clamped to the selected model output cap. (#64734) Thanks @hanamizuki. +- Agent, cron, and Gateway runtime: mark active main sessions before restart shutdown aborts, pause yielded subagent runs whose terminal also signals abort, preserve yielded media completions, de-duplicate main-session heartbeat events, expose session identity in runtime prompts, reject unknown OpenAI agent selectors, keep generated media completions and slash-command block replies in WebChat, preserve fresh post-compaction usage while clearing stale usage snapshots, and require admin privileges for HTTP session/model override surfaces. (#91357, #92631, #92146, #91287, #92468, #92510, #91246, #50795, #50845, #82874, #92651, #92646) Thanks @ooiuuii, @openperf, @IWhatsskill, @ZengWen-DT, @zhangguiping-xydt, @Hollychou924, @leno23, and @TurboTheTurtle. +- Providers and model replay: preserve storeless OpenAI Responses replay compatibility, avoid eager tool streaming for Claude 4.5 in Copilot, honor profile auth for SecretRef model entries, bound model browsing, strip provider prefixes where runtimes need bare IDs, and surface nested embedding fetch failures. (#90706, #75393, #90686, #92247, #92627, #91218, #92628) Thanks @snowzlm, @Kailigithub, @rohitjavvadi, @samson910022, @liuhao1024, @bymle, and @mushuiyu886. +- Memory, state, diagnostics, and config: split header-too-large embedding batches, keep QMD memory search enabled in transient mode, avoid SQLite WAL on NFS volumes, preserve recovery scheduling outside stuck-session warning backoff, and keep shell environment fallbacks contained in config write tests. (#92650, #92618, #92639, #91247, #92752) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, and @gnanam1990. +- UI/mobile/TUI: preserve dashboard session parent lineage, WebChat backscroll, reset soft command args, sidebar session picker interactivity, collapsed workspace files, resolved `/model` confirmation refs, and stale foreground iOS Gateway reconnects. (#90658, #92622, #91353, #92705, #92779, #92773, #92552) Thanks @luoyanglang, @TurboTheTurtle, @zhouhe-xydt, @NianJiuZst, @shakkernerd, @NarahariRaghava, and @Solvely-Colin. +- Release and test reliability: extend slow Gateway/full-suite watchdogs, split local full-suite shards when throttled, stabilize plugin auth marker fixtures, avoid brittle provider-ref error text, and keep QA Lab bootstrap selection assertions aligned with flow-only scenarios. (#92652) +- macOS Peekaboo bridge: update the embedded Peekaboo package to 3.5.2 and route bundled-skill CLI commands through the OpenClaw app bridge so they inherit its Screen Recording and Accessibility grants. +- Agent routing: route subagent RPC callbacks addressed to an agent-shaped `--to` target to the correct session key instead of falling back to the main session, so WeChat (and other channel) session-key callbacks reach the intended subagent session. (#90231) Thanks @zhangguiping-xydt. +- QQBot delivery: keep markdown table chunks self-contained across message boundaries by preserving table state across block deliveries, flushing unfinished table-row fragments as plain text, and detecting short pipe-terminated rows by column count so split rows are not sent as malformed markdown. (#92428) Thanks @sliverp. + +## 2026.6.6 + +### Highlights + +- Security boundaries are substantially tighter across transcripts, sandbox binds, host environment inheritance, MCP stdio, Codex HTTP access, native search policy, elevated sender checks, deleted-agent ACP bypasses, loopback tools, Discord moderation, and Teams group actions; exec approvals now fail closed on timeout. (#91529, #91618, #91615, #91619, #91741, #91745, #91746, #91748, #91749, #91750, #91751, #91752, #91763, #89938) Thanks @joshavant, @pgondhi987, @mmaps, @eleqtrizit, @shakkernerd, and @drobison00. +- Telegram delivery is safer and more coherent: account-scoped topics route to the right agent, streamed text survives tool calls, `/compact` works on generic ingress, callback handling uses concrete APIs, draft chunking is shared, durable dispatch dedupe moved into the SDK, and unauthorized DM text stays out of cache and prompt context. (#91189, #88682, #89588, #90212, #91876, #91874, #91904, #91478, #91915) Thanks @codysai001, @alexzhu0, @joelnishanth, @snowzlm, @obviyus, and @sallyom. +- iMessage recovery and delivery now cover always-on inbound restart, durable echo markers, block streaming, idle approval discovery, hardened outbound transport, and actionable inbound startup diagnostics. (#91335, #91449, #88969, #88530, #91783, #91785) Thanks @omarshahine, @jmissig, and @colmbrogan. +- Browser and MCP connectivity gained existing-session CDP support, discovered WebSocket validation, default-profile `cdpUrl` handling, safer browser-output boundaries, Streamable HTTP loopback transport, corrected OAuth/SSE authorization handling, and broader schema compatibility. (#91422, #89851, #91736, #91747, #91451, #80143) Thanks @pgondhi987, @anagnorisis2peripeteia, @lifuyue, @eleqtrizit, @LiuwqGit, and @HemantSudarshan. +- Control UI startup and first-reply latency are lower through cached model metadata, removal of the startup catalog wait, lazy slash-command loading, and first-event tracing with slow-reply diagnostics. (#91531, #91538, #91568, #91583, #91598) +- Provider support expands with OpenRouter OAuth onboarding and Claude Fable 5 adaptive thinking, while Codex sessions keep correct compaction ownership, local models skip guardian review, dynamic tool progress normalizes cleanly, and Gemma 4 reasoning replay is preserved. (#91830, #91882, #91590, #88630, #88768, #91696) Thanks @Patrick-Erichsen, @joshavant, @bdjben, and @Coder-Wangyankun. + +### Changes + +- CLI progress: emit Claude CLI commentary progress events and bridge inter-tool commentary into channel progress without exposing internal protocol scaffolding. (#89834, #90883) Thanks @anagnorisis2peripeteia. +- Observability: allow trusted diagnostics channels to capture tool input/output content, add first-assistant-event traces, and warn on slow initial replies. (#91256, #91568, #91583) Thanks @amknight. +- Plugins/ClawHub: dogfood reusable package publishing, let dry runs skip publish approval, allow declared installed trusted hooks, report managed plugin version drift, and warn instead of failing on retired Skill Workshop configuration. (#91574, #91591, #90004, #90927, #90838) Thanks @Patrick-Erichsen, @brokemac79, and @lonexreb. +- Memory/providers: move the local llama.cpp runtime into its provider plugin, batch embeddings across files, persist the agent model catalog cache, and keep QMD JSON search one-shot while filtering stale REM recall previews. (#91324, #89138, #90457, #91837, #91851) Thanks @osolmaz, @mushuiyu886, @ai-hpc, and @TurboTheTurtle. +- Channels/mobile: add the QQBot group mention toggle, improve iPad and iPhone control surfaces, and expose the active connection host in the TUI footer. (#91423, #91557, #89909) Thanks @cxyhhhhh, @Solvely-Colin, and @baskduf. +- Performance: prewarm TUI runtime plugins, deduplicate plugin auto-enable fanout, trim dense text-delta snapshots, and reuse prepared startup model metadata. (#90782, #89978, #91580, #91531) Thanks @RomneyDa and @ai-hpc. + +### Fixes + +- Agent/session recovery: drop stale approval follow-ups after session rebind, remove drained reply-queue items by identity, recover stale main and visible replies, preserve Codex context-engine compaction ownership, lower the default compaction timeout to 180 seconds while respecting explicit configuration, and keep provider-failure terminal lifecycle state correct. (#85679, #91450, #91566, #91840, #91590, #91361, #91895) Thanks @openperf, @yetval, @joshavant, @wangmiao0668000666, and @TurboTheTurtle. +- User-visible content boundaries: suppress Codex/Harmony protocol artifacts, neutralize browser and LanceDB memory media directives, redact transcript images, and preserve native `/compact` replies through source suppression. (#89151, #91422, #91425, #91529, #90212) Thanks @joelnishanth, @pgondhi987, @joshavant, and @snowzlm. +- Channel delivery: keep WhatsApp captured replies attached to the successor controller after restart, retry Feishu rate limits, preserve Mattermost thread replies, canonicalize LINE webhook paths, restore Discord reply hydration and runtime timeout exports, and show OpenAI Realtime WebRTC assistant transcripts. (#85823, #89659, #91684, #91649, #90263, #91686, #90426) Thanks @itsuzef, @ladygege, @jacobtomlinson, @fuller-stack-dev, and @shushushv. +- Cron: cancel active task runs cleanly, preserve terminal timeout/cancel state, and recover no-deliver tool warnings instead of silently losing the outcome. (#90666, #90678) Thanks @ai-hpc. +- Gateway/config/auth: share the approval runtime socket token, replace arrays explicitly in `config.patch`, skip the deleted-agent guard only for valid ACP harness sessions, surface headless LaunchAgent state, verify SQLite auth migration before cleanup, and arm QMD startup maintenance. (#87105, #91551, #91219, #91614, #91740, #91978) Thanks @fuller-stack-dev and @scotthuang. +- Providers/Codex: clarify quota errors, restore the Codex synthetic usage line, canonicalize Codex protocol assets, require API-key auth for realtime voice, normalize ACP model refs, preserve Gemma 4 `reasoning_content`, and avoid guardian review for local models. (#91390, #91709, #91507, #91567, #88630, #91696) Thanks @hxy91819, @brokemac79, @RomneyDa, @joshavant, and @Coder-Wangyankun. +- Updates/builds: recover package Gateway restarts after refresh failure, expose plugin convergence repair, fall back to Corepack in PATH-less pnpm environments, seed the correct Docker store packages, and keep ClawHub dry-run and publish paths reusable. (#91581, #91599, #91547, #91591) Thanks @fuller-stack-dev, @sallyom, and @Patrick-Erichsen. +- UI: require explicit user intent before opening chat sessions and drain restored chat queues after session switches. (#91480) Thanks @TurboTheTurtle. +- Android: avoid the `dataSync` foreground-service type for persistent nodes. (#80082) Thanks @davelutztx. +- Native hooks: bound relay lifetimes so abandoned native hook connections cannot linger indefinitely. (#91550) Thanks @joshavant. + +## 2026.6.5 + +### Highlights + +- QQBot now strips model reasoning/thinking scaffolding before native delivery, preventing raw `` content from leaking into channel replies. (#89913, #90132) Thanks @openperf. +- MCP tool results now coerce `resource_link`, `resource`, `audio`, malformed image, and future non-text/image blocks at the materialize boundary, preventing Anthropic 400s and poisoned session history after a tool returns richer MCP content. (#90710, #90728) Thanks @RanSHammer and @849261680. +- Anthropic extended-thinking sessions recover after prompt-cache expiry or Gateway restart because stream start events wait for `message_start`, letting pre-generation signature errors trigger the existing recovery retry. (#90667, #90697) Thanks @openperf. +- Parallel is now a bundled `web_search` provider with `PARALLEL_API_KEY` discovery, guarded endpoint handling, cache-safe session ids, onboarding picker support, and docs. (#85158) Thanks @NormallyGaussian. +- Google Vertex ADC users get static catalog rows and runtime model resolution again, while single-provider cooldown recovery and memory adapter status checks are more reliable. (#90506, #90609, #90717, #90816) Thanks @849261680. +- Matrix can preflight voice notes before mention gating, preserve thread reads/replies through Matrix relations pagination, and carry QA coverage for voice and thread flows. (#78016, #90415) +- Auth and plugin install state is more durable: auth profiles now live in SQLite, official npm plugin install records keep their trusted pins, and prerelease fallback integrity checks avoid carrying stale integrity forward. (#89102, #88585) +- macOS node mode no longer silently self-reconnects away from a healthy direct Gateway session, reducing unexpected companion app session churn. (#90668, #90815) Thanks @vrurg. +- Upgrade and service paths are safer: cron legacy JSON stores migrate during doctor preflight, service env placeholders no longer mask state-dir secrets, WhatsApp startup waits are bounded, and disabled WhatsApp accounts tear down on config reload. (#90072, #90208, #90277, #90488, #90486, #87951, #87965) Thanks @MonkeyLeeT, @sallyom, @mcaxtr, and @MukundaKatta. + +### Changes + +- Search/providers: add the Parallel bundled web-search plugin, live provider tests, registration contracts, onboarding/docs wiring, and guarded `api.parallel.ai/v1/search` support. (#85158) Thanks @NormallyGaussian. +- Matrix/channels: add voice-message preflight and thread-aware read/reply behavior, including Matrix QA scenario wiring and docs for voice-message behavior. (#78016, #90415) +- Skills/ClawHub: install ClawHub skills backed by GitHub repositories through the resolved install API, download the pinned GitHub commit, keep install-policy checks, and report install telemetry after success. (#90478) Thanks @Patrick-Erichsen. +- Google Chat/channels: add native approval card actions and click handling so Google Chat approvals use platform-native cards instead of generic message flow. +- Mobile: Android provider/model screens now surface expiring, unavailable, unresolved, and attention states more clearly, while iOS settings and Talk tabs keep diagnostics, gateway rows, attachment labels, and unavailable Talk controls reachable. +- Memory: QMD search can use the new rerank toggle, and memory adapter status uses the resolved default model identity when checking plain status. (#61834) +- Docs/tooling: add Parallel search docs, refresh weather-skill guidance toward `web_fetch`, clarify legacy `openai-codex` auth, document release/test helper scripts, and tighten changed-test routing docs for CI/debugging work. (#90028, #90250) Thanks @fuller-stack-dev. +- Release/process: switch release trains to `YYYY.M.PATCH` monthly patch numbering, keep pre-transition tags compatible, and pin the June 2026 floor at `2026.6.5` after the published beta. +- Platform maintenance: refresh Android, Swift/macOS, Docker, CodeQL, Buildx, Docker build/push, and Codex Action dependencies for this release train. (#74980, #81757, #86481, #86483, #90601) +- QQBot: add `/bot-group-allways on|off` slash command (with named-account and default-account support) to toggle whether group messages require an `@mention` before the bot replies, and clear the runtime config snapshot after the write so the new account-level `defaultRequireMention` takes effect immediately without restart. (#91423) Thanks @cxyhhhhh. + +### Fixes + +- Agents: `sessions_send` now honors an explicit `sessionKey` when stale label metadata is also present, and denied session-id sends no longer echo the resolved canonical session key. Fixes #64699; refs #74009 and #41199. Thanks @Mintalix, @RevisitMoon, and @Mocha-s. +- Channel content boundaries: QQBot now strips reasoning/thinking tags before sending, preserving final answers while hiding internal model narration from users. (#89913, #90132) Thanks @openperf. +- Agents/MCP/providers: coerce non-text/image MCP tool-result blocks before they reach provider converters, preserving valid images and turning richer MCP content into text instead of malformed image blocks. (#90710, #90728) Thanks @RanSHammer and @849261680. +- Anthropic/Codex/ACP/agent recovery: defer Anthropic stream start events until `message_start`, strip stale compaction thinking signatures before Anthropic replay, detect unsigned thinking-only stalls, refresh prompt fences after compaction writes, reject empty completion handoffs, preserve parent streaming-off overrides/shared progress commentary, forward heartbeat metadata to context-engine hooks, and cover Codex session/thread migration edge cases. (#90667, #90697, #90163, #90108, #89874, #89505, #90632, #89302, #90729, #90317, #90319) Thanks @openperf, @100yenadmin, and @ooiuuii. +- Provider/model resolution: preserve Google Vertex ADC auth markers in generated catalogs, re-probe a single-provider primary after cooldown, share Codex model visibility, fail closed for unknown model auth, preserve Codex alias availability, keep unresolved profile refs unknown, and avoid resolving auth while listing models. (#90506, #90609, #90717, #90702) Thanks @849261680. +- Gateway/macOS/mobile: avoid duplicate Gateway probe warnings by identity, rate-limit node pairing requests while preserving paired-node reconnects, keep macOS node mode on a healthy direct Gateway session, keep iOS diagnostics and gateway rows reachable, and avoid Linux ARM Gradle resource tasks during Android builds. (#85791, #90147, #90668, #90815) Thanks @giodl73-repo and @vrurg. +- TUI/chat/Workboard/auto-reply: optimistic user messages stay stable across stale history reloads, runId reassignment, and abort windows instead of disappearing, jumping, or lingering as ghost rows; Workboard stale lifecycle bulk updates no longer overwrite newer status/provenance; message-tool sends now count as delivery. (#86205, #89600, #88592, #90123) Thanks @RomneyDa. +- Cron/update/service env: doctor config preflight now migrates legacy cron JSON stores into SQLite before runtime reads, service env planning skips unresolved placeholders that would mask state-dir `.env` values, and session transcript rewrites keep registry markers/discriminants consistent. (#90072, #90208, #90277, #90488) Thanks @MonkeyLeeT and @sallyom. +- Security/config/tooling: guard MCP HTTP redirects, protect global agent config defaults, and keep release/test/tooling proof failures bounded and explicit. (#89732, #90145) +- Channels: WhatsApp restarts when per-account config changes, bounds background startup waits, closes failed sockets, and preserves reconnect behavior; Mattermost slash commands keep their state on `globalThis`; Feishu streaming cards preserve full merged content; voice-call tracks Twilio streams after connect; ClickClack reply tools respect `toolsAllow`. (#87951, #87965, #90486, #68113, #90534, #90181, #90607, #89500) Thanks @MukundaKatta, @mcaxtr, @infoanton, @mushuiyu886, and @sahibzada-allahyar. +- Feishu: retry transient send rate-limit errors (HTTP 429, per-chat code 230020, tenant-level code 11232) with linear backoff, including SDK responses that fulfill with rate-limit bodies instead of throwing, and route streaming-card sends through the retry wrapper. (#89659) Thanks @ladygege. +- Release/CI/E2E: main CI guard drift, PR merge diff scoping, live Docker credential staging, base-image qualification, installer Docker classification, Playwright dependency install recovery, API-key auth for Codex live Docker lanes, Parallels option terminators, and JSON-mode progress handling are tighter so release proof fails cleaner. (#90532, #90287, #90058) Thanks @RomneyDa, @hxy91819, and @mrunalp. +- Release/CI/E2E: Docker E2E and live Docker harness runs now apply default memory, CPU, and process ceilings while preserving explicit per-lane overrides. +- Release/CI/E2E: plugin lifecycle matrix resource sampling now fails phases that exceed RSS, wall-clock, or CPU ceilings instead of only logging the measurements. +- Release/CI/E2E: Codex npm plugin live assertions now cap transcript discovery and diagnostic log reads so failure proof stays bounded. +- Memory: keep doctor REM harness previews aligned with live REM by dropping short-term recall snippets whose source files disappeared before rendering preview output. Thanks @samzong and @frankekn. +- Tests/state isolation: QA Lab valid-tool-call metrics now require runtime tool-call evidence when runtime parity data is available instead of counting tool-backed scenario pass status alone. +- Tests/state isolation: QA Lab runtime parity now fails planned-only tool-call rows without matching tool results instead of treating matching mock plans as real tool evidence. +- Tests/state isolation: provider, media, auth, cron, task, session, sandbox, Gateway, and Codex timeout fixtures now scope more home/state/env data per test, reducing cross-test leakage and making release validation failures less noisy. (#90027, #89974) + ## 2026.6.2 ### Highlights diff --git a/Dockerfile b/Dockerfile index 00f40100671c..288e6ef9e580 100644 --- a/Dockerfile +++ b/Dockerfile @@ -116,11 +116,19 @@ RUN pnpm_config_verify_deps_before_run=false pnpm canvas:a2ui:bundle || \ echo "/* A2UI bundle unavailable in this build */" > extensions/canvas/src/host/a2ui/a2ui.bundle.js && \ echo "stub" > extensions/canvas/src/host/a2ui/.bundle.hash && \ rm -rf vendor/a2ui apps/shared/OpenClawKit/Tools/CanvasA2UI) -RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker +RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \ + export OPENCLAW_BUILD_PRIVATE_QA=1 OPENCLAW_ENABLE_PRIVATE_QA_CLI=1; \ + fi && \ + NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker # Force pnpm for UI build (Bun may fail on ARM/Synology architectures) ENV OPENCLAW_PREFER_PNPM=1 RUN pnpm_config_verify_deps_before_run=false pnpm ui:build -RUN pnpm_config_verify_deps_before_run=false pnpm qa:lab:build +RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \ + pnpm_config_verify_deps_before_run=false pnpm qa:lab:build && \ + mkdir -p dist/extensions/qa-lab/web && \ + rm -rf dist/extensions/qa-lab/web/dist && \ + cp -R extensions/qa-lab/web/dist dist/extensions/qa-lab/web/dist; \ + fi # Prune dev dependencies, omitted plugin runtime packages, and build-only # metadata before copying runtime assets into the final image. @@ -130,7 +138,7 @@ ARG OPENCLAW_BUNDLED_PLUGIN_DIR # BuildKit cache mounts are not part of cached layers; seed tarballs for the # installed prod graph in the same step that runs offline prune. RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \ - pnpm list --prod --depth Infinity --json | node scripts/list-prod-store-packages.mjs | xargs -r pnpm store add && \ + node scripts/list-prod-store-packages.mjs | xargs -r pnpm store add && \ CI=true pnpm prune --prod \ --config.offline=true \ --config.supportedArchitectures.os=linux \ @@ -139,6 +147,10 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/sto OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" OPENCLAW_BUNDLED_PLUGIN_DIR="$OPENCLAW_BUNDLED_PLUGIN_DIR" node scripts/prune-docker-plugin-dist.mjs && \ node scripts/postinstall-bundled-plugins.mjs && \ find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete && \ + rm -rf \ + /app/node_modules/openclaw \ + /app/node_modules/.bin/openclaw \ + /app/node_modules/.pnpm/openclaw@*/node_modules/openclaw && \ node scripts/check-package-dist-imports.mjs /app # ── Runtime base image ────────────────────────────────────────── diff --git a/SECURITY.md b/SECURITY.md index 45af5ea83d8a..09cead9869dd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -48,6 +48,7 @@ These patterns are usually not vulnerabilities by themselves: - Prompt injection without a policy, auth, approval, sandbox, or tool-boundary bypass. - A trusted operator using an intentional local feature, such as local shell access or browser/script execution. +- A report whose only primitive is changing the process or child-process environment before running OpenClaw or an executable OpenClaw invokes. - A malicious plugin after a trusted operator installs or enables it. - Multiple adversarial users sharing one Gateway host/config and expecting per-user isolation. - Scanner-only, dependency-only, or stale-path reports without a working repro and demonstrated OpenClaw impact. @@ -103,6 +104,7 @@ These are frequently reported but are typically closed with no code change: - ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass. - Archive/install extraction claims that require pre-existing local filesystem priming in trusted state (for example planting symlink/hardlink aliases under destination directories such as skills/tools paths) without showing an untrusted path that can create/control that primitive. - Reports that depend on replacing or rewriting an already-approved executable path on a trusted host (same-path inode/content swap) without showing an untrusted path to perform that write. +- Reports that depend on attacker-controlled environment variables changing executable behavior, including variables that redirect lookup paths, preload code, select wrappers/interpreters, alter package-manager or runtime hooks, or make one executable call another executable. Control of the process or child-process environment is trusted host/operator control in OpenClaw's model; these reports need a separate OpenClaw boundary bypass that lets untrusted input set or mutate that environment. - Reports that depend on pre-existing symlinked skill/workspace filesystem state (for example symlink chains involving `skills/*/SKILL.md`) without showing an untrusted path that can create/control that state. - Missing HSTS findings on default local/loopback deployments. - Reports against test-only harnesses, QA Lab, QE Lab, E2E fixtures, benchmark rigs, or maintainer-only debugging tools when the vulnerable code is not shipped as a supported production surface. @@ -161,6 +163,7 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway. - Reports where exploitability depends on attacker-controlled pre-existing symlink/hardlink filesystem state in trusted local paths (for example extraction/install target trees) unless a separate untrusted boundary bypass is shown that creates that state. - Reports whose only claim is sandbox/workspace read expansion through trusted local skill/workspace symlink state (for example `skills/*/SKILL.md` symlink chains) unless a separate untrusted boundary bypass is shown that creates/controls that state. - Reports whose only claim is post-approval executable identity drift on a trusted host via same-path file replacement/rewrite unless a separate untrusted boundary bypass is shown for that host write primitive. +- Reports whose only claim is environment-variable-driven executable behavior change, including path lookup changes, preload hooks, wrapper/interpreter selection, package-manager/runtime hooks, or variables that make an executable invoke another executable, unless a separate OpenClaw boundary bypass lets untrusted input set or mutate that environment. - Reports where the only demonstrated impact is an already-authorized sender intentionally invoking a local-action command (for example `/export-session` writing to an absolute host path) without bypassing auth, sandbox, or another documented boundary - Reports whose only claim is use of an explicit trusted-operator control surface (for example `canvas.eval`, browser evaluate/script execution, or direct `node.invoke` execution) without demonstrating an auth, policy, allowlist, approval, or sandbox bypass. - Reports where the only claim is that a trusted-installed/enabled plugin can execute with gateway/host privileges (documented trust model behavior). @@ -181,6 +184,7 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway. OpenClaw security guidance assumes: - The host where OpenClaw runs is within a trusted OS/admin boundary. +- Anyone who can set or mutate the OpenClaw process environment, launcher environment, or child-process environment is inside that trusted host/operator boundary. - Anyone who can modify `~/.openclaw` state/config (including `openclaw.json`) is effectively a trusted operator. - A single Gateway shared by mutually untrusted people is **not a recommended setup**. Use separate gateways (or at minimum separate OS users/hosts) per trust boundary. - Authenticated Gateway callers are treated as trusted operators. Session identifiers (for example `sessionKey`) are routing controls, not per-user authorization boundaries. diff --git a/appcast.xml b/appcast.xml index f6404391515f..d1d1c9aa78db 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,86 @@ OpenClaw + + 2026.6.5 + Tue, 09 Jun 2026 19:06:49 +0000 + https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml + 2606000590 + 2026.6.5 + 15.0 + OpenClaw 2026.6.5 +

Highlights

+
    +
  • QQBot now strips model reasoning/thinking scaffolding before native delivery, preventing raw content from leaking into channel replies. (#89913, #90132) Thanks @openperf.
  • +
  • MCP tool results now coerce resource_link, resource, audio, malformed image, and future non-text/image blocks at the materialize boundary, preventing Anthropic 400s and poisoned session history after a tool returns richer MCP content. (#90710, #90728) Thanks @RanSHammer and @849261680.
  • +
  • Anthropic extended-thinking sessions recover after prompt-cache expiry or Gateway restart because stream start events wait for message_start, letting pre-generation signature errors trigger the existing recovery retry. (#90667, #90697) Thanks @openperf.
  • +
  • Parallel is now a bundled web_search provider with PARALLEL_API_KEY discovery, guarded endpoint handling, cache-safe session ids, onboarding picker support, and docs. (#85158) Thanks @NormallyGaussian.
  • +
  • Google Vertex ADC users get static catalog rows and runtime model resolution again, while single-provider cooldown recovery and memory adapter status checks are more reliable. (#90506, #90609, #90717, #90816) Thanks @849261680.
  • +
  • Matrix can preflight voice notes before mention gating, preserve thread reads/replies through Matrix relations pagination, and carry QA coverage for voice and thread flows. (#78016, #90415)
  • +
  • Auth and plugin install state is more durable: auth profiles now live in SQLite, official npm plugin install records keep their trusted pins, and prerelease fallback integrity checks avoid carrying stale integrity forward. (#89102, #88585)
  • +
  • Agent, tool, and provider loops are stricter around MCP lease timestamps, prompt-cache tool names, local tool catalogs, unreadable dynamic tools, owner-only HTTP tools, and provider catalog metadata, reducing hidden retries and unsafe exposure. (#91124, #91233, #90022, #90261)
  • +
  • macOS node mode no longer silently self-reconnects away from a healthy direct Gateway session, reducing unexpected companion app session churn. (#90668, #90815) Thanks @vrurg.
  • +
  • Upgrade and service paths are safer: cron legacy JSON stores migrate during doctor preflight, service env placeholders no longer mask state-dir secrets, WhatsApp startup waits are bounded, and disabled WhatsApp accounts tear down on config reload. (#90072, #90208, #90277, #90488, #90486, #87951, #87965) Thanks @MonkeyLeeT, @sallyom, @mcaxtr, and @MukundaKatta.
  • +
+

Changes

+
    +
  • Search/providers: add the Parallel bundled web-search plugin, live provider tests, registration contracts, onboarding/docs wiring, and guarded api.parallel.ai/v1/search support. (#85158) Thanks @NormallyGaussian.
  • +
  • Matrix/channels: add voice-message preflight and thread-aware read/reply behavior, including Matrix QA scenario wiring and docs for voice-message behavior. (#78016, #90415)
  • +
  • Skills/ClawHub: install ClawHub skills backed by GitHub repositories through the resolved install API, download the pinned GitHub commit, keep install-policy checks, and report install telemetry after success. (#90478) Thanks @Patrick-Erichsen.
  • +
  • Skills/ClawHub: avoid one filesystem watcher per skill file during refresh, keeping large skill trees from exhausting watcher limits.
  • +
  • Google Chat/channels: add native approval card actions and click handling so Google Chat approvals use platform-native cards instead of generic message flow.
  • +
  • Mobile: Android provider/model screens now surface expiring, unavailable, unresolved, and attention states more clearly, Android adds theme mode selection, and iOS settings and Talk tabs keep diagnostics, gateway rows, attachment labels, fallback copy, and unavailable Talk controls reachable. (#90752, #91201)
  • +
  • Memory: QMD search can use the new rerank toggle, and memory adapter status uses the resolved default model identity when checking plain status. (#61834)
  • +
  • Docs/tooling: add Parallel search docs, refresh weather-skill guidance toward web_fetch, clarify legacy openai-codex auth, document release/test helper scripts, and tighten changed-test routing docs for CI/debugging work. (#90028, #90250) Thanks @fuller-stack-dev.
  • +
  • Release/process: switch release trains to YYYY.M.PATCH monthly patch numbering, keep pre-transition tags compatible, and pin the June 2026 floor at 2026.6.5.
  • +
  • Release/process: defer the session-metadata SQLite migration from the 2026.6.5 beta train so this release keeps the existing JSON-backed session metadata path while the migration risk is worked on main.
  • +
  • Release metadata: align OpenClaw, publishable plugin manifests, generated shrinkwraps, app version metadata, iOS release notes, Matrix plugin changelog, and generated release baselines with the 2026.6.5 release train.
  • +
  • Platform maintenance: refresh Android, Swift/macOS, Docker, CodeQL, Buildx, Docker build/push, and Codex Action dependencies for this release train. (#74980, #81757, #86481, #86483, #90601)
  • +
+

Fixes

+
    +
  • Channel content boundaries: QQBot now strips reasoning/thinking tags before sending, preserving final answers while hiding internal model narration from users. (#89913, #90132) Thanks @openperf.
  • +
  • Agents/MCP/providers: coerce non-text/image MCP tool-result blocks before they reach provider converters, preserving valid images and turning richer MCP content into text instead of malformed image blocks. (#90710, #90728) Thanks @RanSHammer and @849261680.
  • +
  • Anthropic/Codex/ACP/agent recovery: defer Anthropic stream start events until message_start, strip stale compaction thinking signatures before Anthropic replay, detect unsigned thinking-only stalls, refresh prompt fences after compaction writes, reject empty completion handoffs, preserve parent streaming-off overrides/shared progress commentary, forward heartbeat metadata to context-engine hooks, and cover Codex session/thread migration edge cases. (#90667, #90697, #90163, #90108, #89874, #89505, #90632, #89302, #90729, #90317, #90319) Thanks @openperf, @100yenadmin, and @ooiuuii.
  • +
  • Agents/Codex/tools: MCP lease release no longer refreshes lastUsedAt, prompt-cache tool names are guarded, lean local tool catalogs stay compact, unreadable dynamic tools are quarantined, orphan tool errors still surface, native subagent completion results survive app-server monitoring, and background-session name derivation avoids regex backtracking risk. (#91124, #90612, #90022, #91235, #91233)
  • +
  • Provider/model resolution: preserve Google Vertex ADC auth markers in generated catalogs, re-probe a single-provider primary after cooldown, share Codex model visibility, fail closed for unknown model auth, preserve Codex alias availability, keep unresolved profile refs unknown, and avoid resolving auth while listing models. (#90506, #90609, #90717, #90702) Thanks @849261680.
  • +
  • Provider/model resolution: live provider model catalogs keep helper coverage, Ollama catalog metadata is preserved, Google provider prefixes are stripped from Gemini paths, Foundry Responses reasoning replay ids survive, MiniMax M3 thinking stays enabled, Vertex multi-region calls use the right regional host, and OpenRouter streamed generation cost is reconciled. (#91125)
  • +
  • Gateway/macOS/mobile: avoid duplicate Gateway probe warnings by identity, rate-limit node pairing requests while preserving paired-node reconnects, keep macOS node mode on a healthy direct Gateway session, keep iOS diagnostics and gateway rows reachable, and avoid Linux ARM Gradle resource tasks during Android builds. (#85791, #90147, #90668, #90815) Thanks @giodl73-repo and @vrurg.
  • +
  • Gateway/security/config: owner-only HTTP tools are gated, sandbox skills remain readable in writable sandboxes, legacy agent registry and Codex model metadata migrate safely, and stalled MCP response bodies time out instead of tying up Gateway workers. (#90261)
  • +
  • Gateway/config: config.patch now preserves explicit array replacement semantics for arrays without merge keys, so replacement patches do not accidentally merge stale entries. (#91551)
  • +
  • SDK: event pump failures now surface to clients instead of being swallowed behind a quiet iterator shutdown.
  • +
  • Agents/transcripts: inline image payload redaction now catches data URLs and repaired transcript images before they can leak raw image bytes into stored or exported transcripts. (#91529)
  • +
  • Plugins/Gateway: legacy flat Control UI descriptors from shipped JavaScript plugins now normalize name and missing surface fields into session descriptors, restoring Kitchen Sink RPC descriptor proof for package-backed plugin validation.
  • +
  • TUI/chat/Workboard/auto-reply: optimistic user messages stay stable across stale history reloads, runId reassignment, and abort windows instead of disappearing, jumping, or lingering as ghost rows; Workboard stale lifecycle bulk updates no longer overwrite newer status/provenance; message-tool sends now count as delivery. (#86205, #89600, #88592, #90123) Thanks @RomneyDa.
  • +
  • Cron/update/service env: doctor config preflight now migrates legacy cron JSON stores into SQLite before runtime reads, isolated agent turn payload messages preserve timeout context, service env planning skips unresolved placeholders that would mask state-dir .env values, and session transcript rewrites keep registry markers/discriminants consistent. (#90072, #90208, #91230, #90277, #90488) Thanks @MonkeyLeeT and @sallyom.
  • +
  • State/storage: Matrix sync and crypto sidecars, memory-wiki import/source-sync state, sandbox registry state, ACPX process state, device-pair notify state, Zalo hosted media, and plugin SDK dedupe state now use SQLite-owned storage instead of ad hoc runtime files. (#91100, #91108, #91056)
  • +
  • Security/config/tooling: guard MCP HTTP redirects, protect global agent config defaults, and keep release/test/tooling proof failures bounded and explicit. (#89732, #90145)
  • +
  • Channels: WhatsApp restarts when per-account config changes, bounds background startup waits, closes failed sockets, and preserves reconnect behavior; Mattermost slash commands keep their state on globalThis and default replies stay inside existing Mattermost threads instead of starting new ones; Feishu streaming cards preserve full merged content; iMessage private-API failures and send timeouts explain themselves while split-send coalescing honors balloon metadata; voice-call tracks Twilio streams after connect; ClickClack reply tools respect toolsAllow; Discord runtime adapters stay resolvable; and outbound delivery retries survive budget deferrals. (#87951, #87965, #90486, #68113, #90534, #90181, #90607, #89500, #91041, #90858, #91119, #91241) Thanks @MukundaKatta, @mcaxtr, @infoanton, @mushuiyu886, @sahibzada-allahyar, and @jacobtomlinson.
  • +
  • Feishu: retry transient send rate-limit errors (HTTP 429, per-chat code 230020, tenant-level code 11232) with linear backoff, including SDK responses that fulfill with rate-limit bodies instead of throwing, and route streaming-card sends through the retry wrapper. (#89659) Thanks @ladygege.
  • +
  • WhatsApp: captured replies after restart now route through the successor controller instead of the stale pre-restart controller. (#85823)
  • +
  • Release/CI/E2E: main CI guard drift, PR merge diff scoping, live Docker credential staging, base-image qualification, installer Docker classification, Playwright dependency install recovery, API-key auth for Codex live Docker lanes, Parallels option terminators, and JSON-mode progress handling are tighter so release proof fails cleaner. (#90532, #90287, #90058) Thanks @RomneyDa, @hxy91819, and @mrunalp.
  • +
  • Release/CI/E2E: installed-package root dist verification now allows the current package's JavaScript file count while keeping dependency, per-file-size, and scan-bound checks active.
  • +
  • Release/CI/E2E: Chutes OAuth model-discovery proof now accepts standard Headers requests, and QR package install smoke caps Docker CPU requests to the hosted runner capacity so beta validation fails on real package regressions.
  • +
  • Release/CI/E2E: Docker E2E and live Docker harness runs now apply default memory, CPU, and process ceilings while preserving explicit per-lane overrides.
  • +
  • Release/CI/E2E: Docker E2E CPU limits now cap to the runner capacity, keeping package Telegram acceptance on hosted 8-vCPU runners focused on package regressions instead of impossible Docker resource requests.
  • +
  • Release/CI/E2E: task maintenance release checks now reset pinned config around isolated temp state dirs, keeping normal CI focused on the active session-store fixture instead of stale process snapshots.
  • +
  • Release/CI/E2E: plugin lifecycle matrix resource sampling now fails phases that exceed RSS, wall-clock, or CPU ceilings instead of only logging the measurements.
  • +
  • Release/CI/E2E: Codex npm plugin live assertions now cap transcript discovery and diagnostic log reads so failure proof stays bounded.
  • +
  • Release/CI/E2E: browser snapshot, release-scenario, release-user-journey, Telegram desktop/RTT/package, web-search, Parallels update, plugin update, doctor switch, and upgrade-survivor diagnostics now stream or bound log/artifact reads so failed proof stays inspectable without unbounded output.
  • +
  • Release/CI/E2E: Parallels smoke validation now runs without requiring pnpm on the host, supports already-started Windows/Linux guests without snapshots, reports empty snapshot metadata clearly, and finds portable user-local Node on Windows.
  • +
  • Release/CI/E2E: ClawHub publish jobs prepare dependencies after checking out the target ref, and Docker store seed package discovery now targets the intended production packages. (#91547)
  • +
  • Release/CI/E2E: QA Lab capability-flip release validation now marks intentional tools.deny restores as array replacements, so beta validation fails only on real capability regressions.
  • +
  • Tests/state isolation: QA Lab valid-tool-call metrics now require runtime tool-call evidence when runtime parity data is available instead of counting tool-backed scenario pass status alone.
  • +
  • Tests/state isolation: QA Lab runtime parity now fails planned-only tool-call rows without matching tool results instead of treating matching mock plans as real tool evidence.
  • +
  • Tests/state isolation: QA Lab runtime parity now treats matching controlled tool errors as equivalent and falls back to transcript tool results when mock debug rows miss async image-generation starts.
  • +
  • Tests/state isolation: QA suites now fail closed on skipped summaries, missing runtime tool proof, planned-only rows, loose release limits, missing live/provider artifacts, failed agent reply markers, and package Telegram summary failures.
  • +
  • Tests/state isolation: provider, media, auth, cron, task, session, sandbox, Gateway, and Codex timeout fixtures now scope more home/state/env data per test, reducing cross-test leakage and making release validation failures less noisy. (#90027, #89974)
  • +
  • Sessions: the beta SQLite downgrade rescue now skips extra pre-reads for active non-empty JSON session stores, preserving cache race detection while still restoring missing or empty beta session files.
  • +
+

View full changelog

+]]>
+ +
2026.6.1 Wed, 03 Jun 2026 21:26:22 +0000 @@ -193,52 +273,5 @@ ]]> - - 2026.5.27 - Thu, 28 May 2026 12:12:19 +0000 - https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml - 2026052790 - 2026.5.27 - 15.0 - OpenClaw 2026.5.27 -

Highlights

-
    -
  • Stronger security and content boundaries: group prompt text is kept out of the system prompt, repeated-dot hostnames are normalized, side-effecting command wrappers and unsafe Node runtime env overrides are blocked, no-auth Tailscale exposure is rejected, and node/device-role approvals now require admin authority. (#87144, #87305, #87292, #87308, #87146) Thanks @eleqtrizit and @pgondhi987.
  • -
  • More reliable Codex app-server runs: Codex runtime models resolve first, workspace memory is routed through tools, shared app-server clients survive startup and spawned-helper failures, native hook relay generations survive restarts and rotate on fresh fallbacks, and false runtime live switches are avoided. (#87383, #87403, #87375, #72574, #87428) Thanks @yetval.
  • -
  • Faster Gateway and reply paths: session reads, plugin metadata fingerprints, auth env snapshots, auto-enabled plugin config, tool-search catalogs, and stable metadata caches do less hot-path rediscovery while visible replies no longer inherit hidden cleanup timeouts. (#86439, #87044) Thanks @keshavbotagent.
  • -
  • Better provider and model coverage: OpenAI-compatible embedding providers are core, DeepInfra catalog browsing loads the full credential-aware model set, Pixverse adds video generation and API region selection, VLLM thinking params are wired, Claude CLI OAuth overlays load for PI auth profiles, and bare direct Anthropic model ids work. (#85269, #84549, #87167) Thanks @dutifulbob, @ats3v, and @joshavant.
  • -
  • Channel delivery is steadier: Telegram sendMessage actions use durable outbound delivery, iMessage suppresses duplicate native exec approval prompts and sends, Slack keeps delivered final replies during late cleanup, Matrix mention previews/finals are stricter, QQBot fallback approval buttons honor slash-command auth, Discord guild requester checks are tighter, recovered Discord tool-warning artifacts stay out of successful replies, and Google Chat stops thread sends in DMs. (#87261, #87154) Thanks @mbelinky and @eleqtrizit.
  • -
  • Release, package, and CI proof paths are harder to wedge: npm/package inventory honors dist exclusions, shrinkwrap override pins merge correctly, Docker runtime workspace templates are packaged and smoked, release postpublish checks are stricter, beta smoke rejects empty runs, and E2E log/probe waits are bounded.
  • -
-

Changes

-
    -
  • Memory: add a core OpenAI-compatible embedding provider for local and hosted OpenAI-style endpoints, with config, doctor, and docs support. (#85269) Thanks @dutifulbob.
  • -
  • Plugin SDK: mark memory-specific embedding provider registration as deprecated compatibility and surface non-bundled usage in plugin compatibility diagnostics. (#85072) Thanks @mbelinky.
  • -
  • Providers: add the Pixverse video generation provider, API region selection, docs, and external plugin packaging support.
  • -
  • DeepInfra: load the full model catalog when users browse models during onboarding, preserve configured API-key catalogs, refresh media/video defaults, and keep pricing/default model metadata aligned. (#84549) Thanks @ats3v.
  • -
  • Plugin SDK: expose plugin approval action metadata and stop exporting Vitest test helpers from the public SDK surface. (#87120) Thanks @RomneyDa.
  • -
  • Channel SDK: move channel message compatibility into core, remove old channel turn runtime aliases, and preserve runtime catalog markdown metadata for plugins.
  • -
  • ClawHub: add plugin display metadata so catalog/package listings use cleaner names. (#87354) Thanks @thewilloftheshadow.
  • -
  • Agents: split the heartbeat runtime template out of docs assets and add compatibility repair for legacy heartbeat template content. (#85416) Thanks @hxy91819.
  • -
-

Fixes

-
    -
  • Security/content boundaries: route untrusted group prompt metadata outside system prompts, normalize repeated trailing hostname dots, block side-effecting command wrappers, reject unsafe Node runtime env overrides, reject no-auth Tailscale exposure, block untrusted Microsoft Teams service URLs, enforce /allowlist configWrites origin policy, gate QQBot fallback approval buttons, and require admin for node/device-role approvals. (#87144, #87305, #87292, #87308, #87146, #87154, #87334) Thanks @eleqtrizit and @pgondhi987.
  • -
  • Codex: resolve Codex runtime models before generic routing, route workspace memory through tools, preserve shared app-server clients after startup and spawned-helper failures, preserve native hook relay generations across restarts and fresh fallbacks, keep raw reasoning/source-reply guards intact, report quarantined dynamic tools, keep the attempt watchdog armed for queued terminal turns, and route Codex OAuth compaction through OpenAI-Codex. (#87383, #87403, #87375, #72574, #87428) Thanks @yetval.
  • -
  • Agents/runtime: avoid session event queue self-waits, bound compaction wake and steering retries, preserve grace for pending error diagnostics, avoid false Codex runtime live switches, avoid stale restart continuation reuse, preserve session fallback errors, suppress duplicate Claude CLI skill prompts, keep runtime context before active user turns, strip stale Anthropic thinking, quarantine unsupported tool schemas, recover completed write timeouts safely, release retained session write locks on timeout abort, and validate forced plugin harness support before pinning. (#86123, #55424, #86855, #74341, #87278) Thanks @luoyanglang, @cathrynlavery, and @openperf.
  • -
  • Reply/session delivery: keep visible turn admission unbounded, keep visible fallback delivery on latest targets, preserve bridge hook context, classify direct fallback targets by channel grammar, report approval resolutions in bridge mode, and avoid stale source-reply artifacts. (#87044) Thanks @keshavbotagent.
  • -
  • Channels: make Telegram sendMessage action replies durable and preserve SecretRef prompt config, suppress duplicate iMessage native exec approval prompts and sends, keep iMessage approval polling alive after denied reactions, keep Slack delivered final replies during late cleanup, keep Matrix mention previews/finals mention-inert and normally delivered, ignore filename-embedded Matrix IDs, suppress recovered Discord tool-warning artifacts from successful replies, suppress Google Chat thread sends in DMs, and harden Discord guild requester checks. (#87261, #87452) Thanks @mbelinky.
  • -
  • Memory: salvage QMD search JSON after nonzero exits and keep workspace memory routing through the Codex tool path where possible. (#87225, #87383, #87403) Thanks @osolmaz.
  • -
  • Providers/models: forward cached token usage in OpenAI-compatible chat completions, load Claude CLI OAuth overlays for PI auth profiles, send bare direct Anthropic model ids, wire configured VLLM thinking params, honor OpenAI-compatible cache retention, normalize OpenAI Responses replay tool ids, resolve OpenAI gpt-5.5 without a cached catalog, preserve retry-after fallback handling, bound GitHub Copilot auth requests, and load DeepInfra custom/live catalogs consistently. (#82062, #87167, #84549) Thanks @caz0075, @joshavant, and @ats3v.
  • -
  • Gateway/performance: borrow read-only session metadata and active session working stores, cache current/stable plugin metadata fingerprints, cache auto-enabled plugin config, slim metadata identity caches, trust current metadata lifecycle caches, stabilize isolated cron prompt-cache affinity, persist model auth profile suffixes, drain probe client closes, expire browser tokens after auth rotation, and keep default status fast paths bounded. Thanks @ferminquant.
  • -
  • CLI/help/config: reject loose or malformed numeric options for gateway timeouts, model limits, directory limits, message options, webhooks, and partial values; respect subcommand version options; route generated/root/plugin help targets correctly; keep skills JSON output flushing naturally; and keep plugin descriptor loading quiet in root help. (#87398) Thanks @Patrick-Erichsen.
  • -
  • Plugin state/tool search: evict the current namespace when plugin rows hit caps, reuse unchanged tool-search catalogs, align the release catalog reuse wrapper, and keep fallback tool warnings mention-inert.
  • -
  • Install/package/release: match npm globstar exclusions, honor dist package exclusions in inventory, omit unpacked test helpers, skip Homebrew until macOS packages need it, package Docker runtime workspace templates, smoke Docker runtime templates during full validation, merge nested shrinkwrap override pins, preserve forked shrinkwrap pins, pin aged lru-cache, harden postpublish verification, accept main full-validation proof, and reject empty beta smoke runs.
  • -
  • E2E/QA/Crabbox: bound Telegram, Open WebUI, ClawHub, Matrix, Tool Search, MCP, gateway network, bundled runtime, kitchen-sink, codex media, config reload, and agent-turn assertion waits; prefer Azure for Windows targets; reinitialize invalid changed-gate git dirs; full-sync sparse container runs; and fail empty explicit test requests. (#87186)
  • -
-

View full changelog

-]]>
- -
\ No newline at end of file diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 5b916458b776..62d01d91b3f9 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -41,7 +41,7 @@ plugins { android { namespace = "ai.openclaw.app" - compileSdk = 36 + compileSdk = 37 // Release signing is local-only; keep the keystore path and passwords out of the repo. signingConfigs { diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 0796788492d2..2aa7eeb0810c 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,9 @@ + - + + android:foregroundServiceType="connectedDevice|microphone" /> systemDark + Dark -> true + Light -> false + } + + companion object { + fun fromRawValue(value: String?): AppearanceThemeMode = entries.firstOrNull { it.rawValue == value?.trim()?.lowercase() } ?: Dark + + fun fromDisplayLabel(label: String): AppearanceThemeMode = entries.firstOrNull { it.displayLabel.equals(label.trim(), ignoreCase = true) } ?: Dark + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt index ef54fb4254de..89d1b2270779 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt @@ -2,18 +2,36 @@ package ai.openclaw.app import ai.openclaw.app.ui.OpenClawTheme import ai.openclaw.app.ui.RootScreen +import android.content.Intent import android.os.Bundle import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp import androidx.core.view.WindowCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * Main Android activity that owns Compose UI attachment and runtime UI wiring. @@ -21,18 +39,89 @@ import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { private val viewModel: MainViewModel by viewModels() private lateinit var permissionRequester: PermissionRequester + private var initializedViewModel: MainViewModel? = null private var didAttachRuntimeUi = false private var didStartNodeService = false + private var didStartViewModelCollectors = false + private var foreground = false + private var pendingIntent: Intent? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - handleAssistantIntent(intent) + pendingIntent = intent WindowCompat.setDecorFitsSystemWindows(window, false) permissionRequester = PermissionRequester(this) + setContent { + var activeViewModel by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + withFrameNanos { } + withContext(Dispatchers.Default) { + (application as NodeApp).prefs + } + val readyViewModel = viewModel + activateViewModel(readyViewModel) + activeViewModel = readyViewModel + } + + val currentViewModel = activeViewModel + if (currentViewModel == null) { + OpenClawTheme { + StartupSurface() + } + } else { + val appearanceThemeMode by currentViewModel.appearanceThemeMode.collectAsState() + OpenClawTheme(themeMode = appearanceThemeMode) { + RootScreen(viewModel = currentViewModel) + } + } + } + } + + override fun onStart() { + super.onStart() + foreground = true + initializedViewModel?.setForeground(true) + } + + override fun onStop() { + foreground = false + initializedViewModel?.setForeground(false) + super.onStop() + } + + override fun onNewIntent(intent: android.content.Intent) { + super.onNewIntent(intent) + setIntent(intent) + pendingIntent = intent + initializedViewModel?.let { handleAssistantIntent(viewModel = it, intent = intent) } + } + + /** + * Wires MainViewModel only after Activity first draw and background prefs warm-up. + */ + private fun activateViewModel(readyViewModel: MainViewModel) { + if (initializedViewModel != null) return + initializedViewModel = readyViewModel + readyViewModel.setForeground(foreground) + startViewModelCollectors(readyViewModel) + pendingIntent?.let { initialIntent -> + handleAssistantIntent(viewModel = readyViewModel, intent = initialIntent) + pendingIntent = null + } + } + + /** + * Starts lifecycle collectors after ViewModel construction so they cannot force early startup. + */ + private fun startViewModelCollectors(readyViewModel: MainViewModel) { + if (didStartViewModelCollectors) return + didStartViewModelCollectors = true + lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.preventSleep.collect { enabled -> + readyViewModel.preventSleep.collect { enabled -> if (enabled) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { @@ -44,10 +133,10 @@ class MainActivity : ComponentActivity() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.runtimeInitialized.collect { ready -> + readyViewModel.runtimeInitialized.collect { ready -> if (!ready || didAttachRuntimeUi) return@collect // Runtime UI helpers need an Activity owner, so attach once after NodeRuntime is ready. - viewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester) + readyViewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester) didAttachRuntimeUi = true if (!didStartNodeService) { NodeForegroundService.start(this@MainActivity) @@ -56,36 +145,15 @@ class MainActivity : ComponentActivity() { } } } - - setContent { - OpenClawTheme { - Surface(modifier = Modifier) { - RootScreen(viewModel = viewModel) - } - } - } - } - - override fun onStart() { - super.onStart() - viewModel.setForeground(true) - } - - override fun onStop() { - viewModel.setForeground(false) - super.onStop() - } - - override fun onNewIntent(intent: android.content.Intent) { - super.onNewIntent(intent) - setIntent(intent) - handleAssistantIntent(intent) } /** * Routes assistant/app-action intents into ViewModel state without recreating the activity. */ - private fun handleAssistantIntent(intent: android.content.Intent?) { + private fun handleAssistantIntent( + viewModel: MainViewModel, + intent: Intent?, + ) { parseHomeDestinationIntent(intent)?.let { destination -> viewModel.requestHomeDestination(destination) return @@ -94,3 +162,23 @@ class MainActivity : ComponentActivity() { viewModel.handleAssistantLaunch(request) } } + +@Composable +private fun StartupSurface() { + Surface( + modifier = Modifier.fillMaxSize(), + color = Color.Black, + contentColor = Color.White, + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = "OPENCLAW", + fontSize = 22.sp, + fontWeight = FontWeight.Medium, + ) + } + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index 26fae6a23b54..8680e960df2d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -4,6 +4,8 @@ import ai.openclaw.app.chat.ChatMessage import ai.openclaw.app.chat.ChatPendingToolCall import ai.openclaw.app.chat.ChatSessionEntry import ai.openclaw.app.chat.OutgoingAttachment +import ai.openclaw.app.gateway.DeviceAuthStore +import ai.openclaw.app.gateway.DeviceIdentityStore import ai.openclaw.app.gateway.GatewayEndpoint import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary import ai.openclaw.app.node.CameraCaptureManager @@ -14,6 +16,7 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -21,6 +24,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch /** * UI-facing bridge that exposes NodeRuntime and preference state as Compose-friendly StateFlows. @@ -32,7 +36,11 @@ class MainViewModel( private val nodeApp = app as NodeApp private val prefs = nodeApp.prefs private val runtimeRef = MutableStateFlow(null) - private var foreground = true + + @Volatile private var foreground = false + + @Volatile private var runtimeStartupQueued = false + private val _requestedHomeDestination = MutableStateFlow(null) val requestedHomeDestination: StateFlow = _requestedHomeDestination private val _startOnboardingAtGatewaySetup = MutableStateFlow(false) @@ -53,6 +61,19 @@ class MainViewModel( return runtime } + /** + * Starts the node runtime off the main thread so fresh installs can render + * the shell before encrypted prefs, device identity, and gateway setup warm up. + */ + private fun queueRuntimeStartup() { + if (runtimeRef.value != null || runtimeStartupQueued) return + runtimeStartupQueued = true + viewModelScope.launch(Dispatchers.Default) { + runCatching { ensureRuntime() } + runtimeStartupQueued = false + } + } + /** * Adapts a runtime StateFlow to a stable ViewModel StateFlow before runtime startup. */ @@ -91,6 +112,7 @@ class MainViewModel( val isConnected: StateFlow = runtimeState(initial = false) { it.isConnected } val isNodeConnected: StateFlow = runtimeState(initial = false) { it.nodeConnected } val statusText: StateFlow = runtimeState(initial = "Offline") { it.statusText } + val gatewayConnectionProblem: StateFlow = runtimeState(initial = null) { it.gatewayConnectionProblem } val serverName: StateFlow = runtimeState(initial = null) { it.serverName } val remoteAddress: StateFlow = runtimeState(initial = null) { it.remoteAddress } val gatewayVersion: StateFlow = runtimeState(initial = null) { it.gatewayVersion } @@ -150,6 +172,7 @@ class MainViewModel( val canvasDebugStatusEnabled: StateFlow = prefs.canvasDebugStatusEnabled val installedAppsSharingEnabled: StateFlow = prefs.installedAppsSharingEnabled val speakerEnabled: StateFlow = prefs.speakerEnabled + val appearanceThemeMode: StateFlow = prefs.appearanceThemeMode val voiceCaptureMode: StateFlow = runtimeState(initial = VoiceCaptureMode.Off) { it.voiceCaptureMode } val micEnabled: StateFlow = runtimeState(initial = false) { it.micEnabled } @@ -180,12 +203,6 @@ class MainViewModel( val chatSessions: StateFlow> = runtimeState(initial = emptyList()) { it.chatSessions } val pendingRunCount: StateFlow = runtimeState(initial = 0) { it.pendingRunCount } - init { - if (prefs.onboardingCompleted.value) { - ensureRuntime() - } - } - val canvas: CanvasController get() = ensureRuntime().canvas @@ -213,13 +230,10 @@ class MainViewModel( */ fun setForeground(value: Boolean) { foreground = value - val runtime = - if (value && prefs.onboardingCompleted.value) { - ensureRuntime() - } else { - runtimeRef.value - } - runtime?.setForeground(value) + if (value && prefs.onboardingCompleted.value) { + queueRuntimeStartup() + } + runtimeRef.value?.setForeground(value) } fun setDisplayName(value: String) { @@ -270,9 +284,51 @@ class MainViewModel( prefs.setGatewayPassword(value) } - /** Clears setup credentials through the runtime so active gateway sessions drop stale auth state. */ - fun resetGatewaySetupAuth() { - ensureRuntime().resetGatewaySetupAuth() + /** Clears setup credentials without starting the runtime just to discard first-run pairing auth. */ + private fun resetGatewaySetupAuth() { + runtimeRef.value?.resetGatewaySetupAuth() ?: resetGatewaySetupAuthWithoutRuntime() + } + + private fun resetGatewaySetupAuthWithoutRuntime() { + prefs.clearGatewaySetupAuth() + val deviceId = DeviceIdentityStore(nodeApp).loadOrCreate().deviceId + val deviceAuthStore = DeviceAuthStore(prefs) + deviceAuthStore.clearToken(deviceId, "node") + deviceAuthStore.clearToken(deviceId, "operator") + } + + fun saveGatewayConfigAndConnect( + host: String, + port: Int, + tls: Boolean, + token: String, + bootstrapToken: String, + password: String, + resetSetupAuth: Boolean, + ) { + // Gateway pairing touches encrypted prefs, identity files, and sockets; keep + // the whole sequence off the Compose thread so retries cannot trigger ANRs. + viewModelScope.launch(Dispatchers.Default) { + if (resetSetupAuth) { + resetGatewaySetupAuth() + } + prefs.setManualEnabled(true) + prefs.setManualHost(host) + prefs.setManualPort(port) + prefs.setManualTls(tls) + prefs.setGatewayBootstrapToken(bootstrapToken) + prefs.setGatewayToken(token) + prefs.setGatewayPassword(password) + ensureRuntime() + .connect( + GatewayEndpoint.manual(host = host, port = port), + NodeRuntime.GatewayConnectAuth( + token = token.ifEmpty { null }, + bootstrapToken = bootstrapToken.ifEmpty { null }, + password = password.ifEmpty { null }, + ), + ) + } } /** Marks onboarding complete and starts the runtime before UI observes connected-state flows. */ @@ -285,10 +341,12 @@ class MainViewModel( /** Re-enters gateway setup after disconnecting and clearing one-time setup credentials. */ fun pairNewGateway() { - runtimeRef.value?.disconnect() - resetGatewaySetupAuth() - _startOnboardingAtGatewaySetup.value = true - prefs.setOnboardingCompleted(false) + viewModelScope.launch(Dispatchers.Default) { + runtimeRef.value?.disconnect() + resetGatewaySetupAuth() + prefs.setOnboardingCompleted(false) + _startOnboardingAtGatewaySetup.value = true + } } /** Acknowledges the one-shot request that opens onboarding at the gateway setup step. */ @@ -383,14 +441,30 @@ class MainViewModel( ensureRuntime().setSpeakerEnabled(enabled) } + fun setAppearanceThemeMode(mode: AppearanceThemeMode) { + prefs.setAppearanceThemeMode(mode) + } + fun refreshGatewayConnection() { - ensureRuntime().refreshGatewayConnection() + viewModelScope.launch(Dispatchers.Default) { + ensureRuntime().refreshGatewayConnection() + } + } + + fun startGatewayDiscovery() { + queueRuntimeStartup() } fun connect(endpoint: GatewayEndpoint) { ensureRuntime().connect(endpoint) } + fun connectInBackground(endpoint: GatewayEndpoint) { + viewModelScope.launch(Dispatchers.Default) { + ensureRuntime().connect(endpoint) + } + } + fun connect( endpoint: GatewayEndpoint, token: String?, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt index e91b3e70a45b..66b95023a581 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt @@ -23,7 +23,6 @@ import kotlinx.coroutines.launch class NodeForegroundService : Service() { private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private var notificationJob: Job? = null - private var didStartForeground = false private var voiceCaptureMode = VoiceCaptureMode.Off override fun onCreate() { @@ -183,13 +182,7 @@ class NodeForegroundService : Service() { private fun startForegroundWithTypes(notification: Notification) { val serviceTypes = foregroundServiceTypesForVoiceMode(voiceCaptureMode) - if (didStartForeground) { - // Re-issue startForeground when Talk mode toggles so Android sees the microphone service type. - ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, serviceTypes) - return - } ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, serviceTypes) - didStartForeground = true } companion object { @@ -200,19 +193,16 @@ class NodeForegroundService : Service() { private const val ACTION_SET_VOICE_CAPTURE_MODE = "ai.openclaw.app.action.SET_VOICE_CAPTURE_MODE" private const val EXTRA_VOICE_CAPTURE_MODE = "ai.openclaw.app.extra.VOICE_CAPTURE_MODE" - /** Starts the persistent node foreground service from UI lifecycle code. */ fun start(context: Context) { val intent = Intent(context, NodeForegroundService::class.java) context.startForegroundService(intent) } - /** Requests disconnect through the service action path so notification actions and UI share behavior. */ fun stop(context: Context) { val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_STOP) context.startService(intent) } - /** Updates Android's foreground-service type before voice capture mode changes require microphone access. */ fun setVoiceCaptureMode( context: Context, mode: VoiceCaptureMode, @@ -231,11 +221,8 @@ class NodeForegroundService : Service() { } } -/** - * Foreground-service type mask required by Android for the current voice capture mode. - */ internal fun foregroundServiceTypesForVoiceMode(mode: VoiceCaptureMode): Int { - val base = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + val base = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE return if (mode == VoiceCaptureMode.TalkMode) { base or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE } else { @@ -243,9 +230,6 @@ internal fun foregroundServiceTypesForVoiceMode(mode: VoiceCaptureMode): Int { } } -/** - * Compact notification suffix for voice state; kept pure for service-notification tests. - */ internal fun voiceNotificationSuffix( mode: VoiceCaptureMode, manualMicEnabled: Boolean, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 0ed9e98a4dba..5903ce181d04 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -78,6 +78,25 @@ import java.util.concurrent.atomic.AtomicLong /** * Process runtime that owns gateway sessions, node command handlers, capture managers, and UI-facing state. */ +data class GatewayConnectionProblem( + val code: String?, + val message: String, + val reason: String?, + val requestId: String?, + val recommendedNextStep: String?, + val pauseReconnect: Boolean, + val retryable: Boolean, +) { + val isPairingRequired: Boolean = code == "PAIRING_REQUIRED" + val canAutoRetry: Boolean = + isPairingRequired && + ( + retryable || + !pauseReconnect || + recommendedNextStep == "wait_then_retry" + ) +} + class NodeRuntime( context: Context, val prefs: SecurePrefs = SecurePrefs(context.applicationContext), @@ -285,6 +304,8 @@ class NodeRuntime( private val _statusText = MutableStateFlow("Offline") val statusText: StateFlow = _statusText.asStateFlow() + private val _gatewayConnectionProblem = MutableStateFlow(null) + val gatewayConnectionProblem: StateFlow = _gatewayConnectionProblem.asStateFlow() private val _pendingGatewayTrust = MutableStateFlow(null) val pendingGatewayTrust: StateFlow = _pendingGatewayTrust.asStateFlow() @@ -410,6 +431,7 @@ class NodeRuntime( identityStore = identityStore, deviceAuthStore = deviceAuthStore, onConnected = { hello -> + _gatewayConnectionProblem.value = null operatorConnected = true operatorStatusText = "Connected" _serverName.value = hello.serverName @@ -457,6 +479,7 @@ class NodeRuntime( updateStatus() micCapture.onGatewayConnectionChanged(false) }, + onConnectFailure = ::handleGatewayConnectFailure, onEvent = { event, payloadJson -> handleGatewayEvent(event, payloadJson) }, @@ -468,6 +491,7 @@ class NodeRuntime( identityStore = identityStore, deviceAuthStore = deviceAuthStore, onConnected = { + _gatewayConnectionProblem.value = null _nodeConnected.value = true nodeStatusText = "Connected" didAutoRequestCanvasRehydrate = false @@ -493,6 +517,7 @@ class NodeRuntime( updateStatus() showLocalCanvasOnDisconnect() }, + onConnectFailure = ::handleGatewayConnectFailure, onEvent = { _, _ -> }, onInvoke = { req -> invokeDispatcher.handleInvoke(req.command, req.paramsJson) @@ -687,6 +712,23 @@ class NodeRuntime( updateHomeCanvasState() } + private fun handleGatewayConnectFailure( + error: GatewaySession.ErrorShape, + pauseReconnect: Boolean, + ) { + val details = error.details + _gatewayConnectionProblem.value = + GatewayConnectionProblem( + code = details?.code ?: error.code, + message = error.message, + reason = details?.reason, + requestId = details?.requestId, + recommendedNextStep = details?.recommendedNextStep, + pauseReconnect = pauseReconnect || details?.pauseReconnect == true, + retryable = details?.retryable == true, + ) + } + private fun resolveMainSessionKey(): String { val trimmed = _mainSessionKey.value.trim() return if (trimmed.isEmpty()) "main" else trimmed @@ -1410,11 +1452,14 @@ class NodeRuntime( } fun refreshGatewayConnection() { - val endpoint = - connectedEndpoint ?: run { - _statusText.value = "Failed: no cached gateway endpoint" - return - } + val endpoint = connectedEndpoint + if (endpoint == null) { + resolvePreferredGatewayEndpoint()?.let(::connect) + ?: run { + _statusText.value = "Failed: no saved gateway endpoint" + } + return + } operatorStatusText = "Connecting…" updateStatus() connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth(), reconnect = true) @@ -1524,6 +1569,7 @@ class NodeRuntime( connectAttemptId: Long, ) { if (!isCurrentConnectAttempt(connectAttemptId)) return + _gatewayConnectionProblem.value = null connectedEndpoint = endpoint operatorStatusText = "Connecting…" nodeStatusText = "Connecting…" @@ -1620,6 +1666,7 @@ class NodeRuntime( stopActiveVoiceSession() connectedEndpoint = null activeGatewayAuth = null + _gatewayConnectionProblem.value = null _pendingGatewayTrust.value = null operatorSession.disconnect() nodeSession.disconnect() @@ -1858,7 +1905,7 @@ class NodeRuntime( return } try { - val modelsRes = operatorSession.request("models.list", """{"view":"all"}""") + val modelsRes = operatorSession.request("models.list", "{}") val modelsRoot = json.parseToJsonElement(modelsRes).asObjectOrNull() _modelCatalog.value = parseGatewayModels(modelsRoot?.get("models") as? JsonArray) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt b/apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt index b26223106e2c..8790db36ac62 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/PermissionRequester.kt @@ -53,6 +53,7 @@ class PermissionRequester internal constructor( private val mutex = Mutex() private val requestSlotsLock = Any() private val mainHandler = Handler(Looper.getMainLooper()) + // ActivityResult launchers cannot be registered after start; pre-register a small pool for nested UI flows. private val launchers = List(4) { createPermissionRequestSlot(launcherFactory) } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt b/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt index 77fc08793ced..bbc0f628a791 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt @@ -42,6 +42,7 @@ class SecurePrefs( private const val notificationsForwardingSessionKeyKey = "notifications.forwarding.sessionKey" private const val installedAppsSharingEnabledKey = "device.apps.sharing.enabled" private const val voiceMicEnabledKey = "voice.micEnabled" + private const val appearanceThemeModeKey = "appearance.themeMode" } private val appContext = context.applicationContext @@ -181,6 +182,10 @@ class SecurePrefs( private val _speakerEnabled = MutableStateFlow(plainPrefs.getBoolean("voice.speakerEnabled", true)) val speakerEnabled: StateFlow = _speakerEnabled + private val _appearanceThemeMode = + MutableStateFlow(AppearanceThemeMode.fromRawValue(plainPrefs.getString(appearanceThemeModeKey, null))) + val appearanceThemeMode: StateFlow = _appearanceThemeMode + fun setLastDiscoveredStableId(value: String) { val trimmed = value.trim() plainPrefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) } @@ -525,6 +530,11 @@ class SecurePrefs( _speakerEnabled.value = value } + fun setAppearanceThemeMode(mode: AppearanceThemeMode) { + plainPrefs.edit { putString(appearanceThemeModeKey, mode.rawValue) } + _appearanceThemeMode.value = mode + } + private fun loadNotificationForwardingPackages(): Set { val raw = plainPrefs.getString(notificationsForwardingPackagesKey, null)?.trim() if (raw.isNullOrEmpty()) { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index e477da25a65b..acbdfc5561f8 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -61,9 +61,11 @@ class ChatController( private val pendingRuns = mutableSetOf() private val pendingRunTimeoutJobs = ConcurrentHashMap() + // Preserve sent messages locally until chat.history includes the gateway-confirmed copy. private val optimisticMessagesByRunId = LinkedHashMap() private val pendingRunTimeoutMs = 120_000L + // Drops stale history responses after session switches or refresh races. private val historyLoadGeneration = AtomicLong(0) @@ -225,6 +227,7 @@ class ChatController( role = "user", content = userContent, timestampMs = System.currentTimeMillis(), + idempotencyKey = "$runId:user", ) optimisticMessagesByRunId[runId] = optimisticMessage _messages.value = _messages.value + optimisticMessage @@ -350,6 +353,7 @@ class ChatController( ) if (!isCurrentHistoryLoad(sessionKey, _sessionKey.value, generation, historyLoadGeneration.get())) return val history = parseHistory(historyJson, sessionKey = sessionKey, previousMessages = _messages.value) + prunePersistedOptimisticMessages(history.messages) _messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values) _sessionId.value = history.sessionId _historyLoading.value = false @@ -422,10 +426,8 @@ class ChatController( } if (runId != null) { clearPendingRun(runId) - optimisticMessagesByRunId.remove(runId) } else { - clearPendingRuns() - optimisticMessagesByRunId.clear() + clearPendingRuns(clearOptimisticMessages = false) } pendingToolCallsById.clear() publishPendingToolCalls() @@ -455,6 +457,7 @@ class ChatController( sessionKey = currentSessionKey, previousMessages = _messages.value, ) + prunePersistedOptimisticMessages(history.messages) _messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values) _sessionId.value = history.sessionId history.thinkingLevel @@ -561,12 +564,14 @@ class ChatController( } } - private fun clearPendingRuns() { + private fun clearPendingRuns(clearOptimisticMessages: Boolean = true) { for ((_, job) in pendingRunTimeoutJobs) { job.cancel() } pendingRunTimeoutJobs.clear() - optimisticMessagesByRunId.clear() + if (clearOptimisticMessages) { + optimisticMessagesByRunId.clear() + } synchronized(pendingRuns) { pendingRuns.clear() _pendingRunCount.value = 0 @@ -578,6 +583,15 @@ class ChatController( _messages.value = _messages.value.filterNot { it.id == message.id } } + private fun prunePersistedOptimisticMessages(incoming: List) { + val retained = + retainUnmatchedOptimisticMessages( + incoming = incoming, + optimistic = optimisticMessagesByRunId.values, + ).toSet() + optimisticMessagesByRunId.entries.removeAll { entry -> entry.value !in retained } + } + private fun parseHistory( historyJson: String, sessionKey: String, @@ -592,13 +606,14 @@ class ChatController( array.mapNotNull { item -> val obj = item.asObjectOrNull() ?: return@mapNotNull null val role = obj["role"].asStringOrNull() ?: return@mapNotNull null - val content = obj["content"].asArrayOrNull()?.mapNotNull(::parseChatMessageContent) ?: emptyList() + val content = parseChatMessageContents(obj) val ts = obj["timestamp"].asLongOrNull() ChatMessage( id = UUID.randomUUID().toString(), role = role, content = content, timestampMs = ts, + idempotencyKey = obj["idempotencyKey"].asStringOrNull(), ) } @@ -674,6 +689,19 @@ internal fun parseChatMessageContent(el: JsonElement): ChatMessageContent? { } } +internal fun parseChatMessageContents(obj: JsonObject): List { + obj["content"].asArrayOrNull()?.let { content -> + return content.mapNotNull(::parseChatMessageContent) + } + obj["content"].asStringOrNull()?.let { text -> + return listOf(ChatMessageContent(type = "text", text = text)) + } + obj["text"].asStringOrNull()?.let { text -> + return listOf(ChatMessageContent(type = "text", text = text)) + } + return emptyList() +} + internal data class MainSessionState( val currentSessionKey: String, val appliedMainSessionKey: String, @@ -732,29 +760,41 @@ internal fun mergeOptimisticMessages( ): List { if (optimistic.isEmpty()) return incoming - val unmatchedIncoming = incoming.toMutableList() - val missingOptimistic = - optimistic.filter { message -> - val matchIndex = - unmatchedIncoming.indexOfFirst { incomingMessage -> - incomingMessageConsumesOptimistic(incomingMessage, message) - } - if (matchIndex >= 0) { - unmatchedIncoming.removeAt(matchIndex) - false - } else { - true - } - } + val missingOptimistic = retainUnmatchedOptimisticMessages(incoming = incoming, optimistic = optimistic) if (missingOptimistic.isEmpty()) return incoming return (incoming + missingOptimistic).sortedWith(compareBy { it.timestampMs ?: Long.MAX_VALUE }.thenBy { it.id }) } +internal fun retainUnmatchedOptimisticMessages( + incoming: List, + optimistic: Collection, +): List { + if (optimistic.isEmpty()) return emptyList() + + val unmatchedIncoming = incoming.toMutableList() + return optimistic.filter { message -> + val matchIndex = + unmatchedIncoming.indexOfFirst { incomingMessage -> + incomingMessageConsumesOptimistic(incomingMessage, message) + } + if (matchIndex >= 0) { + unmatchedIncoming.removeAt(matchIndex) + false + } else { + true + } + } +} + /** * Message identity used only for refresh reconciliation; it avoids exposing gateway ids as UI keys. */ internal fun messageIdentityKey(message: ChatMessage): String? { + val idempotencyKey = message.idempotencyKey?.trim().orEmpty() + if (idempotencyKey.isNotEmpty()) { + return listOf(message.role.trim().lowercase(), idempotencyKey).joinToString(separator = "|") + } val contentKey = messageContentIdentityKey(message) ?: return null val timestamp = message.timestampMs?.toString().orEmpty() if (timestamp.isEmpty() && contentKey.isEmpty()) return null @@ -767,6 +807,10 @@ private fun incomingMessageConsumesOptimistic( incoming: ChatMessage, optimistic: ChatMessage, ): Boolean { + val optimisticIdempotencyKey = optimistic.idempotencyKey?.trim().orEmpty() + if (optimisticIdempotencyKey.isNotEmpty()) { + return incoming.idempotencyKey?.trim() == optimisticIdempotencyKey + } if (optimisticMessageIdentityKey(incoming) != optimisticMessageIdentityKey(optimistic)) return false val incomingTimestamp = incoming.timestampMs ?: return false val optimisticTimestamp = optimistic.timestampMs ?: return true diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt index 30649a6d25dc..f2341da13767 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt @@ -8,6 +8,7 @@ data class ChatMessage( val role: String, val content: List, val timestampMs: Long?, + val idempotencyKey: String? = null, ) /** diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayDiscovery.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayDiscovery.kt index dea9d1eebdba..c3e8108476d4 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayDiscovery.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayDiscovery.kt @@ -49,6 +49,19 @@ import java.util.concurrent.Executors import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException +private fun createDnsResolver(context: Context): DnsResolver = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) { + createContextDnsResolver(context) + } else { + createLegacyDnsResolver() + } + +@TargetApi(Build.VERSION_CODES.CINNAMON_BUN) +private fun createContextDnsResolver(context: Context): DnsResolver = DnsResolver(context, null) + +@Suppress("DEPRECATION") +private fun createLegacyDnsResolver(): DnsResolver = DnsResolver.getInstance() + /** * Watches local DNS-SD and optional wide-area DNS-SD for reachable OpenClaw gateways. */ @@ -58,7 +71,7 @@ class GatewayDiscovery( ) { private val nsd = context.getSystemService(NsdManager::class.java) private val connectivity = context.getSystemService(ConnectivityManager::class.java) - private val dns = DnsResolver.getInstance() + private val dns = createDnsResolver(context) private val serviceType = "_openclaw-gw._tcp." private val wideAreaDomain = System.getenv("OPENCLAW_WIDE_AREA_DOMAIN") private val logTag = "OpenClaw/GatewayDiscovery" @@ -66,10 +79,12 @@ class GatewayDiscovery( private val localById = ConcurrentHashMap() private val unicastById = ConcurrentHashMap() private val _gateways = MutableStateFlow>(emptyList()) + /** Current discovered gateway list, merged from local DNS-SD and optional wide-area DNS-SD. */ val gateways: StateFlow> = _gateways.asStateFlow() private val _statusText = MutableStateFlow("Searching…") + /** Short diagnostic text shown by connect UI while discovery is running. */ val statusText: StateFlow = _statusText.asStateFlow() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt index a05b8044240d..ce89aa80e0c9 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt @@ -77,6 +77,8 @@ data class GatewayConnectErrorDetails( val recommendedNextStep: String?, val pauseReconnect: Boolean? = null, val reason: String? = null, + val requestId: String? = null, + val retryable: Boolean = false, ) /** @@ -120,6 +122,7 @@ class GatewaySession( private val deviceAuthStore: DeviceAuthTokenStore, private val onConnected: (GatewayHelloSummary) -> Unit, private val onDisconnected: (message: String) -> Unit, + private val onConnectFailure: (error: ErrorShape, pauseReconnect: Boolean) -> Unit = { _, _ -> }, private val onEvent: (event: String, payloadJson: String?) -> Unit, private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null, private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null, @@ -127,6 +130,7 @@ class GatewaySession( private companion object { // Keep connect timeout above observed gateway unauthorized close on lower-end devices. private const val CONNECT_RPC_TIMEOUT_MS = 12_000L + private val PAIRING_REQUEST_ID_PATTERN = Regex("^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") } /** @@ -923,6 +927,8 @@ class GatewaySession( recommendedNextStep = it["recommendedNextStep"].asStringOrNull(), pauseReconnect = it["pauseReconnect"].asBooleanOrNull(), reason = it["reason"].asStringOrNull(), + requestId = normalizePairingRequestId(it["requestId"].asStringOrNull()), + retryable = it["retryable"].asBooleanOrNull() == true, ) } ErrorShape(code, msg, details) @@ -948,6 +954,11 @@ class GatewaySession( onEvent(event, payloadJson) } + private fun normalizePairingRequestId(requestId: String?): String? { + val trimmed = requestId?.trim()?.takeIf { it.isNotEmpty() } ?: return null + return trimmed.takeIf { PAIRING_REQUEST_ID_PATTERN.matches(it) } + } + private suspend fun awaitConnectNonce(): String = try { withTimeout(2_000) { connectNonceDeferred.await() } @@ -1061,10 +1072,14 @@ class GatewaySession( } catch (err: Throwable) { attempt += 1 onDisconnected("Gateway error: ${err.message ?: err::class.java.simpleName}") - if ( - err is GatewayConnectFailure && - shouldPauseReconnectAfterAuthFailure(err.gatewayError) - ) { + val gatewayConnectFailure = err as? GatewayConnectFailure + val pauseForAuthFailure = + gatewayConnectFailure + ?.let { shouldPauseReconnectAfterAuthFailure(it.gatewayError) } == true + if (gatewayConnectFailure != null) { + onConnectFailure(gatewayConnectFailure.gatewayError, pauseForAuthFailure) + } + if (pauseForAuthFailure) { reconnectPausedForAuthFailure = true continue } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt index aacaba074e43..0322963e1d12 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt @@ -30,8 +30,7 @@ private const val MAX_DEVICE_APPS_LIMIT = 200 private const val DEVICE_APPS_SYSTEM_FLAGS = ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP -internal fun isSystemDeviceApp(appInfo: ApplicationInfo): Boolean = - (appInfo.flags and DEVICE_APPS_SYSTEM_FLAGS) != 0 +internal fun isSystemDeviceApp(appInfo: ApplicationInfo): Boolean = (appInfo.flags and DEVICE_APPS_SYSTEM_FLAGS) != 0 internal data class DeviceAppEntry( val label: String, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/CommandPalette.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/CommandPalette.kt index bf10cc018e62..6801ff534999 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/CommandPalette.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/CommandPalette.kt @@ -302,13 +302,10 @@ internal fun providerCommandSubtitle( providers: List, models: List, ): String { - if (!isConnected) return "Connect Gateway to load models" - val expiringProviderCount = expiringModelProviderCount(providers) - if (expiringProviderCount > 0) return "$expiringProviderCount providers expiring" - val readyProviderCount = readyModelProviderCount(providers, models) + if (!isConnected) return "Connect Gateway to view providers" + val readyProviderCount = providerRows(providers = providers, models = models).count { it.ready } if (readyProviderCount > 0) return "$readyProviderCount providers ready" - if (models.isNotEmpty()) return "${models.size} models available" - return "Configure model access" + return "No ready providers" } /** Falls back to the canonical main-session label when gateway display names are blank. */ diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt index 880c3a40e09e..a0e3e2527d8b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt @@ -1,7 +1,7 @@ package ai.openclaw.app.ui +import ai.openclaw.app.GatewayConnectionProblem import ai.openclaw.app.MainViewModel -import ai.openclaw.app.gateway.GatewayEndpoint import ai.openclaw.app.ui.mobileCardSurface import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.BorderStroke @@ -66,6 +66,7 @@ private enum class ConnectInputMode { fun ConnectTabScreen(viewModel: MainViewModel) { val context = LocalContext.current val statusText by viewModel.statusText.collectAsState() + val gatewayConnectionProblem by viewModel.gatewayConnectionProblem.collectAsState() val isConnected by viewModel.isConnected.collectAsState() val remoteAddress by viewModel.remoteAddress.collectAsState() val manualHost by viewModel.manualHost.collectAsState() @@ -147,13 +148,10 @@ fun ConnectTabScreen(viewModel: MainViewModel) { } } - val showDiagnostics = !isConnected && gatewayStatusHasDiagnostics(statusText) - val pairingRequired = !isConnected && gatewayStatusLooksLikePairing(statusText) - val statusLabel = gatewayStatusForDisplay(statusText) - - PairingAutoRetryEffect(enabled = pairingRequired) { - viewModel.refreshGatewayConnection() - } + val showDiagnostics = !isConnected && (gatewayConnectionProblem != null || gatewayStatusHasDiagnostics(statusText)) + val pairingRequired = !isConnected && (gatewayConnectionProblem?.isPairingRequired == true || gatewayStatusLooksLikePairing(statusText)) + val pairingInstruction = gatewayPairingInstruction(gatewayConnectionProblem) + val statusLabel = gatewayStatusForDisplay(gatewayConnectionProblem?.message ?: statusText) Column( modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 16.dp), @@ -291,27 +289,14 @@ fun ConnectTabScreen(viewModel: MainViewModel) { } validationText = null - if (inputMode == ConnectInputMode.SetupCode) { - // Setup-code auth should replace old bootstrap/shared credentials; - // manual reconnects keep existing typed credentials. - viewModel.resetGatewaySetupAuth() - } - viewModel.setManualEnabled(true) - viewModel.setManualHost(config.host) - viewModel.setManualPort(config.port) - viewModel.setManualTls(config.tls) - viewModel.setGatewayBootstrapToken(config.bootstrapToken) - if (config.token.isNotBlank()) { - viewModel.setGatewayToken(config.token) - } else if (config.bootstrapToken.isNotBlank()) { - viewModel.setGatewayToken("") - } - viewModel.setGatewayPassword(config.password) - viewModel.connect( - GatewayEndpoint.manual(host = config.host, port = config.port), - token = config.token.ifEmpty { null }, - bootstrapToken = config.bootstrapToken.ifEmpty { null }, - password = config.password.ifEmpty { null }, + viewModel.saveGatewayConfigAndConnect( + host = config.host, + port = config.port, + tls = config.tls, + token = config.token, + bootstrapToken = config.bootstrapToken, + password = config.password, + resetSetupAuth = inputMode == ConnectInputMode.SetupCode, ) }, modifier = Modifier.fillMaxWidth().height(52.dp), @@ -341,7 +326,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) { Text(statusLabel, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText) if (pairingRequired) { Text( - "Approve this phone on the gateway. OpenClaw retries automatically while this screen stays open.", + pairingInstruction, style = mobileCallout, color = mobileTextSecondary, ) @@ -590,6 +575,13 @@ fun ConnectTabScreen(viewModel: MainViewModel) { } } +private fun gatewayPairingInstruction(problem: GatewayConnectionProblem?): String = + if (problem?.canAutoRetry == true) { + "Approve this phone on the gateway. OpenClaw will reconnect automatically." + } else { + "Approve this phone on the gateway, then retry the connection." + } + @Composable private fun MethodChip( label: String, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayPairingRetry.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayPairingRetry.kt deleted file mode 100644 index 657aaf6d1bed..000000000000 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayPairingRetry.kt +++ /dev/null @@ -1,53 +0,0 @@ -package ai.openclaw.app.ui - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import kotlinx.coroutines.delay - -internal const val PAIRING_INITIAL_AUTO_RETRY_MS = 1_500L -internal const val PAIRING_AUTO_RETRY_MS = 4_000L - -/** Retries pairing-only gateway refreshes while the screen is visible and started. */ -@Composable -internal fun PairingAutoRetryEffect( - enabled: Boolean, - onRetry: () -> Unit, -) { - val lifecycleOwner = LocalLifecycleOwner.current - var lifecycleStarted by - remember(lifecycleOwner) { - mutableStateOf(lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) - } - - DisposableEffect(lifecycleOwner) { - val observer = - LifecycleEventObserver { _, _ -> - lifecycleStarted = lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } - } - - LaunchedEffect(enabled, lifecycleStarted) { - if (!enabled || !lifecycleStarted) { - return@LaunchedEffect - } - // Give the gateway a short settling window before the first retry so an - // approval response is not immediately chased by a redundant reconnect. - delay(PAIRING_INITIAL_AUTO_RETRY_MS) - while (true) { - onRetry() - delay(PAIRING_AUTO_RETRY_MS) - } - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/MobileUiTokens.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/MobileUiTokens.kt index 77f763f69cf7..7ad7625ee6d6 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/MobileUiTokens.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/MobileUiTokens.kt @@ -41,27 +41,27 @@ internal data class MobileColors( internal fun lightMobileColors() = MobileColors( - surface = Color(0xFFF6F7FA), - surfaceStrong = Color(0xFFECEEF3), + surface = Color(0xFFFAFBFC), + surfaceStrong = Color(0xFFEFF3F8), cardSurface = Color(0xFFFFFFFF), - border = Color(0xFFE5E7EC), - borderStrong = Color(0xFFD6DAE2), - text = Color(0xFF17181C), - textSecondary = Color(0xFF5D6472), - textTertiary = Color(0xFF99A0AE), - accent = Color(0xFF1D5DD8), - accentSoft = Color(0xFFECF3FF), - accentBorderStrong = Color(0xFF184DAF), - success = Color(0xFF2F8C5A), - successSoft = Color(0xFFEEF9F3), - warning = Color(0xFFC8841A), - warningSoft = Color(0xFFFFF8EC), - danger = Color(0xFFD04B4B), - dangerSoft = Color(0xFFFFF2F2), - codeBg = Color(0xFF15171B), - codeText = Color(0xFFE8EAEE), - codeBorder = Color(0xFF2B2E35), - codeAccent = Color(0xFF3FC97A), + border = Color(0xFFDDE3EC), + borderStrong = Color(0xFFC7D0DC), + text = Color(0xFF16181D), + textSecondary = Color(0xFF505B6A), + textTertiary = Color(0xFF8E98A7), + accent = Color(0xFF1B5ACB), + accentSoft = Color(0xFFEAF2FF), + accentBorderStrong = Color(0xFF174CA9), + success = Color(0xFF287F52), + successSoft = Color(0xFFEAF7F0), + warning = Color(0xFFAF7418), + warningSoft = Color(0xFFFFF4DF), + danger = Color(0xFFC94343), + dangerSoft = Color(0xFFFFECEC), + codeBg = Color(0xFFEFF3F8), + codeText = Color(0xFF172033), + codeBorder = Color(0xFFD7DDE7), + codeAccent = Color(0xFF287F52), chipBorderConnected = Color(0xFFCFEBD8), chipBorderConnecting = Color(0xFFD5E2FA), chipBorderWarning = Color(0xFFEED8B8), diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt index 7c498a1cff34..c20f6d811df1 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt @@ -1,9 +1,10 @@ package ai.openclaw.app.ui +import ai.openclaw.app.GatewayConnectionProblem import ai.openclaw.app.LocationMode import ai.openclaw.app.MainViewModel +import ai.openclaw.app.R import ai.openclaw.app.SensitiveFeatureConfig -import ai.openclaw.app.gateway.GatewayEndpoint import ai.openclaw.app.node.DeviceNotificationListenerService import ai.openclaw.app.ui.design.ClawDesignTheme import ai.openclaw.app.ui.design.ClawErrorState @@ -31,24 +32,31 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight @@ -88,10 +96,13 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -120,14 +131,19 @@ fun OnboardingFlow( viewModel: MainViewModel, modifier: Modifier = Modifier, ) { - ClawDesignTheme { + val appearanceThemeMode by viewModel.appearanceThemeMode.collectAsState() + val onboardingDark = appearanceThemeMode.isDark(systemDark = isSystemInDarkTheme()) + ClawDesignTheme(dark = onboardingDark) { val context = LocalContext.current val statusText by viewModel.statusText.collectAsState() + val gatewayConnectionProblem by viewModel.gatewayConnectionProblem.collectAsState() val isConnected by viewModel.isConnected.collectAsState() val isNodeConnected by viewModel.isNodeConnected.collectAsState() + val runtimeInitialized by viewModel.runtimeInitialized.collectAsState() val serverName by viewModel.serverName.collectAsState() val remoteAddress by viewModel.remoteAddress.collectAsState() val gateways by viewModel.gateways.collectAsState() + val discoveryStatusText by viewModel.discoveryStatusText.collectAsState() val savedToken by viewModel.gatewayToken.collectAsState() val pendingTrust by viewModel.pendingGatewayTrust.collectAsState() val startAtGatewaySetup by viewModel.startOnboardingAtGatewaySetup.collectAsState() @@ -142,9 +158,12 @@ fun OnboardingFlow( var password by rememberSaveable { mutableStateOf("") } var setupError by rememberSaveable { mutableStateOf(null) } var attemptedConnect by rememberSaveable { mutableStateOf(false) } + var attemptedGatewayName by rememberSaveable { mutableStateOf(null) } var connectAttemptStartedAtMs by rememberSaveable { mutableLongStateOf(0L) } var recoveryNowMs by remember { mutableLongStateOf(SystemClock.elapsedRealtime()) } + OpenClawSystemBarAppearance(lightAppearance = !onboardingDark && step != OnboardingStep.Welcome) + val qrScannerOptions = remember { GmsBarcodeScannerOptions @@ -163,6 +182,12 @@ fun OnboardingFlow( } } + LaunchedEffect(step) { + if (step == OnboardingStep.Gateway) { + viewModel.startGatewayDiscovery() + } + } + LaunchedEffect(ready, attemptedConnect) { if (attemptedConnect && ready) { step = OnboardingStep.Permissions @@ -203,10 +228,12 @@ fun OnboardingFlow( when (step) { OnboardingStep.Welcome -> - WelcomeScreen( - modifier = modifier, - onConnect = { step = OnboardingStep.Gateway }, - ) + ClawDesignTheme(dark = true) { + WelcomeScreen( + modifier = modifier, + onConnect = { step = OnboardingStep.Gateway }, + ) + } OnboardingStep.Gateway -> GatewaySetupScreen( modifier = modifier, @@ -217,6 +244,8 @@ fun OnboardingFlow( token = token, password = password, nearbyGatewayName = gateways.firstOrNull()?.name, + discoveryStatusText = discoveryStatusText, + discoveryStarted = runtimeInitialized, error = setupError, onBack = { step = OnboardingStep.Welcome }, onScan = { @@ -253,8 +282,10 @@ fun OnboardingFlow( onPasswordChange = { password = it }, onUseNearby = { val endpoint = gateways.firstOrNull() ?: return@GatewaySetupScreen + attemptedGatewayName = endpoint.name attemptedConnect = true - viewModel.connect(endpoint) + connectAttemptStartedAtMs = SystemClock.elapsedRealtime() + viewModel.connectInBackground(endpoint) step = OnboardingStep.Recovery }, onPair = { @@ -273,23 +304,17 @@ fun OnboardingFlow( } setupError = null + attemptedGatewayName = null attemptedConnect = true connectAttemptStartedAtMs = SystemClock.elapsedRealtime() - // Setup-code pairing replaces any stale shared credentials before - // the bootstrap token is stored for the first authenticated connect. - viewModel.resetGatewaySetupAuth() - viewModel.setManualEnabled(true) - viewModel.setManualHost(config.host) - viewModel.setManualPort(config.port) - viewModel.setManualTls(config.tls) - viewModel.setGatewayBootstrapToken(config.bootstrapToken) - viewModel.setGatewayToken(config.token) - viewModel.setGatewayPassword(config.password) - viewModel.connect( - GatewayEndpoint.manual(host = config.host, port = config.port), - token = config.token.ifEmpty { null }, - bootstrapToken = config.bootstrapToken.ifEmpty { null }, - password = config.password.ifEmpty { null }, + viewModel.saveGatewayConfigAndConnect( + host = config.host, + port = config.port, + tls = config.tls, + token = config.token, + bootstrapToken = config.bootstrapToken, + password = config.password, + resetSetupAuth = true, ) step = OnboardingStep.Recovery }, @@ -299,11 +324,11 @@ fun OnboardingFlow( modifier = modifier, statusText = statusText, serverName = serverName, + attemptedGatewayName = attemptedGatewayName, remoteAddress = remoteAddress, ready = ready, - attemptedConnect = attemptedConnect, + gatewayConnectionProblem = gatewayConnectionProblem, connectSettling = recoveryNowMs - connectAttemptStartedAtMs < GATEWAY_CONNECT_SETTLING_MS, - onAutoRetry = viewModel::refreshGatewayConnection, onBack = { step = OnboardingStep.Gateway }, onRetry = { attemptedConnect = true @@ -317,11 +342,14 @@ fun OnboardingFlow( token = token, password = password, ) ?: return@GatewayRecoveryScreen - viewModel.connect( - GatewayEndpoint.manual(host = config.host, port = config.port), - token = config.token.ifEmpty { null }, - bootstrapToken = config.bootstrapToken.ifEmpty { null }, - password = config.password.ifEmpty { null }, + viewModel.saveGatewayConfigAndConnect( + host = config.host, + port = config.port, + tls = config.tls, + token = config.token, + bootstrapToken = config.bootstrapToken, + password = config.password, + resetSetupAuth = false, ) }, onEdit = { step = OnboardingStep.Gateway }, @@ -346,20 +374,39 @@ private fun WelcomeScreen( onConnect: () -> Unit, modifier: Modifier = Modifier, ) { - ClawScaffold(modifier = modifier, contentPadding = PaddingValues(horizontal = 24.dp, vertical = 18.dp)) { + val welcomeBackground = + Brush.verticalGradient( + colors = + listOf( + Color(0xFFFF4D4D), + Color(0xFFD73332), + Color(0xFF991B1B), + Color(0xFF260707), + ), + ) + + Box( + modifier = + modifier + .fillMaxSize() + .background(welcomeBackground) + .windowInsetsPadding(WindowInsets.safeDrawing) + .padding(horizontal = 24.dp, vertical = 18.dp), + ) { Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(modifier = Modifier.height(96.dp)) Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(18.dp)) { + WelcomeLogo() Text( text = "OPENCLAW", style = ClawTheme.type.display.copy(fontSize = 34.sp, lineHeight = 38.sp, fontWeight = FontWeight.Black), color = ClawTheme.colors.text, ) Text( - text = "Your AI command center.\nPrivate. Local. Under your control.", + text = "Your personal AI assistant.\nExfoliate! Exfoliate!", style = ClawTheme.type.section, color = ClawTheme.colors.text, textAlign = TextAlign.Center, @@ -370,19 +417,26 @@ private fun WelcomeScreen( Spacer(modifier = Modifier.height(30.dp)) Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) { HeroPrimaryAction(title = "Connect Gateway", onClick = onConnect) - OutlinedAction(title = "Enter setup code", icon = Icons.AutoMirrored.Filled.KeyboardArrowRight, onClick = onConnect) - Surface(onClick = onConnect, color = Color.Transparent, contentColor = ClawTheme.colors.text) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - Text(text = "Already have a setup? ", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) - Text(text = "Sign in", style = ClawTheme.type.body.copy(fontWeight = FontWeight.SemiBold), color = ClawTheme.colors.text) - } - } } Spacer(modifier = Modifier.height(104.dp)) } } } +@Composable +private fun WelcomeLogo() { + Surface( + modifier = Modifier.size(82.dp), + shape = CircleShape, + color = Color.White.copy(alpha = 0.92f), + contentColor = Color.Unspecified, + ) { + Box(modifier = Modifier.fillMaxSize().padding(12.dp), contentAlignment = Alignment.Center) { + Image(painter = painterResource(id = R.drawable.openclaw_logo), contentDescription = "OpenClaw logo", modifier = Modifier.fillMaxSize()) + } + } +} + @Composable private fun WelcomeHorizon() { Canvas(modifier = Modifier.fillMaxWidth().height(120.dp)) { @@ -428,6 +482,8 @@ private fun GatewaySetupScreen( token: String, password: String, nearbyGatewayName: String?, + discoveryStatusText: String, + discoveryStarted: Boolean, error: String?, onBack: () -> Unit, onScan: () -> Unit, @@ -442,6 +498,29 @@ private fun GatewaySetupScreen( modifier: Modifier = Modifier, ) { var advancedOpen by rememberSaveable { mutableStateOf(false) } + var nearbySearchTimedOut by remember { mutableStateOf(false) } + + LaunchedEffect(nearbyGatewayName, discoveryStatusText, discoveryStarted) { + if (!nearbyGatewayName.isNullOrBlank()) { + nearbySearchTimedOut = false + return@LaunchedEffect + } + if (!discoveryStarted) { + nearbySearchTimedOut = false + return@LaunchedEffect + } + nearbySearchTimedOut = false + delay(5_000) + nearbySearchTimedOut = true + } + + val nearbyGateway = + nearbyGatewayUiState( + nearbyGatewayName = nearbyGatewayName, + discoveryStatusText = discoveryStatusText, + discoveryStarted = discoveryStarted, + searchTimedOut = nearbySearchTimedOut, + ) ClawScaffold(modifier = modifier, contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp)) { Column(modifier = Modifier.fillMaxSize().imePadding(), verticalArrangement = Arrangement.SpaceBetween) { @@ -461,9 +540,9 @@ private fun GatewaySetupScreen( GatewayOption( icon = Icons.Default.WifiTethering, title = "Nearby gateway", - subtitle = nearbyGatewayName ?: "Discovery ready", - status = nearbyGatewayName?.let { "Found" }, - onClick = onUseNearby, + subtitle = nearbyGateway.subtitle, + status = nearbyGateway.status, + onClick = onUseNearby.takeIf { nearbyGateway.canConnect }, ) } item { @@ -527,20 +606,19 @@ private fun GatewaySetupScreen( private fun GatewayRecoveryScreen( statusText: String, serverName: String?, + attemptedGatewayName: String?, remoteAddress: String?, ready: Boolean, - attemptedConnect: Boolean, + gatewayConnectionProblem: GatewayConnectionProblem?, connectSettling: Boolean, - onAutoRetry: () -> Unit, onBack: () -> Unit, onRetry: () -> Unit, onEdit: () -> Unit, onContinue: () -> Unit, modifier: Modifier = Modifier, ) { - val recoveryState = gatewayRecoveryUiState(ready = ready, statusText = statusText, connectSettling = connectSettling) + val recoveryState = gatewayRecoveryUiState(ready = ready, statusText = statusText, connectSettling = connectSettling, gatewayConnectionProblem = gatewayConnectionProblem) val context = LocalContext.current - PairingAutoRetryEffect(enabled = recoveryState.canAutoRetry && attemptedConnect, onRetry = onAutoRetry) ClawScaffold(modifier = modifier, contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp)) { Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(18.dp)) { @@ -551,6 +629,7 @@ private fun GatewayRecoveryScreen( imageVector = when (recoveryState) { GatewayRecoveryUiState.Connected -> Icons.Default.CheckCircle + GatewayRecoveryUiState.ApprovalRequired -> Icons.Default.WifiTethering GatewayRecoveryUiState.Pairing -> Icons.Default.WifiTethering GatewayRecoveryUiState.Finishing -> Icons.Default.WifiTethering GatewayRecoveryUiState.Failed -> Icons.Default.ErrorOutline @@ -560,6 +639,7 @@ private fun GatewayRecoveryScreen( tint = when (recoveryState) { GatewayRecoveryUiState.Connected -> ClawTheme.colors.success + GatewayRecoveryUiState.ApprovalRequired -> ClawTheme.colors.warning GatewayRecoveryUiState.Pairing -> ClawTheme.colors.text GatewayRecoveryUiState.Finishing -> ClawTheme.colors.text GatewayRecoveryUiState.Failed -> ClawTheme.colors.warning @@ -577,12 +657,16 @@ private fun GatewayRecoveryScreen( ClawPanel { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Text(text = "Last gateway", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted) - Text(text = serverName?.takeIf { it.isNotBlank() } ?: "Home Gateway", style = ClawTheme.type.section, color = ClawTheme.colors.text) - Text(text = recoveryGatewayDetail(ready = ready, remoteAddress = remoteAddress, statusText = statusText), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) + Text(text = recoveryGatewayName(serverName = serverName, attemptedGatewayName = attemptedGatewayName), style = ClawTheme.type.section, color = ClawTheme.colors.text) + Text(text = recoveryGatewayDetail(ready = ready, remoteAddress = remoteAddress, statusText = statusText, gatewayConnectionProblem = gatewayConnectionProblem), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) + recoveryGatewayApprovalCommand(gatewayConnectionProblem)?.let { command -> + ApprovalCommandBlock(command = command, onCopy = { copyApprovalCommand(context, command) }) + } ClawStatusPill( text = when (recoveryState) { GatewayRecoveryUiState.Connected -> "Healthy" + GatewayRecoveryUiState.ApprovalRequired -> "Needs approval" GatewayRecoveryUiState.Pairing -> "Pairing" GatewayRecoveryUiState.Finishing -> "Connecting" GatewayRecoveryUiState.Failed -> "Needs attention" @@ -590,6 +674,7 @@ private fun GatewayRecoveryScreen( status = when (recoveryState) { GatewayRecoveryUiState.Connected -> ClawStatus.Success + GatewayRecoveryUiState.ApprovalRequired -> ClawStatus.Warning GatewayRecoveryUiState.Pairing -> ClawStatus.Neutral GatewayRecoveryUiState.Finishing -> ClawStatus.Neutral GatewayRecoveryUiState.Failed -> ClawStatus.Warning @@ -606,7 +691,42 @@ private fun GatewayRecoveryScreen( modifier = Modifier.fillMaxWidth(), ) OutlinedAction(title = "Edit connection", icon = Icons.Default.Edit, onClick = onEdit) - OutlinedAction(title = "Copy diagnostic", icon = Icons.Default.ContentCopy, onClick = { copyGatewayDiagnostic(context, statusText, serverName, remoteAddress, ready) }) + OutlinedAction(title = "Copy diagnostic", icon = Icons.Default.ContentCopy, onClick = { copyGatewayDiagnostic(context, statusText, serverName, remoteAddress, ready, gatewayConnectionProblem) }) + } + } + } +} + +@Composable +private fun ApprovalCommandBlock( + command: String, + onCopy: () -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = ClawTheme.colors.surfacePressed, + border = BorderStroke(1.dp, ClawTheme.colors.border), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 6.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SelectionContainer(modifier = Modifier.weight(1f)) { + Text(text = command, style = ClawTheme.type.body.copy(fontFamily = FontFamily.Monospace), color = ClawTheme.colors.text) + } + Surface( + onClick = onCopy, + modifier = Modifier.size(36.dp), + shape = RoundedCornerShape(8.dp), + color = ClawTheme.colors.surfaceRaised, + contentColor = ClawTheme.colors.text, + border = BorderStroke(1.dp, ClawTheme.colors.border), + ) { + Box(contentAlignment = Alignment.Center) { + Icon(imageVector = Icons.Default.ContentCopy, contentDescription = "Copy approval command", modifier = Modifier.size(18.dp)) + } } } } @@ -703,7 +823,7 @@ private fun GatewayOption( icon: ImageVector, title: String, subtitle: String, - onClick: () -> Unit, + onClick: (() -> Unit)?, status: String? = null, ) { ClawPanel(contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)) { @@ -712,9 +832,12 @@ private fun GatewayOption( subtitle = subtitle, metadata = status, leading = { Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(22.dp), tint = ClawTheme.colors.text) }, - trailing = { - Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Open $title", modifier = Modifier.size(20.dp), tint = ClawTheme.colors.text) - }, + trailing = + onClick?.let { + { + Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Open $title", modifier = Modifier.size(20.dp), tint = ClawTheme.colors.text) + } + }, onClick = onClick, ) } @@ -890,38 +1013,80 @@ private fun PermissionContinueButton(onClick: () -> Unit) { internal enum class GatewayRecoveryUiState( val title: String, val message: String, - val canAutoRetry: Boolean, ) { Connected( title = "Connected", message = "Your Gateway is ready.", - canAutoRetry = false, + ), + ApprovalRequired( + title = "Pairing Gateway", + message = "Approve this phone on the gateway.\nThen retry the connection.", ), Pairing( title = "Pairing Gateway", message = "Approval is in progress.\nOpenClaw will reconnect automatically.", - canAutoRetry = true, ), Finishing( - title = "Finishing Setup", - message = "Gateway approved this phone.\nOpenClaw is bringing the node online.", - canAutoRetry = true, + title = "Connecting Gateway", + message = "OpenClaw is checking gateway and node access.", ), Failed( title = "Connection issue", message = "We could not reach your Gateway.\nLet's fix this.", - canAutoRetry = false, ), } +internal data class NearbyGatewayUiState( + val subtitle: String, + val status: String?, + val canConnect: Boolean, +) + +/** Maps best-effort discovery into row copy and clickability for onboarding. */ +internal fun nearbyGatewayUiState( + nearbyGatewayName: String?, + discoveryStatusText: String, + discoveryStarted: Boolean = true, + searchTimedOut: Boolean = false, +): NearbyGatewayUiState { + val name = nearbyGatewayName?.trim().takeUnless { it.isNullOrEmpty() } + if (name != null) { + return NearbyGatewayUiState(subtitle = name, status = "Found", canConnect = true) + } + if (!discoveryStarted) { + return NearbyGatewayUiState(subtitle = "Starting discovery...", status = "Starting", canConnect = false) + } + + val status = discoveryStatusText.trim() + val searching = + status.isEmpty() || + status.equals("Searching…", ignoreCase = true) || + status.contains("Searching", ignoreCase = true) || + status.endsWith("?", ignoreCase = true) + return if (searching) { + if (searchTimedOut) { + NearbyGatewayUiState(subtitle = "No gateway found", status = "Not found", canConnect = false) + } else { + NearbyGatewayUiState(subtitle = "Searching for gateways...", status = "Searching", canConnect = false) + } + } else { + NearbyGatewayUiState(subtitle = "No gateway found", status = "Not found", canConnect = false) + } +} + /** Derives recovery screen state from gateway/node readiness and transient status text. */ internal fun gatewayRecoveryUiState( ready: Boolean, statusText: String, connectSettling: Boolean, + gatewayConnectionProblem: GatewayConnectionProblem? = null, ): GatewayRecoveryUiState = when { ready -> GatewayRecoveryUiState.Connected + gatewayConnectionProblem?.isPairingRequired == true && + !gatewayConnectionProblem.canAutoRetry -> GatewayRecoveryUiState.ApprovalRequired + gatewayConnectionProblem?.isPairingRequired == true -> GatewayRecoveryUiState.Pairing + gatewayConnectionProblem?.pauseReconnect == true -> GatewayRecoveryUiState.Failed connectSettling -> GatewayRecoveryUiState.Finishing gatewayStatusLooksLikePairing(statusText) -> GatewayRecoveryUiState.Pairing gatewayStatusLooksLikePartialConnect(statusText) -> GatewayRecoveryUiState.Finishing @@ -934,6 +1099,18 @@ internal fun gatewayStatusLooksLikePartialConnect(statusText: String): Boolean { return lower.contains("operator offline") || lower.contains("node offline") } +internal fun recoveryGatewayName( + serverName: String?, + attemptedGatewayName: String?, +): String = + serverName + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?: attemptedGatewayName + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?: "Home Gateway" + private data class GatewayConfig( val host: String, val port: Int, @@ -993,11 +1170,16 @@ private fun recoveryGatewayDetail( ready: Boolean, remoteAddress: String?, statusText: String, + gatewayConnectionProblem: GatewayConnectionProblem?, ): String = remoteAddress ?.takeIf { it.isNotBlank() } ?: if (ready) { "Ready for chat and voice" + } else if (gatewayConnectionProblem?.isPairingRequired == true && !gatewayConnectionProblem.canAutoRetry) { + recoveryGatewayApprovalCommand(gatewayConnectionProblem) + ?.let { "Gateway approval is pending. Run this on the gateway host:" } + ?: "Gateway approval is pending. Run openclaw devices list on the gateway host, approve this phone, then retry." } else if (statusText.contains("operator offline", ignoreCase = true)) { "Gateway paired. Waiting for operator access." } else if (gatewayStatusLooksLikePairing(statusText)) { @@ -1006,6 +1188,25 @@ private fun recoveryGatewayDetail( "Gateway unreachable" } +private fun recoveryGatewayApprovalCommand(gatewayConnectionProblem: GatewayConnectionProblem?): String? { + if (gatewayConnectionProblem?.isPairingRequired != true || gatewayConnectionProblem.canAutoRetry) return null + val requestId = gatewayConnectionProblem.requestId?.trim()?.takeIf { it.isNotEmpty() } + return if (requestId != null) { + "openclaw devices approve $requestId" + } else { + "openclaw devices list" + } +} + +private fun copyApprovalCommand( + context: Context, + command: String, +) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("OpenClaw pairing approval command", command)) + Toast.makeText(context, "Approval command copied", Toast.LENGTH_SHORT).show() +} + /** Copies the onboarding recovery snapshot for support without including credentials. */ private fun copyGatewayDiagnostic( context: Context, @@ -1013,11 +1214,16 @@ private fun copyGatewayDiagnostic( serverName: String?, remoteAddress: String?, ready: Boolean, + gatewayConnectionProblem: GatewayConnectionProblem?, ) { + val approvalCommand = recoveryGatewayApprovalCommand(gatewayConnectionProblem) val diagnostic = - listOf( + listOfNotNull( "OpenClaw Android gateway diagnostic", "Status: $statusText", + gatewayConnectionProblem?.message?.let { "Gateway problem: $it" }, + gatewayConnectionProblem?.requestId?.let { "Pairing request: $it" }, + approvalCommand?.let { "Approval command: $it" }, "Gateway: ${serverName?.takeIf { it.isNotBlank() } ?: "Home Gateway"}", "Address: ${remoteAddress?.takeIf { it.isNotBlank() } ?: "Not available"}", "Ready: ${if (ready) "yes" else "no"}", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/OpenClawTheme.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/OpenClawTheme.kt index 9c022895dbc4..af83ff9997d7 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/OpenClawTheme.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/OpenClawTheme.kt @@ -1,5 +1,6 @@ package ai.openclaw.app.ui +import ai.openclaw.app.AppearanceThemeMode import android.app.Activity import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme @@ -8,34 +9,51 @@ import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat +private val LocalOpenClawDarkTheme = staticCompositionLocalOf { true } + /** * App theme wrapper that installs dynamic Material colors and legacy mobile color tokens. */ @Composable -fun OpenClawTheme(content: @Composable () -> Unit) { +fun OpenClawTheme( + themeMode: AppearanceThemeMode = AppearanceThemeMode.Dark, + content: @Composable () -> Unit, +) { val context = LocalContext.current - val isDark = isSystemInDarkTheme() + val isDark = themeMode.isDark(systemDark = isSystemInDarkTheme()) val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) val mobileColors = if (isDark) darkMobileColors() else lightMobileColors() + OpenClawSystemBarAppearance(lightAppearance = !isDark) + + CompositionLocalProvider( + LocalMobileColors provides mobileColors, + LocalOpenClawDarkTheme provides isDark, + ) { + MaterialTheme(colorScheme = colorScheme, content = content) + } +} + +@Composable +internal fun OpenClawSystemBarAppearance(lightAppearance: Boolean) { val view = LocalView.current if (!view.isInEditMode) { SideEffect { - val window = (view.context as Activity).window + val window = (view.context as? Activity)?.window ?: return@SideEffect WindowCompat .getInsetsController(window, window.decorView) - .isAppearanceLightStatusBars = !isDark + .isAppearanceLightStatusBars = lightAppearance + WindowCompat + .getInsetsController(window, window.decorView) + .isAppearanceLightNavigationBars = lightAppearance } } - - CompositionLocalProvider(LocalMobileColors provides mobileColors) { - MaterialTheme(colorScheme = colorScheme, content = content) - } } /** @@ -44,9 +62,9 @@ fun OpenClawTheme(content: @Composable () -> Unit) { @Composable fun overlayContainerColor(): Color { val scheme = MaterialTheme.colorScheme - val isDark = isSystemInDarkTheme() + val isDark = LocalOpenClawDarkTheme.current val base = if (isDark) scheme.surfaceContainerLow else scheme.surfaceContainerHigh - // Light mode: background stays dark (canvas), so clamp overlays away from pure-white glare. + // Light mode keeps overlays away from pure-white glare on the app canvas. return if (isDark) base else base.copy(alpha = 0.88f) } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/ProvidersModelsScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ProvidersModelsScreen.kt index 0866ea02e369..f3609e431b4a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/ProvidersModelsScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ProvidersModelsScreen.kt @@ -6,7 +6,6 @@ import ai.openclaw.app.MainViewModel import ai.openclaw.app.providerDisplayName import ai.openclaw.app.ui.design.ClawEmptyState import ai.openclaw.app.ui.design.ClawPanel -import ai.openclaw.app.ui.design.ClawPrimaryButton import ai.openclaw.app.ui.design.ClawScaffold import ai.openclaw.app.ui.design.ClawSecondaryButton import ai.openclaw.app.ui.design.ClawTheme @@ -17,27 +16,20 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Surface @@ -46,25 +38,20 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -/** Android providers/models browser backed by the gateway catalog. */ +/** Android provider readiness screen backed by the configured gateway model view. */ @Composable internal fun ProvidersModelsScreen( viewModel: MainViewModel, onBack: () -> Unit, - onAddProvider: () -> Unit, ) { val isConnected by viewModel.isConnected.collectAsState() val models by viewModel.modelCatalog.collectAsState() @@ -72,9 +59,6 @@ internal fun ProvidersModelsScreen( val refreshing by viewModel.modelCatalogRefreshing.collectAsState() val errorText by viewModel.modelCatalogErrorText.collectAsState() val providerRows = providerRows(providers = providers, models = models) - val modelGroups = sortedModelGroups(models) - val setupRows = providerSetupRows(providerRows) - var expandedModelProviders by rememberSaveable { mutableStateOf(emptyList()) } LaunchedEffect(isConnected) { if (isConnected) { @@ -100,12 +84,11 @@ internal fun ProvidersModelsScreen( horizontalArrangement = Arrangement.SpaceBetween, ) { ProviderHeaderIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", onClick = onBack) - ProviderHeaderIconButton(icon = Icons.Default.Add, contentDescription = "Add provider", outlined = true, onClick = onAddProvider) } Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { Text(text = "Providers & Models", style = ClawTheme.type.display.copy(fontSize = 14.8.sp, lineHeight = 18.sp), color = ClawTheme.colors.text, maxLines = 1) Text( - text = "Connect and manage AI providers\nBrowse models and their capabilities.", + text = "Review provider readiness\nand configured models.", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, ) @@ -119,26 +102,17 @@ internal fun ProvidersModelsScreen( providerRows = providerRows, modelCount = models.size, onRefresh = viewModel::refreshModelCatalog, - onSetup = onAddProvider, refreshing = refreshing, ) } - item { - ProviderSectionLabel(title = "Provider setup") - } - - item { - ProviderSetupList(rows = setupRows, onSetup = onAddProvider) - } - item { ProviderSectionLabel(title = "Connected providers") } item { if (!isConnected && providerRows.isEmpty()) { - ClawEmptyState(title = "Gateway offline", body = "Connect your Gateway to load provider readiness and model catalog.") + ClawEmptyState(title = "Gateway offline", body = "Connect your Gateway to load provider readiness.") } else { ProviderList(rows = providerRows, refreshing = refreshing) } @@ -151,156 +125,53 @@ internal fun ProvidersModelsScreen( } } } - - item { - ProviderSectionLabel(title = "Model catalog") - } - - if (modelGroups.isEmpty()) { - item { - ModelCatalogEmpty( - title = if (refreshing) "Loading models" else "No models loaded", - body = if (isConnected) "Refresh after configuring a provider on the Gateway." else "Connect the Gateway to browse models.", - ) - } - } else { - items(modelGroups, key = { it.first }) { entry -> - val expanded = expandedModelProviders.contains(entry.first) - ModelGroup( - provider = entry.first, - models = entry.second, - expanded = expanded, - onToggle = { - expandedModelProviders = - if (expanded) { - expandedModelProviders - entry.first - } else { - expandedModelProviders + entry.first - } - }, - ) - } - } } - ProviderAddButton(onClick = onAddProvider, modifier = Modifier.align(Alignment.BottomCenter)) } } } -private data class ProviderSetupRow( - val id: String, - val name: String, - val subtitle: String, - val ready: Boolean, - val available: Boolean, - val statusLabel: String, - val warning: Boolean, -) - -private data class ProviderRow( +internal data class ProviderRow( val id: String, val name: String, val status: String, val ready: Boolean, - val available: Boolean, - val setupRequired: Boolean, - val warning: Boolean, val modelCount: Int, ) -/** Combines auth-provider readiness rows with catalog-only browse providers. */ -private fun providerRows( +/** Combines gateway auth-provider readiness with configured model providers. */ +internal fun providerRows( providers: List, models: List, ): List { val modelCounts = models.groupingBy { it.provider }.eachCount() - val availableProviderIds = - models - .filter(::modelAvailabilityUsable) - .map { it.provider.normalizedProviderId() } - .toSet() val authRows = - providers.map { provider -> - val providerId = provider.id.normalizedProviderId() - val authReady = modelProviderReady(provider.status) - val expiring = modelProviderExpiring(provider.status) - val available = providerId in availableProviderIds - ProviderRow( - id = provider.id, - name = provider.displayName, - status = - when { - authReady -> "Ready" - expiring -> "Expiring" - available -> "Available" - else -> "Needs setup" - }, - ready = authReady, - available = available || authReady || expiring, - setupRequired = !authReady && !available && !expiring, - warning = expiring, - modelCount = modelCounts[provider.id] ?: 0, - ) - } - // Catalog-only providers can be browsed but are not a readiness signal. - val missingAuthRows = - modelCounts.keys - .filter { provider -> authRows.none { it.id == provider } } + providers + .map { provider -> + val ready = modelProviderReady(provider.status) + ProviderRow( + id = provider.id, + name = provider.displayName, + status = if (ready) "Ready" else "Needs attention", + ready = ready, + modelCount = modelCounts[provider.id] ?: 0, + ) + } + val authProviderIds = authRows.mapTo(mutableSetOf()) { it.id.trim().lowercase() } + val configuredModelRows = + modelCounts.keys + .filter { provider -> provider.trim().lowercase() !in authProviderIds } .map { provider -> - val available = provider.normalizedProviderId() in availableProviderIds ProviderRow( id = provider, name = providerDisplayName(provider), - status = if (available) "Available" else "Catalog", - ready = available, - available = available, - setupRequired = false, - warning = false, + status = "Ready", + ready = true, modelCount = modelCounts[provider] ?: 0, ) } - return (authRows + missingAuthRows).sortedWith(compareBy(::providerPriority, { it.name.lowercase() })) + return (authRows + configuredModelRows).sortedWith(compareBy(::providerPriority, { it.name.lowercase() })) } -private fun providerSetupRows(providerRows: List): List { - val byId = providerRows.associateBy { it.id.trim().lowercase() } - return listOf("openai", "anthropic", "google", "openrouter", "ollama").map { id -> - val row = byId[id] ?: byId["ollama-local"].takeIf { id == "ollama" } - ProviderSetupRow( - id = id, - name = providerDisplayName(id), - subtitle = providerSetupSubtitle(id, row), - ready = row?.ready == true, - available = row?.available == true, - statusLabel = providerSetupStatusLabel(row), - warning = row?.warning == true || row?.setupRequired == true || row == null, - ) - } -} - -private fun providerSetupSubtitle( - id: String, - row: ProviderRow?, -): String = - when { - row?.warning == true -> "Credential expires soon" - row?.ready == true -> if (row.modelCount > 0) "${row.modelCount} models available" else "Ready" - row?.available == true -> if (row.modelCount > 0) "${row.modelCount} models available" else "Available" - row?.setupRequired == true -> "Finish setup to use ${row.name}" - row != null && row.modelCount > 0 -> "${row.modelCount} catalog models" - id == "ollama" -> "Use models running on your network" - else -> "Add provider credentials on your Gateway" - } - -private fun providerSetupStatusLabel(row: ProviderRow?): String = - when { - row?.ready == true -> "Ready" - row?.warning == true -> "Expiring" - row?.available == true -> "Available" - row?.setupRequired == false -> "Catalog" - else -> "Setup" - } - /** Normalizes gateway provider status strings into a ready/not-ready boolean. */ internal fun modelProviderReady(status: String): Boolean { val normalized = status.trim().lowercase() @@ -311,38 +182,6 @@ internal fun modelProviderReady(status: String): Boolean { normalized == "static" } -private fun modelProviderExpiring(status: String): Boolean = status.trim().lowercase() == "expiring" - -internal fun readyModelProviderCount( - providers: List, - models: List, -): Int { - val authReadyProviders = providers.filter { modelProviderReady(it.status) }.map { it.id.normalizedProviderId() } - val availableModelProviders = models.filter(::modelAvailabilityUsable).map { it.provider.normalizedProviderId() } - return (authReadyProviders + availableModelProviders).distinct().size -} - -// Older gateways did not emit `available`; keep those rows on the legacy -// readiness path while still honoring explicit false from upgraded gateways. -internal fun modelAvailabilityUsable(model: GatewayModelSummary): Boolean = model.available != false - -internal fun expiringModelProviderCount(providers: List): Int = - providers - .filter { modelProviderExpiring(it.status) } - .map { it.id.normalizedProviderId() } - .distinct() - .size - -private fun String.normalizedProviderId(): String = trim().lowercase() - -/** Groups models by provider using the same display priority as provider rows. */ -private fun sortedModelGroups(models: List): List>> = - models - .groupBy { it.provider } - .entries - .sortedWith(compareBy({ providerPriority(it.key) }, { providerDisplayName(it.key).lowercase() })) - .map { it.key to it.value } - private fun providerPriority(row: ProviderRow): Int = providerPriority(row.id) private fun providerPriority(provider: String): Int = @@ -370,9 +209,6 @@ private fun ProviderList( name = "Provider catalog", status = if (refreshing) "Loading" else "No providers", ready = false, - available = false, - setupRequired = false, - warning = false, modelCount = 0, ), ) @@ -396,26 +232,22 @@ private fun ProviderOverviewPanel( modelCount: Int, refreshing: Boolean, onRefresh: () -> Unit, - onSetup: () -> Unit, ) { - val readyCount = providerRows.count { it.available } - val needsSetupCount = providerRows.count { it.setupRequired } + val readyCount = providerRows.count { it.ready } + val needsSetupCount = providerRows.count { !it.ready } ClawPanel(contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - ProviderMetricTile(label = "Available", value = readyCount.toString(), modifier = Modifier.weight(1f)) + ProviderMetricTile(label = "Ready", value = readyCount.toString(), modifier = Modifier.weight(1f)) ProviderMetricTile(label = "Models", value = modelCount.toString(), modifier = Modifier.weight(1f)) - ProviderMetricTile(label = "Setup", value = needsSetupCount.toString(), modifier = Modifier.weight(1f)) + ProviderMetricTile(label = "Needs", value = needsSetupCount.toString(), modifier = Modifier.weight(1f)) } Text( - text = if (isConnected) "Choose a provider below, then finish credentials on your Gateway." else "Connect your Gateway before adding model providers.", + text = if (isConnected) "Refresh to recheck provider readiness from your Gateway." else "Connect your Gateway to view provider readiness.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted, ) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - ClawSecondaryButton(text = if (refreshing) "Refreshing" else "Refresh", onClick = onRefresh, enabled = isConnected && !refreshing, modifier = Modifier.weight(1f)) - ClawPrimaryButton(text = "Setup Provider", onClick = onSetup, enabled = isConnected, modifier = Modifier.weight(1f)) - } + ClawSecondaryButton(text = if (refreshing) "Refreshing" else "Refresh", onClick = onRefresh, enabled = isConnected && !refreshing, modifier = Modifier.fillMaxWidth()) } } } @@ -440,70 +272,16 @@ private fun ProviderMetricTile( } } -@Composable -private fun ProviderSetupList( - rows: List, - onSetup: () -> Unit, -) { - ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) { - Column { - rows.forEachIndexed { index, row -> - ProviderSetupListRow(row = row, onClick = onSetup) - if (index != rows.lastIndex) { - HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp) - } - } - } - } -} - -@Composable -private fun ProviderSetupListRow( - row: ProviderSetupRow, - onClick: () -> Unit, -) { - Surface(onClick = onClick, color = Color.Transparent, contentColor = ClawTheme.colors.text) { - Row( - modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(horizontal = 10.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - ProviderBadge(text = row.name) - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { - Text(text = row.name, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1) - Text(text = row.subtitle, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - val statusColor = - when { - row.warning -> ClawTheme.colors.warning - row.ready || row.available -> ClawTheme.colors.success - else -> ClawTheme.colors.textMuted - } - Box(modifier = Modifier.size(5.dp).clip(CircleShape).background(statusColor)) - Text(text = row.statusLabel, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1) - Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Open ${row.name}", modifier = Modifier.size(17.dp), tint = ClawTheme.colors.text) - } - } - } -} - @Composable private fun ProviderListRow(row: ProviderRow) { Row(modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { ProviderBadge(text = row.name) Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { Text(text = row.name, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1) - Text(text = if (row.modelCount > 0) "${row.modelCount} models" else "Provider setup", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1) + Text(text = if (row.modelCount > 0) "${row.modelCount} models" else "No configured models", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1) } Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { - val statusColor = - when { - row.warning || row.setupRequired -> ClawTheme.colors.warning - row.ready || row.available -> ClawTheme.colors.success - else -> ClawTheme.colors.textMuted - } - Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(statusColor)) + Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(if (row.ready) ClawTheme.colors.success else ClawTheme.colors.warning)) Text(text = row.status, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1) } } @@ -527,79 +305,6 @@ private fun providerInitials(value: String): String = .joinToString("") .ifBlank { "AI" } -@Composable -private fun ModelCatalogEmpty( - title: String, - body: String, -) { - ClawPanel(contentPadding = PaddingValues(horizontal = 11.dp, vertical = 10.dp)) { - Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text) - Text(text = body, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted) - } - } -} - -@Composable -private fun ModelGroup( - provider: String, - models: List, - expanded: Boolean, - onToggle: () -> Unit, -) { - ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) { - Column { - Surface(onClick = onToggle, color = Color.Transparent, contentColor = ClawTheme.colors.text) { - Row(modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - ProviderBadge(text = providerDisplayName(provider)) - Text(text = providerDisplayName(provider), style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1) - ProviderMiniTag(text = "${models.size} models") - Icon(imageVector = if (expanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = if (expanded) "Collapse ${providerDisplayName(provider)} models" else "Expand ${providerDisplayName(provider)} models", modifier = Modifier.size(14.dp), tint = ClawTheme.colors.textMuted) - } - } - HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp) - val visibleModels = if (expanded) models else models.take(3) - visibleModels.forEachIndexed { index, model -> - ModelRow(model) - if (index != visibleModels.lastIndex || models.size > visibleModels.size) { - HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp) - } - } - if (models.size > visibleModels.size) { - Surface(onClick = onToggle, color = Color.Transparent, contentColor = ClawTheme.colors.text) { - Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { - Text(text = "View all models", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, modifier = Modifier.weight(1f)) - Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "View all models", modifier = Modifier.size(14.dp), tint = ClawTheme.colors.text) - } - } - } - } - } -} - -@Composable -private fun ModelRow(model: GatewayModelSummary) { - val available = modelAvailabilityUsable(model) - Row(modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(horizontal = 10.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Text(text = model.name, style = ClawTheme.type.mono, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) - modelCapabilityLabels(model).take(3).forEach { label -> - ProviderMiniTag(text = label) - } - Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(if (available) ClawTheme.colors.success else ClawTheme.colors.warning)) - } -} - -/** Derives compact capability chips for model catalog rows. */ -private fun modelCapabilityLabels(model: GatewayModelSummary): List = - buildList { - if (model.supportsReasoning) add("Reasoning") - if (model.supportsVision) add("Vision") - if (model.supportsAudio) add("Voice") - if (model.supportsDocuments) add("Docs") - if ((model.contextTokens ?: 0L) >= 100_000L) add("Long context") - if (isEmpty()) add("Fast") - } - @Composable private fun ProviderSectionLabel(title: String) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { @@ -627,39 +332,3 @@ private fun ProviderHeaderIconButton( } } } - -@Composable -private fun ProviderAddButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Surface( - onClick = onClick, - modifier = modifier.fillMaxWidth().height(ClawTheme.spacing.touchTarget), - shape = RoundedCornerShape(ClawTheme.radii.pill), - color = ClawTheme.colors.primary, - contentColor = ClawTheme.colors.primaryText, - ) { - Row( - modifier = Modifier.fillMaxSize(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Icon(imageVector = Icons.Default.Add, contentDescription = null, modifier = Modifier.size(17.dp)) - Spacer(modifier = Modifier.width(7.dp)) - Text(text = "Open Gateway Setup", style = ClawTheme.type.label, maxLines = 1) - } - } -} - -@Composable -private fun ProviderMiniTag(text: String) { - Surface( - shape = RoundedCornerShape(5.dp), - color = Color.Transparent, - border = BorderStroke(1.dp, ClawTheme.colors.border), - contentColor = ClawTheme.colors.textMuted, - ) { - Text(text = text, modifier = Modifier.padding(horizontal = 4.dp, vertical = 0.5.dp), style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), maxLines = 1) - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt index 5a02374e4599..434ba8121309 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt @@ -217,7 +217,7 @@ private fun SessionRow( compact: Boolean, onClick: () -> Unit, ) { - Surface(onClick = onClick, color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) { + Surface(onClick = onClick, color = Color.Transparent, contentColor = ClawTheme.colors.text) { Column { Row( modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(vertical = 5.dp), diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt index fdba96f193a0..582fde4f31fd 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt @@ -1,5 +1,6 @@ package ai.openclaw.app.ui +import ai.openclaw.app.AppearanceThemeMode import ai.openclaw.app.BuildConfig import ai.openclaw.app.GatewayAgentSummary import ai.openclaw.app.GatewayCronJobSummary @@ -8,7 +9,6 @@ import ai.openclaw.app.LocationMode import ai.openclaw.app.MainViewModel import ai.openclaw.app.NotificationPackageFilterMode import ai.openclaw.app.chat.ChatPendingToolCall -import ai.openclaw.app.gateway.GatewayEndpoint import ai.openclaw.app.node.DeviceNotificationListenerService import ai.openclaw.app.ui.design.ClawDetailRow import ai.openclaw.app.ui.design.ClawIconBadge @@ -147,7 +147,7 @@ internal fun SettingsDetailScreen( SettingsRoute.Notifications -> NotificationSettingsScreen(viewModel = viewModel, onBack = onBack) SettingsRoute.PhoneCapabilities -> PhoneCapabilitiesScreen(viewModel = viewModel, onBack = onBack) SettingsRoute.Gateway -> GatewaySettingsScreen(viewModel = viewModel, onBack = onBack) - SettingsRoute.Appearance -> AppearanceSettingsScreen(onBack = onBack) + SettingsRoute.Appearance -> AppearanceSettingsScreen(viewModel = viewModel, onBack = onBack) SettingsRoute.Health -> HealthLogsSettingsScreen(viewModel = viewModel, onBack = onBack) SettingsRoute.About -> AboutSettingsScreen(viewModel = viewModel, onBack = onBack) } @@ -897,18 +897,14 @@ private fun GatewaySettingsScreen( .orEmpty() .ifEmpty { passwordInput.trim() } validationText = null - viewModel.setManualEnabled(true) - viewModel.setManualHost(endpointConfig.host) - viewModel.setManualPort(endpointConfig.port) - viewModel.setManualTls(endpointConfig.tls) - viewModel.setGatewayBootstrapToken(bootstrapToken) - viewModel.setGatewayToken(token) - viewModel.setGatewayPassword(password) - viewModel.connect( - GatewayEndpoint.manual(host = endpointConfig.host, port = endpointConfig.port), - token = token.ifEmpty { null }, - bootstrapToken = bootstrapToken.ifEmpty { null }, - password = password.ifEmpty { null }, + viewModel.saveGatewayConfigAndConnect( + host = endpointConfig.host, + port = endpointConfig.port, + tls = endpointConfig.tls, + token = token, + bootstrapToken = bootstrapToken, + password = password, + resetSetupAuth = setup != null, ) }, modifier = Modifier.fillMaxWidth(), @@ -919,22 +915,40 @@ private fun GatewaySettingsScreen( } @Composable -private fun AppearanceSettingsScreen(onBack: () -> Unit) { +private fun AppearanceSettingsScreen( + viewModel: MainViewModel, + onBack: () -> Unit, +) { + val themeMode by viewModel.appearanceThemeMode.collectAsState() + SettingsDetailFrame(title = "Appearance", subtitle = "A calm, high-contrast OpenClaw interface.", icon = Icons.Default.Palette, onBack = onBack) { SettingsMetricPanel( rows = listOf( - SettingsMetric("Theme", "Dark"), + SettingsMetric("Theme", appearanceThemeSummary(themeMode)), SettingsMetric("Contrast", "High"), SettingsMetric("Typography", "Readable"), ), ) ClawPanel { - Text(text = "OpenClaw uses a fixed premium dark theme so it stays consistent across devices.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(text = "Theme", style = ClawTheme.type.section, color = ClawTheme.colors.text) + ClawSegmentedControl( + options = appearanceThemeOptions(), + selected = appearanceThemeSummary(themeMode), + onSelect = { selected -> viewModel.setAppearanceThemeMode(appearanceThemeModeForLabel(selected)) }, + ) + } } } } +internal fun appearanceThemeSummary(mode: AppearanceThemeMode): String = mode.displayLabel + +internal fun appearanceThemeOptions(): List = AppearanceThemeMode.entries.map { it.displayLabel } + +internal fun appearanceThemeModeForLabel(label: String): AppearanceThemeMode = AppearanceThemeMode.fromDisplayLabel(label) + /** Converts raw gateway connection text into stable settings metric labels. */ private fun gatewayStatusLabel( statusText: String, @@ -971,7 +985,7 @@ private fun AboutSettingsScreen( listOf( SettingsMetric("Android App", BuildConfig.VERSION_NAME), SettingsMetric("Build", BuildConfig.VERSION_CODE.toString()), - SettingsMetric("Channel", "Play"), + SettingsMetric("Channel", androidDistributionChannel()), SettingsMetric("Gateway", currentGatewayVersion ?: "Not connected"), ), ) @@ -994,6 +1008,14 @@ private fun AboutSettingsScreen( } } +internal fun androidDistributionChannel(flavor: String = BuildConfig.FLAVOR): String = + when (flavor.trim()) { + "play" -> "Play" + "thirdParty" -> "Third-party" + "" -> "Unknown" + else -> flavor.trim() + } + @Composable private fun AboutStatusRow( title: String, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt index b892e9a0113c..b966a4d06d74 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt @@ -22,6 +22,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -103,7 +104,10 @@ private val shellNavTabs = listOf(Tab.Overview, Tab.Chat, Tab.Voice, Tab.Setting private val shellContentInsets: WindowInsets @Composable get() = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) -internal fun shellBottomNavVisible(keyboardVisible: Boolean, commandOpen: Boolean): Boolean = !keyboardVisible && !commandOpen +internal fun shellBottomNavVisible( + keyboardVisible: Boolean, + commandOpen: Boolean, +): Boolean = !keyboardVisible && !commandOpen /** Main post-onboarding shell that owns top-level Android navigation state. */ @Composable @@ -111,13 +115,18 @@ fun ShellScreen( viewModel: MainViewModel, modifier: Modifier = Modifier, ) { - ClawDesignTheme { + val appearanceThemeMode by viewModel.appearanceThemeMode.collectAsState() + val shellDark = appearanceThemeMode.isDark(systemDark = isSystemInDarkTheme()) + OpenClawSystemBarAppearance(lightAppearance = !shellDark) + ClawDesignTheme(dark = shellDark) { var activeTab by rememberSaveable { mutableStateOf(Tab.Overview) } var settingsRoute by rememberSaveable { mutableStateOf(SettingsRoute.Home) } var returnToOverviewFromSettings by rememberSaveable { mutableStateOf(false) } var commandOpen by rememberSaveable { mutableStateOf(false) } + var voiceScreenWasActive by rememberSaveable { mutableStateOf(false) } val requestedHomeDestination by viewModel.requestedHomeDestination.collectAsState() val pendingTrust by viewModel.pendingGatewayTrust.collectAsState() + val runtimeInitialized by viewModel.runtimeInitialized.collectAsState() LaunchedEffect(requestedHomeDestination) { val destination = requestedHomeDestination ?: return@LaunchedEffect @@ -138,8 +147,12 @@ fun ShellScreen( viewModel.clearRequestedHomeDestination() } - LaunchedEffect(activeTab) { - viewModel.setVoiceScreenActive(activeTab == Tab.Voice) + LaunchedEffect(activeTab, runtimeInitialized) { + val voiceScreenActive = activeTab == Tab.Voice + if (voiceScreenActive || voiceScreenWasActive || runtimeInitialized) { + viewModel.setVoiceScreenActive(voiceScreenActive) + } + voiceScreenWasActive = voiceScreenActive } BackHandler(enabled = activeTab != Tab.Overview) { @@ -213,11 +226,6 @@ fun ShellScreen( ProvidersModelsScreen( viewModel = viewModel, onBack = { activeTab = Tab.Overview }, - onAddProvider = { - settingsRoute = SettingsRoute.Gateway - returnToOverviewFromSettings = false - activeTab = Tab.Settings - }, ) Tab.Sessions -> SessionsScreen( @@ -342,8 +350,7 @@ private fun OverviewScreen( val cronStatus by viewModel.cronStatus.collectAsState() val nodesDevicesSummary by viewModel.nodesDevicesSummary.collectAsState() val channelsSummary by viewModel.channelsSummary.collectAsState() - val readyProviderCount = readyModelProviderCount(providers, models) - val expiringProviderCount = expiringModelProviderCount(providers) + val readyProviderCount = providerRows(providers = providers, models = models).count { it.ready } val attentionRows = homeAttentionRows( isConnected = isConnected, @@ -351,7 +358,6 @@ private fun OverviewScreen( channelsSummary = channelsSummary, nodesDevicesSummary = nodesDevicesSummary, readyProviderCount = readyProviderCount, - expiringProviderCount = expiringProviderCount, ) LaunchedEffect(isConnected) { @@ -457,14 +463,12 @@ private fun OverviewScreen( ModuleRow("Sessions", "Conversation history", if (sessions.isEmpty()) "Empty" else "${sessions.size} recent", Icons.Outlined.AccessTime, Tab.Sessions), ModuleRow( title = "Providers & Models", - subtitle = "Model setup", + subtitle = "Provider readiness", metadata = when { !isConnected -> "Offline" readyProviderCount > 0 -> "$readyProviderCount ready" - expiringProviderCount > 0 -> "$expiringProviderCount expiring" - models.isNotEmpty() -> "${models.size} models" - else -> "Setup" + else -> "No ready" }, icon = Icons.Outlined.Inventory2, tab = Tab.ProvidersModels, @@ -567,13 +571,8 @@ internal fun homeAttentionRows( } else { null }, - if (isConnected && expiringProviderCount > 0) { - HomeAttentionRow("Providers", "Provider auth expires soon", Icons.Outlined.Inventory2, Tab.ProvidersModels) - } else { - null - }, - if (isConnected && readyProviderCount == 0 && expiringProviderCount == 0) { - HomeAttentionRow("Providers", "No ready providers", Icons.Outlined.Inventory2, Tab.ProvidersModels) + if (isConnected && readyProviderCount == 0) { + HomeAttentionRow("Providers", "No ready providers", Icons.Outlined.Inventory2, Tab.Settings, SettingsRoute.Gateway) } else { null }, @@ -756,7 +755,7 @@ private fun RecentSessionRowContent( metadata: String, onClick: () -> Unit, ) { - Surface(color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) { + Surface(color = Color.Transparent, contentColor = ClawTheme.colors.text) { Row( modifier = Modifier @@ -854,6 +853,7 @@ private fun SettingsShellScreen( val nodesDevicesSummary by viewModel.nodesDevicesSummary.collectAsState() val channelsSummary by viewModel.channelsSummary.collectAsState() val dreamingSummary by viewModel.dreamingSummary.collectAsState() + val appearanceThemeMode by viewModel.appearanceThemeMode.collectAsState() LaunchedEffect(isConnected) { if (isConnected) { @@ -915,7 +915,7 @@ private fun SettingsShellScreen( SettingsRow("Notifications", if (notificationForwardingEnabled) "Smart delivery" else "Off", Icons.Default.Notifications, route = SettingsRoute.Notifications), SettingsRow("Phone Capabilities", if (cameraEnabled) "Camera enabled" else "Locked", Icons.Default.Lock, status = !cameraEnabled, route = SettingsRoute.PhoneCapabilities), SettingsRow("Gateway", gatewaySummary(statusText, isConnected), Icons.Default.Cloud, status = isConnected, route = SettingsRoute.Gateway), - SettingsRow("Appearance", "Dark", Icons.Default.Palette, route = SettingsRoute.Appearance), + SettingsRow("Appearance", appearanceThemeSummary(appearanceThemeMode), Icons.Default.Palette, route = SettingsRoute.Appearance), SettingsRow("Health", "Diagnostics", Icons.Default.Settings, status = isConnected, route = SettingsRoute.Health), SettingsRow("About", "Version and update", Icons.Default.Storage, route = SettingsRoute.About), ), diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt index 87dba896dc25..3464b3e8a3eb 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt @@ -97,6 +97,7 @@ fun VoiceScreen( val talkModeEnabled by viewModel.talkModeEnabled.collectAsState() val talkModeListening by viewModel.talkModeListening.collectAsState() val talkModeSpeaking by viewModel.talkModeSpeaking.collectAsState() + val talkModeStatusText by viewModel.talkModeStatusText.collectAsState() val talkModeConversation by viewModel.talkModeConversation.collectAsState() var pendingAction by remember { mutableStateOf(null) } @@ -119,6 +120,16 @@ fun VoiceScreen( val activeConversation = if (voiceCaptureMode == VoiceCaptureMode.TalkMode) talkModeConversation else micConversation val voiceActive = micEnabled || micIsSending || talkModeEnabled val gatewayReady = gatewayStatus.isVoiceGatewayReady() + val voiceAttentionStatus = + voiceAttentionStatus( + talkModeStatusText = talkModeStatusText, + voiceCaptureMode = voiceCaptureMode, + micEnabled = micEnabled, + micIsSending = micIsSending, + talkModeEnabled = talkModeEnabled, + talkModeListening = talkModeListening, + talkModeSpeaking = talkModeSpeaking, + ) val activeStatus = voiceStatusLabel( gatewayStatus = gatewayStatus, @@ -128,6 +139,7 @@ fun VoiceScreen( micIsSending = micIsSending, talkModeListening = talkModeListening, talkModeSpeaking = talkModeSpeaking, + voiceAttentionStatus = voiceAttentionStatus, ) if (talkModeEnabled) { @@ -169,7 +181,7 @@ fun VoiceScreen( verticalArrangement = Arrangement.spacedBy(10.dp), ) { VoiceHeader( - statusText = if (voiceActive || !gatewayReady) activeStatus else "Your voice command center.", + statusText = voiceAttentionStatus ?: if (voiceActive || !gatewayReady) activeStatus else "Your voice command center.", speakerEnabled = speakerEnabled, onToggleSpeaker = { viewModel.setSpeakerEnabled(!speakerEnabled) }, onOpenCommand = onOpenCommand, @@ -184,6 +196,7 @@ fun VoiceScreen( talkModeSpeaking = talkModeSpeaking, micLiveTranscript = micLiveTranscript, gatewayReady = gatewayReady, + voiceAttentionStatus = voiceAttentionStatus, onStartTalk = { runVoiceAction( action = VoiceAction.Talk, @@ -242,7 +255,9 @@ private fun DictationScreen( ) { val lastUserText = conversation.lastOrNull { it.role == VoiceConversationRole.User }?.text val draftText = liveTranscript?.takeIf { it.isNotBlank() } ?: lastUserText.orEmpty() - val speechProviderReady = gatewayStatus.isVoiceGatewayReady() + val providerAttentionStatus = voiceRuntimeAttentionStatus(statusText) + val displayStatusText = providerAttentionStatus ?: statusText + val speechProviderReady = providerAttentionStatus == null && gatewayStatus.isVoiceGatewayReady() Column( modifier = Modifier @@ -278,7 +293,7 @@ private fun DictationScreen( DictationWaveform(active = listening || sending) Row(horizontalArrangement = Arrangement.spacedBy(7.dp), verticalAlignment = Alignment.CenterVertically) { Icon(imageVector = Icons.Default.Mic, contentDescription = null, modifier = Modifier.size(15.dp), tint = if (listening) ClawTheme.colors.success else ClawTheme.colors.textMuted) - Text(text = statusText, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) + Text(text = displayStatusText, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) } } } @@ -298,13 +313,20 @@ private fun DictationScreen( } Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text(text = "Speech provider", style = ClawTheme.type.section, color = ClawTheme.colors.text) - Text(text = gatewayStatus.voiceGatewayLabel(), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + text = providerAttentionStatus ?: gatewayStatus.voiceGatewayLabel(), + style = ClawTheme.type.body, + color = ClawTheme.colors.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { Text( text = when { sending -> "Sending" + providerAttentionStatus != null -> "Attention" speechProviderReady -> "Ready" else -> "Offline" }, @@ -312,6 +334,7 @@ private fun DictationScreen( color = when { sending -> ClawTheme.colors.warning + providerAttentionStatus != null -> ClawTheme.colors.warning speechProviderReady -> ClawTheme.colors.success else -> ClawTheme.colors.textMuted }, @@ -324,6 +347,7 @@ private fun DictationScreen( .background( when { sending -> ClawTheme.colors.warning + providerAttentionStatus != null -> ClawTheme.colors.warning speechProviderReady -> ClawTheme.colors.success else -> ClawTheme.colors.textSubtle }, @@ -594,6 +618,7 @@ private fun VoiceHero( talkModeSpeaking: Boolean, micLiveTranscript: String?, gatewayReady: Boolean, + voiceAttentionStatus: String?, onStartTalk: () -> Unit, onStartDictation: () -> Unit, onConnectGateway: () -> Unit, @@ -616,6 +641,7 @@ private fun VoiceHero( Text( text = when { + voiceAttentionStatus != null -> voiceAttentionStatus talkModeSpeaking -> "OpenClaw is replying" talkModeListening -> "Listening" talkModeEnabled -> "Talk is live" @@ -672,7 +698,7 @@ private fun VoiceHero( ) } - VoiceProviderCard(gatewayStatus = gatewayStatus) + VoiceProviderCard(gatewayStatus = gatewayStatus, voiceAttentionStatus = voiceAttentionStatus) VoicePrimaryAction( text = @@ -734,8 +760,11 @@ private fun VoiceModeRow( } @Composable -private fun VoiceProviderCard(gatewayStatus: String) { - val ready = gatewayStatus.isVoiceGatewayReady() +private fun VoiceProviderCard( + gatewayStatus: String, + voiceAttentionStatus: String?, +) { + val ready = voiceAttentionStatus == null && gatewayStatus.isVoiceGatewayReady() Surface( modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp), shape = RoundedCornerShape(ClawTheme.radii.panel), @@ -761,7 +790,13 @@ private fun VoiceProviderCard(gatewayStatus: String) { } Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text(text = "Provider", style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1) - Text(text = gatewayStatus.voiceGatewayLabel(), style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + text = voiceAttentionStatus ?: gatewayStatus.voiceGatewayLabel(), + style = ClawTheme.type.caption, + color = ClawTheme.colors.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) { Box( @@ -769,9 +804,25 @@ private fun VoiceProviderCard(gatewayStatus: String) { Modifier .size(7.dp) .clip(CircleShape) - .background(if (ready) ClawTheme.colors.success else ClawTheme.colors.textSubtle), + .background( + when { + ready -> ClawTheme.colors.success + voiceAttentionStatus != null -> ClawTheme.colors.warning + else -> ClawTheme.colors.textSubtle + }, + ), + ) + Text( + text = + when { + ready -> "Ready" + voiceAttentionStatus != null -> "Attention" + else -> "Offline" + }, + style = ClawTheme.type.caption, + color = ClawTheme.colors.textMuted, + maxLines = 1, ) - Text(text = if (ready) "Ready" else "Offline", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1) } } } @@ -968,7 +1019,7 @@ private fun runVoiceAction( } } -private fun voiceStatusLabel( +internal fun voiceStatusLabel( gatewayStatus: String, voiceCaptureMode: VoiceCaptureMode, micStatusText: String, @@ -976,8 +1027,10 @@ private fun voiceStatusLabel( micIsSending: Boolean, talkModeListening: Boolean, talkModeSpeaking: Boolean, + voiceAttentionStatus: String?, ): String = when { + voiceAttentionStatus != null -> voiceAttentionStatus voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeSpeaking -> "OpenClaw is speaking" voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeListening -> "Listening" voiceCaptureMode == VoiceCaptureMode.TalkMode -> "Talk is live" @@ -988,6 +1041,69 @@ private fun voiceStatusLabel( else -> "Ready to talk" } +internal fun voiceAttentionStatus( + talkModeStatusText: String, + voiceCaptureMode: VoiceCaptureMode, + micEnabled: Boolean, + micIsSending: Boolean, + talkModeEnabled: Boolean, + talkModeListening: Boolean, + talkModeSpeaking: Boolean, +): String? { + if (voiceCaptureMode != VoiceCaptureMode.Off || micEnabled || micIsSending) return null + if (talkModeEnabled || talkModeListening || talkModeSpeaking) return null + val status = talkModeStatusText.trim() + if (status.isBlank()) return null + val lower = status.lowercase() + if (lower == "off" || lower == "ready" || lower == "listening" || lower == "connecting…") return null + return status + .takeIf { + lower.contains("failed") || + lower.contains("unavailable") || + lower.contains("permission required") || + lower.contains("not connected") || + lower.contains("error") + }?.let(::userFacingVoiceAttentionStatus) +} + +internal fun voiceRuntimeAttentionStatus(statusText: String): String? { + val status = statusText.trim() + if (status.isBlank()) return null + val lower = status.lowercase() + return status + .takeIf { + lower.contains("transcription unavailable") || + lower.contains("provider unavailable") || + (lower.contains("provider") && lower.contains("not configured")) || + lower.contains("no realtime transcription provider") || + lower.contains("failed") + }?.let(::userFacingVoiceAttentionStatus) +} + +private fun userFacingVoiceAttentionStatus(status: String): String { + val normalized = + status + .removePrefix("Start failed:") + .trim() + .removePrefix("Transcription unavailable:") + .trim() + .removePrefix("UNAVAILABLE:") + .trim() + .removePrefix("Error:") + .trim() + val lower = normalized.lowercase() + if (lower.contains("realtime voice provider") && lower.contains("not configured")) { + return "Realtime voice provider is not configured." + } + if (lower.contains("no realtime transcription provider")) { + return "Realtime transcription provider is not configured." + } + if (lower.contains("microphone permission required")) { + return "Microphone permission is required." + } + return if (normalized.length <= 90) normalized else "${normalized.take(87)}..." +} + private fun String.isVoiceGatewayReady(): Boolean { val status = lowercase() return !status.contains("offline") && !status.contains("not connected") && !status.contains("failed") && !status.contains("error") diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt index aa352f95296c..4ca9db9bcbc3 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageListCard.kt @@ -15,7 +15,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator @@ -40,17 +40,19 @@ fun ChatMessageListCard( modifier: Modifier = Modifier, ) { val listState = rememberLazyListState() - val displayMessages = remember(messages) { messages.asReversed() } - val stream = streamingAssistantText?.trim() + val timeline = + remember(messages, pendingRunCount, pendingToolCalls, streamingAssistantText) { + buildChatTimeline( + messages = messages, + pendingRunCount = pendingRunCount, + pendingToolCalls = pendingToolCalls, + streamingAssistantText = streamingAssistantText, + ) + } - // New list items/tool rows should animate into view, but token streaming should not restart - // that animation on every delta. - LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size) { - listState.animateScrollToItem(index = 0) - } - LaunchedEffect(stream) { - if (!stream.isNullOrEmpty()) { - listState.scrollToItem(index = 0) + LaunchedEffect(timeline.scrollTargetIndex, timeline.items.size, pendingRunCount, pendingToolCalls.size) { + timeline.scrollTargetIndex?.let { index -> + listState.animateScrollToItem(index = index) } } @@ -64,32 +66,17 @@ fun ChatMessageListCard( androidx.compose.foundation.layout .PaddingValues(bottom = 8.dp), ) { - // With reverseLayout = true, index 0 renders at the BOTTOM. - // So we emit newest items first: streaming → tools → typing → messages (newest→oldest). - if (!stream.isNullOrEmpty()) { - item(key = "stream") { - ChatStreamingAssistantBubble(text = stream) + itemsIndexed(items = timeline.items, key = { _, item -> chatTimelineItemKey(item) }) { _, item -> + when (item) { + is ChatTimelineItem.Message -> ChatMessageBubble(message = item.message) + is ChatTimelineItem.PendingTools -> ChatPendingToolsBubble(toolCalls = item.toolCalls) + is ChatTimelineItem.StreamingAssistant -> ChatStreamingAssistantBubble(text = item.text) + ChatTimelineItem.Thinking -> ChatTypingIndicatorBubble() } } - - if (pendingToolCalls.isNotEmpty()) { - item(key = "tools") { - ChatPendingToolsBubble(toolCalls = pendingToolCalls) - } - } - - if (pendingRunCount > 0) { - item(key = "typing") { - ChatTypingIndicatorBubble() - } - } - - items(items = displayMessages, key = { it.id }) { message -> - ChatMessageBubble(message = message) - } } - if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) { + if (timeline.items.isEmpty()) { if (historyLoading) { LoadingChatHint(modifier = Modifier.align(Alignment.Center)) } else { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt index d15d900dd9ca..29d192437864 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt @@ -31,7 +31,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape @@ -406,15 +406,19 @@ private fun ChatMessageList( modifier: Modifier = Modifier, ) { val listState = rememberLazyListState() - val displayMessages = remember(messages) { messages.asReversed() } - val stream = streamingAssistantText?.trim() + val timeline = + remember(messages, pendingRunCount, pendingToolCalls, streamingAssistantText) { + buildChatTimeline( + messages = messages, + pendingRunCount = pendingRunCount, + pendingToolCalls = pendingToolCalls, + streamingAssistantText = streamingAssistantText, + ) + } - LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size) { - listState.animateScrollToItem(index = 0) - } - LaunchedEffect(stream) { - if (!stream.isNullOrEmpty()) { - listState.scrollToItem(index = 0) + LaunchedEffect(timeline.scrollTargetIndex, timeline.items.size, pendingRunCount, pendingToolCalls.size) { + timeline.scrollTargetIndex?.let { index -> + listState.animateScrollToItem(index = index) } } @@ -426,30 +430,29 @@ private fun ChatMessageList( verticalArrangement = Arrangement.spacedBy(5.dp), contentPadding = PaddingValues(top = 6.dp, bottom = 3.dp), ) { - if (!stream.isNullOrEmpty()) { - item(key = "stream") { - ChatBubble(role = "assistant", live = true, content = listOf(ChatMessageContent(text = stream)), timestampMs = null) + itemsIndexed(items = timeline.items, key = { _, item -> chatTimelineItemKey(item) }) { _, item -> + when (item) { + is ChatTimelineItem.Message -> + ChatBubble( + role = item.message.role, + live = false, + content = item.message.content, + timestampMs = item.message.timestampMs, + ) + is ChatTimelineItem.PendingTools -> ToolBubble(toolCalls = item.toolCalls) + is ChatTimelineItem.StreamingAssistant -> + ChatBubble( + role = "assistant", + live = true, + content = listOf(ChatMessageContent(text = item.text)), + timestampMs = null, + ) + ChatTimelineItem.Thinking -> ChatThinkingBubble() } } - - if (pendingToolCalls.isNotEmpty()) { - item(key = "tools") { - ToolBubble(toolCalls = pendingToolCalls) - } - } - - if (pendingRunCount > 0) { - item(key = "thinking") { - ChatThinkingBubble() - } - } - - items(items = displayMessages, key = { it.id }) { message -> - ChatBubble(role = message.role, live = false, content = message.content, timestampMs = message.timestampMs) - } } - if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && stream.isNullOrBlank()) { + if (timeline.items.isEmpty()) { if (historyLoading) { ClawLoadingState(title = "Loading session", modifier = Modifier.align(Alignment.Center)) } else { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatTimeline.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatTimeline.kt new file mode 100644 index 000000000000..62bfac729858 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatTimeline.kt @@ -0,0 +1,69 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.chat.ChatMessage +import ai.openclaw.app.chat.ChatPendingToolCall + +internal sealed class ChatTimelineItem { + data class Message( + val message: ChatMessage, + ) : ChatTimelineItem() + + data class StreamingAssistant( + val text: String, + ) : ChatTimelineItem() + + data class PendingTools( + val toolCalls: List, + ) : ChatTimelineItem() + + object Thinking : ChatTimelineItem() +} + +internal data class ChatTimeline( + val items: List, + val scrollTargetIndex: Int?, +) + +internal fun buildChatTimeline( + messages: List, + pendingRunCount: Int, + pendingToolCalls: List, + streamingAssistantText: String?, +): ChatTimeline { + val stream = streamingAssistantText?.trim()?.takeIf { it.isNotEmpty() } + val hasActiveRun = pendingRunCount > 0 || pendingToolCalls.isNotEmpty() || stream != null + val items = + buildList { + if (stream != null) add(ChatTimelineItem.StreamingAssistant(stream)) + if (pendingToolCalls.isNotEmpty()) add(ChatTimelineItem.PendingTools(pendingToolCalls)) + if (pendingRunCount > 0) add(ChatTimelineItem.Thinking) + messages.asReversed().forEach { message -> add(ChatTimelineItem.Message(message)) } + } + if (items.isEmpty()) return ChatTimeline(items = items, scrollTargetIndex = null) + + // In reverseLayout, index 0 is bottom-most. During an active run, keep the prompt + // anchored so streaming/tool rows do not immediately push the just-sent message away. + val activePromptIndex = + if (hasActiveRun) { + items.indexOfFirst { item -> + item is ChatTimelineItem.Message && + item.message.role + .trim() + .equals("user", ignoreCase = true) + } + } else { + -1 + } + return ChatTimeline( + items = items, + scrollTargetIndex = activePromptIndex.takeIf { it >= 0 } ?: 0, + ) +} + +internal fun chatTimelineItemKey(item: ChatTimelineItem): String = + when (item) { + is ChatTimelineItem.Message -> "message:${item.message.id}" + is ChatTimelineItem.PendingTools -> "tools" + is ChatTimelineItem.StreamingAssistant -> "stream" + ChatTimelineItem.Thinking -> "thinking" + } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/SessionFilters.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/SessionFilters.kt index f4c7c0aacec8..0725cdd27ed1 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/SessionFilters.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/SessionFilters.kt @@ -82,7 +82,12 @@ fun resolveCompactSessionChoices( ) val mainKey = mainSessionKey.trim().ifEmpty { "main" } val current = currentSessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it } - val pinnedRank = listOf(mainKey, current).filter { it.isNotBlank() }.distinct().withIndex().associate { it.value to it.index } + val pinnedRank = + listOf(mainKey, current) + .filter { it.isNotBlank() } + .distinct() + .withIndex() + .associate { it.value to it.index } val unpinnedRank = pinnedRank.size return allChoices diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawTheme.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawTheme.kt index 9db3184e2b01..46a7e0085d46 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawTheme.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawTheme.kt @@ -1,5 +1,8 @@ package ai.openclaw.app.ui.design +import ai.openclaw.app.ui.LocalMobileColors +import ai.openclaw.app.ui.darkMobileColors +import ai.openclaw.app.ui.lightMobileColors import ai.openclaw.app.ui.mobileFontFamily import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme @@ -110,22 +113,22 @@ private val ClawDarkColors = private val ClawLightColors = ClawColors( - canvas = Color(0xFFF7F7F7), - surface = Color(0xFFFFFFFF), + canvas = Color(0xFFFAFBFC), + surface = Color(0xFFFFFEFB), surfaceRaised = Color(0xFFFFFFFF), - surfacePressed = Color(0xFFEDEDED), - border = Color(0xFFE0E0E0), - borderStrong = Color(0xFFBDBDBD), - text = Color(0xFF070707), - textMuted = Color(0xFF595959), - textSubtle = Color(0xFF8A8A8A), - primary = Color(0xFF050505), + surfacePressed = Color(0xFFE9EDF3), + border = Color(0xFFDDE3EC), + borderStrong = Color(0xFFC7D0DC), + text = Color(0xFF111318), + textMuted = Color(0xFF505865), + textSubtle = Color(0xFF8993A2), + primary = Color(0xFF111827), primaryText = Color(0xFFFFFFFF), - success = Color(0xFF157A3E), - successSoft = Color(0xFFEAF8EF), - warning = Color(0xFF9A6A12), - warningSoft = Color(0xFFFFF5DD), - danger = Color(0xFFB42323), + success = Color(0xFF217747), + successSoft = Color(0xFFE9F7EF), + warning = Color(0xFFA56F17), + warningSoft = Color(0xFFFFF3DC), + danger = Color(0xFFB82929), dangerSoft = Color(0xFFFFE9E9), ) @@ -168,10 +171,12 @@ internal fun ClawDesignTheme( content: @Composable () -> Unit, ) { val colors = if (dark) ClawDarkColors else ClawLightColors + val mobileColors = if (dark) darkMobileColors() else lightMobileColors() val typography = clawTypography(mobileFontFamily) CompositionLocalProvider( LocalClawColors provides colors, + LocalMobileColors provides mobileColors, LocalClawSpacing provides ClawSpacing(), LocalClawRadii provides ClawRadii(), LocalClawTypography provides typography, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt index 9124e86713ad..a7e4fcfa63e6 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt @@ -104,6 +104,7 @@ class MicCaptureManager( private val messageQueue = ArrayDeque() private val messageQueueLock = Any() private var flushedPartialTranscript: String? = null + // Correlates chat events with the idempotency key generated before sendChat returns. private var pendingRunId: String? = null private var pendingAssistantEntryId: String? = null diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt index f08bb0e65e66..022965c1a1da 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt @@ -168,6 +168,7 @@ class TalkModeManager internal constructor( @Volatile private var realtimeSessionId: String? = null private var realtimeCaptureJob: Job? = null private var realtimeAppendJob: Job? = null + // Realtime tool calls can complete before their chat final arrives; cache by call/run id until both sides meet. private val realtimeToolRuns = LinkedHashMap() private val pendingRealtimeToolCalls = LinkedHashSet() diff --git a/apps/android/app/src/main/res/drawable/openclaw_logo.xml b/apps/android/app/src/main/res/drawable/openclaw_logo.xml new file mode 100644 index 000000000000..2f3a476e2a67 --- /dev/null +++ b/apps/android/app/src/main/res/drawable/openclaw_logo.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt index 4d1ab00c912a..2316d8ddfda7 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt @@ -294,6 +294,38 @@ class GatewayBootstrapAuthTest { assertEquals("aaaaaaaa", prefs.loadGatewayTlsFingerprint(endpoint.stableId)) } + @Test + fun refreshGatewayConnection_reconnectsSavedManualEndpointAfterDisconnect() { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + val prefs = SecurePrefs(app, securePrefsOverride = securePrefs) + prefs.setManualEnabled(true) + prefs.setManualHost("127.0.0.1") + prefs.setManualPort(18789) + prefs.setManualTls(false) + prefs.setGatewayToken("shared-token") + val runtime = NodeRuntime(app, prefs) + + runtime.connect( + GatewayEndpoint.manual(host = "127.0.0.1", port = 18789), + NodeRuntime.GatewayConnectAuth(token = "initial-token", bootstrapToken = null, password = null), + ) + runtime.disconnect() + assertNull(desiredConnection(runtime, "nodeSession")) + + runtime.refreshGatewayConnection() + + val desired = desiredConnection(runtime, "nodeSession") ?: error("Expected desired node connection") + val endpoint = readField(desired, "endpoint") + assertEquals("127.0.0.1", endpoint.host) + assertEquals(18789, endpoint.port) + assertEquals("shared-token", readField(desired, "token")) + } + @Test fun connect_showsSecureEndpointGuidanceWhenTlsProbeFails() { val app = RuntimeEnvironment.getApplication() diff --git a/apps/android/app/src/test/java/ai/openclaw/app/NodeForegroundServiceTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/NodeForegroundServiceTest.kt index 9c76eeb3219b..2c982de07e12 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/NodeForegroundServiceTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/NodeForegroundServiceTest.kt @@ -34,15 +34,15 @@ class NodeForegroundServiceTest { @Test fun foregroundServiceTypesForVoiceMode_addsMicrophoneOnlyForTalkMode() { assertEquals( - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE, foregroundServiceTypesForVoiceMode(VoiceCaptureMode.Off), ) assertEquals( - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE, foregroundServiceTypesForVoiceMode(VoiceCaptureMode.ManualMic), ) assertEquals( - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, foregroundServiceTypesForVoiceMode(VoiceCaptureMode.TalkMode), ) } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt index a8cb0ae0808b..c923b6fdde4e 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt @@ -77,6 +77,31 @@ class SecurePrefsTest { assertTrue(plainPrefs.getBoolean("device.apps.sharing.enabled", false)) } + @Test + fun appearanceThemeMode_defaultsDarkForExistingInstalls() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs.edit().clear().commit() + val prefs = SecurePrefs(context) + + assertEquals(AppearanceThemeMode.Dark, prefs.appearanceThemeMode.value) + assertFalse(plainPrefs.contains("appearance.themeMode")) + } + + @Test + fun setAppearanceThemeMode_persistsSelectedMode() { + val context = RuntimeEnvironment.getApplication() + val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE) + plainPrefs.edit().clear().commit() + val prefs = SecurePrefs(context) + + prefs.setAppearanceThemeMode(AppearanceThemeMode.Light) + + assertEquals(AppearanceThemeMode.Light, prefs.appearanceThemeMode.value) + assertEquals("light", plainPrefs.getString("appearance.themeMode", null)) + assertEquals(AppearanceThemeMode.Light, SecurePrefs(context).appearanceThemeMode.value) + } + @Test fun saveGatewayBootstrapToken_persistsSeparatelyFromSharedToken() { val context = RuntimeEnvironment.getApplication() diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerMessageIdentityTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerMessageIdentityTest.kt index 445b557b635f..5bec5f98384f 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerMessageIdentityTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerMessageIdentityTest.kt @@ -1,10 +1,44 @@ package ai.openclaw.app.chat +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Test class ChatControllerMessageIdentityTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun parseChatMessageContentsReadsGatewayStringContent() { + val obj = + json + .parseToJsonElement( + """ + {"role":"user","content":"Hello","idempotencyKey":"run-1:user"} + """.trimIndent(), + ).jsonObject + + val content = parseChatMessageContents(obj) + + assertEquals(listOf(ChatMessageContent(type = "text", text = "Hello")), content) + } + + @Test + fun parseChatMessageContentsFallsBackToTopLevelText() { + val obj = + json + .parseToJsonElement( + """ + {"role":"assistant","text":"Hi there"} + """.trimIndent(), + ).jsonObject + + val content = parseChatMessageContents(obj) + + assertEquals(listOf(ChatMessageContent(type = "text", text = "Hi there")), content) + } + @Test fun reconcileMessageIdsReusesMatchingIdsAcrossHistoryReload() { val previous = @@ -101,6 +135,62 @@ class ChatControllerMessageIdentityTest { assertEquals(listOf("local-user", "remote-assistant"), merged.map { it.id }) } + @Test + fun retainUnmatchedOptimisticMessagesKeepsOutgoingUserTurnWhenHistoryOmitsIt() { + val optimistic = + ChatMessage( + id = "local-user", + role = "user", + content = listOf(ChatMessageContent(type = "text", text = "Testing testing 1 2 3")), + timestampMs = 1000L, + ) + val assistant = + ChatMessage( + id = "remote-assistant", + role = "assistant", + content = listOf(ChatMessageContent(type = "text", text = "Received.")), + timestampMs = 2000L, + ) + + val retained = retainUnmatchedOptimisticMessages(incoming = listOf(assistant), optimistic = listOf(optimistic)) + + assertEquals(listOf("local-user"), retained.map { it.id }) + } + + @Test + fun retainUnmatchedOptimisticMessagesDropsGatewayPersistedUserTurn() { + val optimistic = + ChatMessage( + id = "local-user", + role = "user", + content = listOf(ChatMessageContent(type = "text", text = "hello")), + timestampMs = 1000L, + idempotencyKey = "run-1:user", + ) + val remoteUser = optimistic.copy(id = "remote-user", timestampMs = 500L) + + val retained = retainUnmatchedOptimisticMessages(incoming = listOf(remoteUser), optimistic = listOf(optimistic)) + + assertEquals(emptyList(), retained.map { it.id }) + } + + @Test + fun retainUnmatchedOptimisticMessagesKeepsDistinctIdempotencyKey() { + val optimistic = + ChatMessage( + id = "local-user", + role = "user", + content = listOf(ChatMessageContent(type = "text", text = "hello")), + timestampMs = 1000L, + idempotencyKey = "run-2:user", + ) + val remoteUser = optimistic.copy(id = "remote-user", timestampMs = 2000L, idempotencyKey = "run-1:user") + + val retained = retainUnmatchedOptimisticMessages(incoming = listOf(remoteUser), optimistic = listOf(optimistic)) + + assertEquals(listOf("local-user"), retained.map { it.id }) + } + @Test fun mergeOptimisticMessagesDoesNotDuplicateHistoryTurns() { val user = diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt index 58cca4ce1db9..79ac918bd3e7 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt @@ -20,6 +20,7 @@ import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.RecordedRequest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -233,7 +234,75 @@ class GatewaySessionReconnectTest { ) } - private fun createReconnectHarness(): ReconnectHarness { + @Test + fun pairingRequiredFailureNotifiesPauseReconnectProblem() = + runBlocking { + val json = Json { ignoreUnknownKeys = true } + val connectFailure = CompletableDeferred>() + val server = + startGatewayServer(json = json) { webSocket, id, method -> + if (method == "connect") { + webSocket.send( + """ + {"type":"res","id":"$id","ok":false,"error":{"code":"NOT_PAIRED","message":"pairing required: device approval is required","details":{"code":"PAIRING_REQUIRED","reason":"not-paired","requestId":"request-1"}}} + """.trimIndent(), + ) + } + } + val harness = + createReconnectHarness { error, pauseReconnect -> + connectFailure.complete(error to pauseReconnect) + } + + try { + connectNodeSession(harness.session, server.port) + val (error, pauseReconnect) = withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { connectFailure.await() } + + assertEquals("PAIRING_REQUIRED", error.details?.code) + assertEquals("not-paired", error.details?.reason) + assertEquals("request-1", error.details?.requestId) + assertTrue(pauseReconnect) + } finally { + shutdownReconnectHarness(harness, server) + } + } + + @Test + fun pairingRequiredFailureDropsUnsafeRequestId() = + runBlocking { + val json = Json { ignoreUnknownKeys = true } + val connectFailure = CompletableDeferred>() + val server = + startGatewayServer(json = json) { webSocket, id, method -> + if (method == "connect") { + webSocket.send( + """ + {"type":"res","id":"$id","ok":false,"error":{"code":"NOT_PAIRED","message":"pairing required: device approval is required","details":{"code":"PAIRING_REQUIRED","reason":"not-paired","requestId":"request-1;echo unsafe"}}} + """.trimIndent(), + ) + } + } + val harness = + createReconnectHarness { error, pauseReconnect -> + connectFailure.complete(error to pauseReconnect) + } + + try { + connectNodeSession(harness.session, server.port) + val (error, pauseReconnect) = withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { connectFailure.await() } + + assertEquals("PAIRING_REQUIRED", error.details?.code) + assertEquals("not-paired", error.details?.reason) + assertNull(error.details?.requestId) + assertTrue(pauseReconnect) + } finally { + shutdownReconnectHarness(harness, server) + } + } + + private fun createReconnectHarness( + onConnectFailure: (GatewaySession.ErrorShape, Boolean) -> Unit = { _, _ -> }, + ): ReconnectHarness { val app = RuntimeEnvironment.getApplication() val sessionJob = SupervisorJob() val session = @@ -243,6 +312,7 @@ class GatewaySessionReconnectTest { deviceAuthStore = ReconnectDeviceAuthStore(), onConnected = {}, onDisconnected = { _ -> }, + onConnectFailure = onConnectFailure, onEvent = { _, _ -> }, onInvoke = { GatewaySession.InvokeResult.ok("""{"handled":true}""") }, ) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/OnboardingFlowLogicTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/OnboardingFlowLogicTest.kt index c62c474cc1c0..d27b54022ac8 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/OnboardingFlowLogicTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/OnboardingFlowLogicTest.kt @@ -1,5 +1,6 @@ package ai.openclaw.app.ui +import ai.openclaw.app.GatewayConnectionProblem import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -26,6 +27,53 @@ class OnboardingFlowLogicTest { assertTrue(canFinishOnboarding(isConnected = true, isNodeConnected = true)) } + @Test + fun nearbyGatewayFoundStateIsConnectable() { + assertEquals( + NearbyGatewayUiState(subtitle = "Studio Gateway", status = "Found", canConnect = true), + nearbyGatewayUiState(nearbyGatewayName = "Studio Gateway", discoveryStatusText = "Searching…", discoveryStarted = false), + ) + } + + @Test + fun nearbyGatewayBeforeDiscoveryStartsIsNotConnectable() { + assertEquals( + NearbyGatewayUiState(subtitle = "Starting discovery...", status = "Starting", canConnect = false), + nearbyGatewayUiState(nearbyGatewayName = null, discoveryStatusText = "Searching…", discoveryStarted = false, searchTimedOut = true), + ) + } + + @Test + fun nearbyGatewaySearchingStateIsNotConnectable() { + assertEquals( + NearbyGatewayUiState(subtitle = "Searching for gateways...", status = "Searching", canConnect = false), + nearbyGatewayUiState(nearbyGatewayName = null, discoveryStatusText = "Searching for gateways…"), + ) + } + + @Test + fun nearbyGatewayTimedOutSearchShowsEmptyState() { + assertEquals( + NearbyGatewayUiState(subtitle = "No gateway found", status = "Not found", canConnect = false), + nearbyGatewayUiState(nearbyGatewayName = null, discoveryStatusText = "Searching for gateways…", searchTimedOut = true), + ) + } + + @Test + fun nearbyGatewayEmptyResultStateIsNotConnectable() { + assertEquals( + NearbyGatewayUiState(subtitle = "No gateway found", status = "Not found", canConnect = false), + nearbyGatewayUiState(nearbyGatewayName = null, discoveryStatusText = "Local: 0 • Wide: 0"), + ) + } + + @Test + fun recoveryGatewayNamePrefersServerThenAttemptedGateway() { + assertEquals("Server Gateway", recoveryGatewayName(serverName = "Server Gateway", attemptedGatewayName = "Discovered Gateway")) + assertEquals("Discovered Gateway", recoveryGatewayName(serverName = null, attemptedGatewayName = "Discovered Gateway")) + assertEquals("Home Gateway", recoveryGatewayName(serverName = " ", attemptedGatewayName = " ")) + } + @Test fun showsPairingStateForPairingRequiredGatewayStatus() { assertEquals( @@ -50,6 +98,50 @@ class OnboardingFlowLogicTest { ) } + @Test + fun showsApprovalRequiredForPausedPairingProblem() { + assertEquals( + GatewayRecoveryUiState.ApprovalRequired, + gatewayRecoveryUiState( + ready = false, + statusText = "Connecting…", + connectSettling = false, + gatewayConnectionProblem = + GatewayConnectionProblem( + code = "PAIRING_REQUIRED", + message = "pairing required: device approval is required", + reason = "not-paired", + requestId = "request-1", + recommendedNextStep = null, + pauseReconnect = true, + retryable = false, + ), + ), + ) + } + + @Test + fun showsPairingForRetryablePairingProblem() { + assertEquals( + GatewayRecoveryUiState.Pairing, + gatewayRecoveryUiState( + ready = false, + statusText = "Connecting…", + connectSettling = false, + gatewayConnectionProblem = + GatewayConnectionProblem( + code = "PAIRING_REQUIRED", + message = "pairing required: device approval is required", + reason = "not-paired", + requestId = "request-1", + recommendedNextStep = "wait_then_retry", + pauseReconnect = false, + retryable = true, + ), + ), + ) + } + @Test fun showsFinishingStateWhileGatewayConnectionSettles() { assertEquals( diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/ProviderModelStatusTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/ProviderModelStatusTest.kt index a41d27155e96..9efc3f580bd4 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/ProviderModelStatusTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/ProviderModelStatusTest.kt @@ -24,118 +24,44 @@ class ProviderModelStatusTest { } @Test - fun readyModelProviderCountUsesAuthBackedProviderStatuses() { - val providers = - listOf( - GatewayModelProviderSummary(id = "openai", displayName = "OpenAI", status = "missing", profileCount = 0), - GatewayModelProviderSummary(id = "anthropic", displayName = "Anthropic", status = "ready", profileCount = 1), - GatewayModelProviderSummary(id = "openai", displayName = "OpenAI", status = "expiring", profileCount = 1), + fun providerRowsIncludeConfiguredModelProvidersWithoutAuthRows() { + val rows = + providerRows( + providers = + listOf( + GatewayModelProviderSummary( + id = "openai", + displayName = "OpenAI", + status = "ok", + profileCount = 1, + ), + ), + models = + listOf( + model(provider = "openai", id = "gpt-5.5"), + model(provider = "byteplus", id = "seed-1-8-251228"), + ), ) - assertEquals(1, readyModelProviderCount(providers, emptyList())) - assertEquals(1, expiringModelProviderCount(providers)) - } - - @Test - fun readyModelProviderCountUsesAvailableModelsAsServingReadiness() { - val models = - listOf( - model(provider = "anthropic", available = true), - model(provider = "anthropic", available = true), - model(provider = "openrouter", available = false), - ) - - assertEquals(1, readyModelProviderCount(emptyList(), models)) - } - - @Test - fun readyModelProviderCountDoesNotTreatCatalogOnlyModelsAsReady() { - val providers = - listOf( - GatewayModelProviderSummary(id = "openrouter", displayName = "OpenRouter", status = "missing", profileCount = 0), - ) - val models = - listOf( - model(provider = "openrouter", available = false), - ) - - assertEquals(0, readyModelProviderCount(providers, models)) - } - - @Test - fun readyModelProviderCountPreservesLegacyRowsWhenAvailabilityIsMissing() { - val models = - listOf( - model(provider = "openrouter", available = null), - ) - - assertEquals(1, readyModelProviderCount(emptyList(), models)) - } - - @Test - fun readyModelProviderCountTreatsExpiringAvailableModelsAsUsableButWarnable() { - val providers = - listOf( - GatewayModelProviderSummary(id = "openai", displayName = "OpenAI", status = "expiring", profileCount = 1), - ) - val models = - listOf( - model(provider = "openai", available = true), - ) - - assertEquals(1, readyModelProviderCount(providers, models)) - assertEquals(1, expiringModelProviderCount(providers)) - assertFalse(modelProviderReady("expiring")) - } - - @Test - fun providerCommandSubtitleSurfacesExpiringBeforeReadyModels() { - val providers = - listOf( - GatewayModelProviderSummary(id = "openai", displayName = "OpenAI", status = "expiring", profileCount = 1), - ) - val models = - listOf( - model(provider = "openai", available = true), - ) - - assertEquals("1 providers expiring", providerCommandSubtitle(isConnected = true, providers = providers, models = models)) - } - - @Test - fun readyModelProviderCountDoesNotTreatUnavailableModelsAsReadyWhenAuthProviderNeedsSetup() { - val providers = - listOf( - GatewayModelProviderSummary(id = "openai", displayName = "OpenAI", status = "missing", profileCount = 0), - ) - val models = - listOf( - model(provider = "openai", available = false), - ) - - assertEquals(0, readyModelProviderCount(providers, models)) - } - - @Test - fun modelAvailabilityHonorsExplicitUnavailableRows() { - assertTrue(modelAvailabilityUsable(model(provider = "openai", available = true))) - assertTrue(modelAvailabilityUsable(model(provider = "openai", available = null))) - assertFalse(modelAvailabilityUsable(model(provider = "openai", available = false))) + assertEquals(listOf("openai", "byteplus"), rows.map { it.id }) + assertEquals(1, rows.first { it.id == "openai" }.modelCount) + assertEquals(1, rows.first { it.id == "byteplus" }.modelCount) + assertTrue(rows.first { it.id == "byteplus" }.ready) } private fun model( provider: String, - available: Boolean?, + id: String, ): GatewayModelSummary = GatewayModelSummary( - id = "$provider/test-model", - name = "test-model", + id = id, + name = id, provider = provider, - available = available, supportsVision = false, supportsAudio = false, supportsDocuments = false, supportsReasoning = false, contextTokens = null, + available = null, ) } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt new file mode 100644 index 000000000000..353c28d28a90 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt @@ -0,0 +1,13 @@ +package ai.openclaw.app.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SettingsScreensTest { + @Test + fun androidDistributionChannelUsesBuildFlavorLabels() { + assertEquals("Play", androidDistributionChannel("play")) + assertEquals("Third-party", androidDistributionChannel("thirdParty")) + assertEquals("Unknown", androidDistributionChannel("")) + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/ShellScreenLogicTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/ShellScreenLogicTest.kt index 12bed0a0ddc6..891a0e0d5767 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/ShellScreenLogicTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/ShellScreenLogicTest.kt @@ -1,5 +1,6 @@ package ai.openclaw.app.ui +import ai.openclaw.app.AppearanceThemeMode import ai.openclaw.app.GatewayChannelSummary import ai.openclaw.app.GatewayChannelsSummary import ai.openclaw.app.GatewayNodesDevicesSummary @@ -17,6 +18,28 @@ class ShellScreenLogicTest { assertFalse(shellBottomNavVisible(keyboardVisible = false, commandOpen = true)) } + @Test + fun appearanceThemeModeDefaultsToDarkForExistingInstalls() { + assertEquals(AppearanceThemeMode.Dark, AppearanceThemeMode.fromRawValue(null)) + assertEquals(AppearanceThemeMode.Dark, AppearanceThemeMode.fromRawValue("unknown")) + } + + @Test + fun appearanceThemeLabelsRoundTripFromSettingsOptions() { + assertEquals(listOf("System", "Dark", "Light"), appearanceThemeOptions()) + assertEquals(AppearanceThemeMode.System, appearanceThemeModeForLabel("System")) + assertEquals(AppearanceThemeMode.Dark, appearanceThemeModeForLabel("Dark")) + assertEquals(AppearanceThemeMode.Light, appearanceThemeModeForLabel("Light")) + } + + @Test + fun appearanceThemeModeResolvesAgainstSystemPreference() { + assertFalse(AppearanceThemeMode.System.isDark(systemDark = false)) + assertTrue(AppearanceThemeMode.System.isDark(systemDark = true)) + assertTrue(AppearanceThemeMode.Dark.isDark(systemDark = false)) + assertFalse(AppearanceThemeMode.Light.isDark(systemDark = true)) + } + @Test fun homeAttentionRowsSurfaceGatewayWhenDisconnected() { val rows = @@ -76,6 +99,9 @@ class ShellScreenLogicTest { ) assertEquals(listOf("Approvals", "Channels", "Nodes & Devices", "Providers"), rows.map { it.title }) + val providersRow = rows.single { it.title == "Providers" } + assertEquals(Tab.Settings, providersRow.tab) + assertEquals(SettingsRoute.Gateway, providersRow.settingsRoute) } @Test @@ -92,21 +118,6 @@ class ShellScreenLogicTest { assertEquals(emptyList(), rows.map { it.title }) } - @Test - fun homeAttentionRowsSurfaceExpiringProviderAuth() { - val rows = - homeAttentionRows( - isConnected = true, - pendingApprovals = 0, - channelsSummary = emptyChannels(), - nodesDevicesSummary = emptyNodesDevices(), - readyProviderCount = 0, - expiringProviderCount = 1, - ) - - assertEquals(listOf("Provider auth expires soon"), rows.map { it.subtitle }) - } - private fun emptyChannels(): GatewayChannelsSummary = GatewayChannelsSummary(channels = emptyList()) private fun emptyNodesDevices(): GatewayNodesDevicesSummary = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList()) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/VoiceScreenLogicTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/VoiceScreenLogicTest.kt new file mode 100644 index 000000000000..e8f3917ddad3 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/VoiceScreenLogicTest.kt @@ -0,0 +1,75 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.VoiceCaptureMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class VoiceScreenLogicTest { + @Test + fun voiceAttentionStatusKeepsFailedTalkStartVisibleAfterModeStops() { + val attention = + voiceAttentionStatus( + talkModeStatusText = "Start failed: Error: Realtime voice provider \"openai\" is not configured", + voiceCaptureMode = VoiceCaptureMode.Off, + micEnabled = false, + micIsSending = false, + talkModeEnabled = false, + talkModeListening = false, + talkModeSpeaking = false, + ) + + assertEquals("Realtime voice provider is not configured.", attention) + assertEquals( + attention, + voiceStatusLabel( + gatewayStatus = "Online", + voiceCaptureMode = VoiceCaptureMode.Off, + micStatusText = "Mic off", + micQueuedMessages = 0, + micIsSending = false, + talkModeListening = false, + talkModeSpeaking = false, + voiceAttentionStatus = attention, + ), + ) + } + + @Test + fun voiceAttentionStatusDoesNotOverrideActiveTalkState() { + assertNull( + voiceAttentionStatus( + talkModeStatusText = "Start failed: provider unavailable", + voiceCaptureMode = VoiceCaptureMode.TalkMode, + micEnabled = false, + micIsSending = false, + talkModeEnabled = true, + talkModeListening = false, + talkModeSpeaking = false, + ), + ) + } + + @Test + fun voiceAttentionStatusDoesNotOverrideDictationState() { + assertNull( + voiceAttentionStatus( + talkModeStatusText = "Start failed: provider unavailable", + voiceCaptureMode = VoiceCaptureMode.ManualMic, + micEnabled = true, + micIsSending = false, + talkModeEnabled = false, + talkModeListening = false, + talkModeSpeaking = false, + ), + ) + } + + @Test + fun voiceRuntimeAttentionStatusSanitizesTranscriptionProviderFailures() { + assertEquals( + "Realtime transcription provider is not configured.", + voiceRuntimeAttentionStatus("Transcription unavailable: UNAVAILABLE: Error: No realtime transcription provider registered"), + ) + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatTimelineTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatTimelineTest.kt new file mode 100644 index 000000000000..7dcb0dfa6d7f --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatTimelineTest.kt @@ -0,0 +1,94 @@ +package ai.openclaw.app.ui.chat + +import ai.openclaw.app.chat.ChatMessage +import ai.openclaw.app.chat.ChatMessageContent +import ai.openclaw.app.chat.ChatPendingToolCall +import org.junit.Assert.assertEquals +import org.junit.Test + +class ChatTimelineTest { + @Test + fun activeRunAnchorsNewestUserPromptInsteadOfThinkingRow() { + val user = textMessage(id = "user-1", role = "user", text = "hello") + + val timeline = + buildChatTimeline( + messages = listOf(user), + pendingRunCount = 1, + pendingToolCalls = emptyList(), + streamingAssistantText = null, + ) + + assertEquals(listOf("thinking", "message:user-1"), timeline.items.map(::chatTimelineItemKey)) + assertEquals(1, timeline.scrollTargetIndex) + } + + @Test + fun activeRunAnchorsNewestUserPromptWhileAssistantStreams() { + val olderAssistant = textMessage(id = "assistant-1", role = "assistant", text = "previous") + val user = textMessage(id = "user-1", role = "user", text = "next") + val tool = + ChatPendingToolCall( + toolCallId = "tool-1", + name = "memory.search", + startedAtMs = 1000L, + ) + + val timeline = + buildChatTimeline( + messages = listOf(olderAssistant, user), + pendingRunCount = 1, + pendingToolCalls = listOf(tool), + streamingAssistantText = "streaming", + ) + + assertEquals( + listOf("stream", "tools", "thinking", "message:user-1", "message:assistant-1"), + timeline.items.map(::chatTimelineItemKey), + ) + assertEquals(3, timeline.scrollTargetIndex) + } + + @Test + fun finishedRunAnchorsNewestPersistedMessage() { + val user = textMessage(id = "user-1", role = "user", text = "hello") + val assistant = textMessage(id = "assistant-1", role = "assistant", text = "done") + + val timeline = + buildChatTimeline( + messages = listOf(user, assistant), + pendingRunCount = 0, + pendingToolCalls = emptyList(), + streamingAssistantText = null, + ) + + assertEquals(listOf("message:assistant-1", "message:user-1"), timeline.items.map(::chatTimelineItemKey)) + assertEquals(0, timeline.scrollTargetIndex) + } + + @Test + fun emptyTimelineHasNoScrollTarget() { + val timeline = + buildChatTimeline( + messages = emptyList(), + pendingRunCount = 0, + pendingToolCalls = emptyList(), + streamingAssistantText = null, + ) + + assertEquals(emptyList(), timeline.items.map(::chatTimelineItemKey)) + assertEquals(null, timeline.scrollTargetIndex) + } + + private fun textMessage( + id: String, + role: String, + text: String, + ): ChatMessage = + ChatMessage( + id = id, + role = role, + content = listOf(ChatMessageContent(type = "text", text = text)), + timestampMs = null, + ) +} diff --git a/apps/android/benchmark/build.gradle.kts b/apps/android/benchmark/build.gradle.kts index e3e2a6641bdc..3bf47d195861 100644 --- a/apps/android/benchmark/build.gradle.kts +++ b/apps/android/benchmark/build.gradle.kts @@ -5,7 +5,7 @@ plugins { android { namespace = "ai.openclaw.app.benchmark" - compileSdk = 36 + compileSdk = 37 defaultConfig { minSdk = 31 diff --git a/apps/android/gradle/libs.versions.toml b/apps/android/gradle/libs.versions.toml index 555c5ac4b84c..6700d68055d5 100644 --- a/apps/android/gradle/libs.versions.toml +++ b/apps/android/gradle/libs.versions.toml @@ -1,10 +1,10 @@ [versions] -agp = "9.2.0" +agp = "9.2.1" androidx-activity = "1.13.0" androidx-benchmark = "1.4.1" androidx-camera = "1.6.0" -androidx-compose-bom = "2026.04.01" -androidx-core = "1.18.0" +androidx-compose-bom = "2026.05.01" +androidx-core = "1.19.0" androidx-exifinterface = "1.4.2" androidx-lifecycle = "2.10.0" androidx-security = "1.1.0" @@ -13,14 +13,14 @@ androidx-uiautomator = "2.4.0-beta02" androidx-webkit = "1.15.0" bcprov = "1.84" commonmark = "0.28.0" -coroutines = "1.10.2" -dnsjava = "3.6.4" +coroutines = "1.11.0" +dnsjava = "3.6.5" junit = "4.13.2" -junit-vintage = "6.0.3" +junit-vintage = "6.1.0" kotest = "6.1.11" ktlint-gradle = "14.2.0" -kotlin = "2.3.21" -material = "1.13.0" +kotlin = "2.4.0" +material = "1.14.0" okhttp = "5.3.2" play-services-code-scanner = "16.1.0" robolectric = "4.16.1" diff --git a/apps/android/gradle/wrapper/gradle-wrapper.jar b/apps/android/gradle/wrapper/gradle-wrapper.jar index d997cfc60f4c..b1b8ef56b44f 100644 Binary files a/apps/android/gradle/wrapper/gradle-wrapper.jar and b/apps/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/apps/android/gradle/wrapper/gradle-wrapper.properties b/apps/android/gradle/wrapper/gradle-wrapper.properties index c61a118f7ddb..df6a6ad763d4 100644 --- a/apps/android/gradle/wrapper/gradle-wrapper.properties +++ b/apps/android/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/apps/android/gradlew b/apps/android/gradlew index 739907dfd159..b9bb139f7905 100755 --- a/apps/android/gradlew +++ b/apps/android/gradlew @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. diff --git a/apps/android/gradlew.bat b/apps/android/gradlew.bat index e509b2dd8fe5..aa5f10b069f9 100644 --- a/apps/android/gradlew.bat +++ b/apps/android/gradlew.bat @@ -23,8 +23,8 @@ @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -51,7 +51,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,7 +65,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line @@ -73,21 +73,10 @@ goto fail @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/apps/ios/.periphery.yml b/apps/ios/.periphery.yml new file mode 100644 index 000000000000..e9156a8170cb --- /dev/null +++ b/apps/ios/.periphery.yml @@ -0,0 +1,18 @@ +project: OpenClaw.xcodeproj +schemes: + - OpenClaw +retain_codable_properties: true +retain_swift_ui_previews: true +retain_objc_accessible: true +retain_unused_protocol_func_params: true +retain_assign_only_properties: true +relative_results: true +disable_update_check: true +report_include: + - Sources/** + - ShareExtension/** + - ActivityWidget/** + - WatchExtension/Sources/** +build_arguments: + - -destination + - generic/platform=iOS Simulator diff --git a/apps/ios/CHANGELOG.md b/apps/ios/CHANGELOG.md index ea7bd40d8c00..be4184c92c32 100644 --- a/apps/ios/CHANGELOG.md +++ b/apps/ios/CHANGELOG.md @@ -2,7 +2,9 @@ ## 2026.6.2 - 2026-06-02 -Maintenance update for the current OpenClaw release. +OpenClaw is now available on iPhone. + +Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, share content from iOS, and bring device capabilities like camera, location, screen, and notifications into your private automation workflows. ## 2026.6.1 - 2026-06-01 diff --git a/apps/ios/README.md b/apps/ios/README.md index 9389c77f892c..28df7b4af0d7 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -1,6 +1,6 @@ # OpenClaw iOS (Super Alpha) -This iOS app is super-alpha and internal-use only. It connects to an OpenClaw Gateway as a `role: node` on iPhone and iPad. +This iOS app is super-alpha and internal-use only. The first public App Store release targets iPhone and connects to an OpenClaw Gateway as a `role: node`. ## Distribution Status @@ -34,7 +34,7 @@ open OpenClaw.xcodeproj 3. In Xcode: - Scheme: `OpenClaw` - - Destination: connected iPhone or iPad (recommended for real behavior) + - Destination: connected iPhone (recommended for real behavior) - Build configuration: `Debug` - Run (`Product` -> `Run`) 4. If signing fails on a personal team: @@ -251,7 +251,7 @@ gateway can only send pushes for iOS devices that paired with that gateway. ## Computer Use Relationship -The iOS app is not a Codex Computer Use backend. Computer Use and `cua-driver mcp` are macOS desktop-control paths; iOS exposes device capabilities as OpenClaw node commands through the gateway. Agents can drive the iPhone or iPad canvas, camera, screen, location, voice, and other node capabilities with `node.invoke`, subject to iOS foreground/background limits. +The iOS app is not a Codex Computer Use backend. Computer Use and `cua-driver mcp` are macOS desktop-control paths; iOS exposes device capabilities as OpenClaw node commands through the gateway. Agents can drive the iPhone canvas, camera, screen, location, voice, and other node capabilities with `node.invoke`, subject to iOS foreground/background limits. ## Location Automation Use Case (Testing) diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/152.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/152.png new file mode 100644 index 000000000000..6ebca8dd49cd Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/152.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/167.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/167.png new file mode 100644 index 000000000000..f6c12700cd4e Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/167.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/20.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/20.png new file mode 100644 index 000000000000..4640d610ea1c Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/20.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/76.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/76.png new file mode 100644 index 000000000000..4e63684a2de2 Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/76.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json index 922e8c6d7315..ded24327aca1 100644 --- a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1 +1 @@ -{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"idiom":"watch","filename":"172.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"86x86","expected-size":"172","role":"quickLook"},{"idiom":"watch","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"40x40","expected-size":"80","role":"appLauncher"},{"idiom":"watch","filename":"88.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"40mm","scale":"2x","size":"44x44","expected-size":"88","role":"appLauncher"},{"idiom":"watch","filename":"102.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"51x51","expected-size":"102","role":"appLauncher"},{"idiom":"watch","filename":"108.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"54x54","expected-size":"108","role":"appLauncher"},{"idiom":"watch","filename":"92.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"41mm","scale":"2x","size":"46x46","expected-size":"92","role":"appLauncher"},{"idiom":"watch","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"50x50","expected-size":"100","role":"appLauncher"},{"idiom":"watch","filename":"196.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"98x98","expected-size":"196","role":"quickLook"},{"idiom":"watch","filename":"216.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"108x108","expected-size":"216","role":"quickLook"},{"idiom":"watch","filename":"234.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"117x117","expected-size":"234","role":"quickLook"},{"idiom":"watch","filename":"258.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"129x129","expected-size":"258","role":"quickLook"},{"idiom":"watch","filename":"48.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"24x24","expected-size":"48","role":"notificationCenter"},{"idiom":"watch","filename":"55.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"27.5x27.5","expected-size":"55","role":"notificationCenter"},{"idiom":"watch","filename":"66.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"33x33","expected-size":"66","role":"notificationCenter"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"3x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"2x"},{"size":"1024x1024","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch-marketing","scale":"1x"}]} \ No newline at end of file +{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"20x20","expected-size":"20","filename":"20.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"40x40","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"76x76","expected-size":"76","filename":"76.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"76x76","expected-size":"152","filename":"152.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"83.5x83.5","expected-size":"167","filename":"167.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"idiom":"watch","filename":"172.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"86x86","expected-size":"172","role":"quickLook"},{"idiom":"watch","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"40x40","expected-size":"80","role":"appLauncher"},{"idiom":"watch","filename":"88.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"40mm","scale":"2x","size":"44x44","expected-size":"88","role":"appLauncher"},{"idiom":"watch","filename":"102.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"51x51","expected-size":"102","role":"appLauncher"},{"idiom":"watch","filename":"108.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"54x54","expected-size":"108","role":"appLauncher"},{"idiom":"watch","filename":"92.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"41mm","scale":"2x","size":"46x46","expected-size":"92","role":"appLauncher"},{"idiom":"watch","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"50x50","expected-size":"100","role":"appLauncher"},{"idiom":"watch","filename":"196.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"98x98","expected-size":"196","role":"quickLook"},{"idiom":"watch","filename":"216.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"108x108","expected-size":"216","role":"quickLook"},{"idiom":"watch","filename":"234.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"117x117","expected-size":"234","role":"quickLook"},{"idiom":"watch","filename":"258.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"129x129","expected-size":"258","role":"quickLook"},{"idiom":"watch","filename":"48.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"24x24","expected-size":"48","role":"notificationCenter"},{"idiom":"watch","filename":"55.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"27.5x27.5","expected-size":"55","role":"notificationCenter"},{"idiom":"watch","filename":"66.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"33x33","expected-size":"66","role":"notificationCenter"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"3x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"2x"},{"size":"1024x1024","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch-marketing","scale":"1x"}]} diff --git a/apps/ios/Sources/Chat/AppleReviewDemoChatTransport.swift b/apps/ios/Sources/Chat/AppleReviewDemoChatTransport.swift new file mode 100644 index 000000000000..139216002c5c --- /dev/null +++ b/apps/ios/Sources/Chat/AppleReviewDemoChatTransport.swift @@ -0,0 +1,253 @@ +import Foundation +import OpenClawChatUI +import OpenClawProtocol + +enum AppleReviewDemoMode { + static let setupCode = "APPLE-REVIEW-DEMO" + static let gatewayName = "Apple Review Demo Gateway" + static let gatewayAddress = "Local demo mode" + static let gatewayID = "apple-review-demo" + + static func isSetupCode(_ value: String) -> Bool { + value.trimmingCharacters(in: .whitespacesAndNewlines) + .localizedCaseInsensitiveCompare(self.setupCode) == .orderedSame + } + + static var agents: [AgentSummary] { + [ + AgentSummary( + id: "main", + name: "Main", + identity: ["emoji": AnyCodable("OC")], + workspace: "Apple Review Demo", + model: ["provider": AnyCodable("demo"), "model": AnyCodable("local-demo")], + agentruntime: ["kind": AnyCodable("local")], + thinkinglevels: nil, + thinkingoptions: ["auto", "low", "medium"], + thinkingdefault: "auto"), + ] + } +} + +struct AppleReviewDemoChatTransport: OpenClawChatTransport { + private let store = AppleReviewDemoChatStore() + + func createSession( + key: String, + label _: String?, + parentSessionKey _: String?) async throws -> OpenClawChatCreateSessionResponse + { + try await self.store.createSession(key: key) + } + + func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { + try await self.store.history(sessionKey: sessionKey) + } + + func listModels() async throws -> [OpenClawChatModelChoice] { + [ + OpenClawChatModelChoice( + modelID: "local-demo", + name: "Apple Review Demo", + provider: "demo", + contextWindow: 128_000), + ] + } + + func sendMessage( + sessionKey: String, + message: String, + thinking _: String, + idempotencyKey: String, + attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse + { + try await self.store.sendMessage( + sessionKey: sessionKey, + message: message, + runId: idempotencyKey) + } + + func abortRun(sessionKey _: String, runId _: String) async throws {} + + func listSessions(limit _: Int?) async throws -> OpenClawChatSessionsListResponse { + try await self.store.sessions() + } + + func setSessionModel(sessionKey _: String, model _: String?) async throws {} + + func setSessionThinking(sessionKey _: String, thinkingLevel _: String) async throws {} + + func requestHealth(timeoutMs _: Int) async throws -> Bool { + true + } + + func waitForRunCompletion(runId _: String, timeoutMs _: Int) async -> Bool { + true + } + + func events() -> AsyncStream { + AsyncStream { continuation in + continuation.yield(.health(ok: true)) + continuation.finish() + } + } + + func setActiveSessionKey(_: String) async throws {} + + func resetSession(sessionKey _: String) async throws { + await self.store.reset() + } + + func compactSession(sessionKey _: String) async throws {} +} + +private actor AppleReviewDemoChatStore { + private let sessionKey = "main" + private var messages: [OpenClawChatMessage] + + init() { + self.messages = AppleReviewDemoChatStore.seedMessages() + } + + func createSession(key: String) throws -> OpenClawChatCreateSessionResponse { + try Self.decode( + CreateSessionPayload(ok: true, key: key, sessionId: "apple-review-demo-\(key)"), + as: OpenClawChatCreateSessionResponse.self) + } + + func history(sessionKey: String) throws -> OpenClawChatHistoryPayload { + let normalizedSessionKey = Self.normalizedSessionKey(sessionKey) + return try Self.decode( + HistoryPayload( + sessionKey: normalizedSessionKey, + sessionId: "apple-review-demo-\(normalizedSessionKey)", + messages: self.messages, + thinkingLevel: "auto"), + as: OpenClawChatHistoryPayload.self) + } + + func sendMessage(sessionKey _: String, message: String, runId: String) throws -> OpenClawChatSendResponse { + let now = Date().timeIntervalSince1970 * 1000 + self.messages.append(Self.message(role: "user", text: message, timestamp: now)) + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + let subject = trimmed.isEmpty ? "that request" : "\"\(trimmed)\"" + self.messages.append( + Self.message( + role: "assistant", + text: """ + Demo mode is active. I can show the review flow locally for \(subject), including chat, agent \ + selection, settings, and Gateway-connected UI states. Live automation requires pairing a real \ + OpenClaw Gateway. + """, + timestamp: now + 1)) + return try Self.decode( + SendPayload(runId: runId, status: "ok"), + as: OpenClawChatSendResponse.self) + } + + func sessions() throws -> OpenClawChatSessionsListResponse { + let entry = OpenClawChatSessionEntry( + key: self.sessionKey, + kind: "chat", + displayName: "Apple Review Demo", + surface: "ios", + subject: "Gateway review flow", + room: nil, + space: nil, + updatedAt: Date().timeIntervalSince1970 * 1000, + sessionId: "apple-review-demo-main", + systemSent: true, + abortedLastRun: false, + thinkingLevel: "auto", + verboseLevel: nil, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + modelProvider: "demo", + model: "local-demo", + contextTokens: 128_000, + thinkingLevels: [ + OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"), + OpenClawChatThinkingLevelOption(id: "low", label: "Low"), + OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"), + ], + thinkingOptions: ["auto", "low", "medium"], + thinkingDefault: "auto") + return OpenClawChatSessionsListResponse( + ts: Date().timeIntervalSince1970 * 1000, + path: nil, + count: 1, + defaults: OpenClawChatSessionsDefaults( + modelProvider: "demo", + model: "local-demo", + contextTokens: 128_000, + thinkingLevels: [ + OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"), + OpenClawChatThinkingLevelOption(id: "low", label: "Low"), + OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"), + ], + thinkingOptions: ["auto", "low", "medium"], + thinkingDefault: "auto", + mainSessionKey: self.sessionKey), + sessions: [entry]) + } + + func reset() { + self.messages = Self.seedMessages() + } + + private static func seedMessages() -> [OpenClawChatMessage] { + let now = Date().timeIntervalSince1970 * 1000 + return [ + self.message( + role: "assistant", + text: """ + Apple Review demo mode is active. This local chat transport lets reviewers inspect the iOS app \ + without a private Gateway. + """, + timestamp: now), + ] + } + + private static func message(role: String, text: String, timestamp: Double) -> OpenClawChatMessage { + OpenClawChatMessage( + role: role, + content: [ + OpenClawChatMessageContent( + type: "text", + text: text, + mimeType: nil, + fileName: nil, + content: nil), + ], + timestamp: timestamp) + } + + private static func normalizedSessionKey(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "main" : trimmed + } + + private static func decode(_ value: some Encodable, as type: T.Type) throws -> T { + let data = try JSONEncoder().encode(value) + return try JSONDecoder().decode(type, from: data) + } + + private struct HistoryPayload: Encodable { + var sessionKey: String + var sessionId: String? + var messages: [OpenClawChatMessage]? + var thinkingLevel: String? + } + + private struct SendPayload: Encodable { + var runId: String + var status: String + } + + private struct CreateSessionPayload: Encodable { + var ok: Bool? + var key: String + var sessionId: String? + } +} diff --git a/apps/ios/Sources/Contacts/ContactsService.swift b/apps/ios/Sources/Contacts/ContactsService.swift index 1a25948829a4..0c8cfde164ce 100644 --- a/apps/ios/Sources/Contacts/ContactsService.swift +++ b/apps/ios/Sources/Contacts/ContactsService.swift @@ -202,10 +202,4 @@ final class ContactsService: ContactsServicing { phoneNumbers: contact.phoneNumbers.map(\.value.stringValue), emails: contact.emailAddresses.map { String($0.value) }) } - - #if DEBUG - static func _test_matches(contact: CNContact, phoneNumbers: [String], emails: [String]) -> Bool { - self.matchContacts(contacts: [contact], phoneNumbers: phoneNumbers, emails: emails) != nil - } - #endif } diff --git a/apps/ios/Sources/Design/AgentProDreamingDestination.swift b/apps/ios/Sources/Design/AgentProDreamingDestination.swift index 4e35665317fd..729bde3a2ea1 100644 --- a/apps/ios/Sources/Design/AgentProDreamingDestination.swift +++ b/apps/ios/Sources/Design/AgentProDreamingDestination.swift @@ -4,6 +4,7 @@ import SwiftUI struct AgentProDreamingDestination: View { @Environment(NodeAppModel.self) private var appModel + let headerLeadingAction: OpenClawSidebarHeaderAction? let overview: AgentOverviewSnapshot? let gatewayConnected: Bool let overviewLoading: Bool @@ -20,6 +21,7 @@ struct AgentProDreamingDestination: View { OpenClawProBackground() ScrollView { VStack(alignment: .leading, spacing: 16) { + self.header self.detailSummaryCard( icon: "moon", title: "Dreaming", @@ -57,6 +59,23 @@ struct AgentProDreamingDestination: View { .navigationBarTitleDisplayMode(.inline) } + @ViewBuilder + private var header: some View { + if let headerLeadingAction { + OpenClawAdaptiveHeaderRow( + title: "Dreaming", + subtitle: self.dreamingDetail, + titleFont: .title3.weight(.semibold), + subtitleFont: .callout) + { + OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction) + } accessory: { + EmptyView() + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + } + private enum DreamAction: String, CaseIterable, Identifiable { case backfill case repair diff --git a/apps/ios/Sources/Design/AgentProModels.swift b/apps/ios/Sources/Design/AgentProModels.swift index 79e0c8f810e6..de5d94ac5713 100644 --- a/apps/ios/Sources/Design/AgentProModels.swift +++ b/apps/ios/Sources/Design/AgentProModels.swift @@ -329,14 +329,24 @@ struct AgentConfigLite: Decodable { struct ConfigPatchParams: Encodable { let raw: String let baseHash: String + let replacePaths: [String]? + + init(raw: String, baseHash: String, replacePaths: [String]? = nil) { + self.raw = raw + self.baseHash = baseHash + self.replacePaths = replacePaths + } } enum SkillMutationError: LocalizedError { + case liveGatewayUnavailable case missingConfigHash case invalidPatchPayload var errorDescription: String? { switch self { + case .liveGatewayUnavailable: + "Connect a live gateway to edit agent skills." case .missingConfigHash: "Config hash missing; refresh and retry." case .invalidPatchPayload: diff --git a/apps/ios/Sources/Design/AgentProNodesDestination.swift b/apps/ios/Sources/Design/AgentProNodesDestination.swift index f20b1bf6dfe4..1d8c59819f83 100644 --- a/apps/ios/Sources/Design/AgentProNodesDestination.swift +++ b/apps/ios/Sources/Design/AgentProNodesDestination.swift @@ -3,6 +3,7 @@ import SwiftUI import UIKit struct AgentProNodesDestination: View { + let headerLeadingAction: OpenClawSidebarHeaderAction? let overview: AgentOverviewSnapshot? let gatewayConnected: Bool let agentCount: Int @@ -16,6 +17,7 @@ struct AgentProNodesDestination: View { OpenClawProBackground() ScrollView { VStack(alignment: .leading, spacing: 16) { + self.header self.summaryCard self.totalsCard self.nodesList @@ -27,16 +29,33 @@ struct AgentProNodesDestination: View { } .safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset) } - .navigationTitle("Nodes") + .navigationTitle("Instances") .navigationBarTitleDisplayMode(.inline) } + @ViewBuilder + private var header: some View { + if let headerLeadingAction { + OpenClawAdaptiveHeaderRow( + title: "Instances", + subtitle: self.instancesDetail, + titleFont: .title3.weight(.semibold), + subtitleFont: .callout) + { + OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction) + } accessory: { + EmptyView() + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + } + private var summaryCard: some View { ProCard { HStack(spacing: 12) { ProIconBadge(systemName: "display", color: self.instancesColor) VStack(alignment: .leading, spacing: 3) { - Text("Nodes") + Text("Instances") .font(.headline) Text(self.instancesDetail) .font(.caption) @@ -70,16 +89,16 @@ struct AgentProNodesDestination: View { private var nodesList: some View { VStack(alignment: .leading, spacing: 8) { - ProSectionHeader(title: "Connected Nodes") + ProSectionHeader(title: "Connected Instances") ProCard(padding: 0) { let nodes = self.sortedPresenceEntries if nodes.isEmpty { self.emptyRow( icon: "display", - title: self.gatewayConnected ? "No nodes connected" : "Nodes unavailable", + title: self.gatewayConnected ? "No instances connected" : "Instances unavailable", detail: self.gatewayConnected ? "The gateway did not report any system presence entries." - : "Connect a gateway to inspect connected nodes.") + : "Connect a gateway to inspect connected instances.") .padding(14) } else { VStack(spacing: 0) { @@ -114,7 +133,7 @@ struct AgentProNodesDestination: View { HStack(alignment: .top, spacing: 12) { ProIconBadge(systemName: Self.presenceIcon(entry), color: Self.presenceColor(entry)) VStack(alignment: .leading, spacing: 4) { - Text(Self.presenceLabel(entry) ?? "Node") + Text(Self.presenceLabel(entry) ?? "Instance") .font(.subheadline.weight(.semibold)) .lineLimit(1) Text(Self.presenceDetail(entry)) @@ -153,7 +172,7 @@ struct AgentProNodesDestination: View { HStack(spacing: 12) { ProIconBadge(systemName: Self.presenceIcon(entry), color: Self.presenceColor(entry)) VStack(alignment: .leading, spacing: 3) { - Text(Self.presenceLabel(entry) ?? "Node") + Text(Self.presenceLabel(entry) ?? "Instance") .font(.headline) Text(Self.presenceDetail(entry)) .font(.caption) @@ -192,7 +211,7 @@ struct AgentProNodesDestination: View { } .safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset) } - .navigationTitle(Self.presenceLabel(entry) ?? "Node") + .navigationTitle(Self.presenceLabel(entry) ?? "Instance") .navigationBarTitleDisplayMode(.inline) } diff --git a/apps/ios/Sources/Design/AgentProTab+Cron.swift b/apps/ios/Sources/Design/AgentProTab+Cron.swift index 0c0746153bc4..f0a85c31b5f3 100644 --- a/apps/ios/Sources/Design/AgentProTab+Cron.swift +++ b/apps/ios/Sources/Design/AgentProTab+Cron.swift @@ -99,14 +99,14 @@ extension AgentProTab { } label: { Label("Run", systemImage: "play.fill") } - .disabled(busy || !self.gatewayConnected) + .disabled(busy || !self.liveGatewayConnected) Button { Task { await self.setCronJob(job, enabled: !job.enabled) } } label: { Label(job.enabled ? "Pause" : "Enable", systemImage: job.enabled ? "pause.fill" : "checkmark") } - .disabled(busy || !self.gatewayConnected) + .disabled(busy || !self.liveGatewayConnected) } .buttonStyle(.bordered) .controlSize(.mini) @@ -149,7 +149,7 @@ extension AgentProTab { success: String, action: () async throws -> Void) async { - guard self.gatewayConnected else { return } + guard self.liveGatewayConnected else { return } self.cronActionBusyIDs.insert(job.id) self.cronActionStatusText = nil defer { self.cronActionBusyIDs.remove(job.id) } diff --git a/apps/ios/Sources/Design/AgentProTab+Destinations.swift b/apps/ios/Sources/Design/AgentProTab+Destinations.swift index 6267e61d55fd..1b8fd48a2f4b 100644 --- a/apps/ios/Sources/Design/AgentProTab+Destinations.swift +++ b/apps/ios/Sources/Design/AgentProTab+Destinations.swift @@ -6,10 +6,12 @@ extension AgentProTab { @ViewBuilder func destination(for route: AgentRoute) -> some View { switch route { + case .agents: + self.agentsDestination case .skills: self.skillsDestination - case .nodes: - self.nodesDestination + case .instances: + self.instancesDestination case .cron: self.cronDestination case .usage: @@ -19,6 +21,26 @@ extension AgentProTab { } } + var agentsDestination: some View { + ZStack { + OpenClawProBackground() + ScrollView { + VStack(alignment: .leading, spacing: 16) { + self.rosterHeader + self.agentFilters + self.agentsSection + } + .padding(.vertical, 18) + } + .refreshable { + await self.refreshOverview(force: true) + } + .safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset) + } + .navigationTitle("Agents") + .navigationBarTitleDisplayMode(.inline) + } + var skillsDestination: some View { ZStack { OpenClawProBackground() @@ -46,8 +68,9 @@ extension AgentProTab { .navigationBarTitleDisplayMode(.inline) } - var nodesDestination: some View { + var instancesDestination: some View { AgentProNodesDestination( + headerLeadingAction: self.directHeaderLeadingAction(for: .instances), overview: self.overview, gatewayConnected: self.gatewayConnected, agentCount: self.appModel.gatewayAgents.count, @@ -64,6 +87,10 @@ extension AgentProTab { OpenClawProBackground() ScrollView { VStack(alignment: .leading, spacing: 16) { + self.directHeader( + for: .cron, + title: "Cron Jobs", + subtitle: self.cronDetail) self.detailSummaryCard( icon: "clock.arrow.circlepath", title: "Cron Jobs", @@ -89,6 +116,10 @@ extension AgentProTab { OpenClawProBackground() ScrollView { VStack(alignment: .leading, spacing: 16) { + self.directHeader( + for: .usage, + title: "Usage", + subtitle: self.usageDetail) self.detailSummaryCard( icon: "chart.line.uptrend.xyaxis", title: "Usage", @@ -111,6 +142,7 @@ extension AgentProTab { var dreamingDestination: some View { AgentProDreamingDestination( + headerLeadingAction: self.directHeaderLeadingAction(for: .dreaming), overview: self.overview, gatewayConnected: self.gatewayConnected, overviewLoading: self.overviewLoading, @@ -122,6 +154,27 @@ extension AgentProTab { }) } + @ViewBuilder + func directHeader(for route: AgentRoute, title: String, subtitle: String) -> some View { + if let headerLeadingAction = self.directHeaderLeadingAction(for: route) { + OpenClawAdaptiveHeaderRow( + title: title, + subtitle: subtitle, + titleFont: .title3.weight(.semibold), + subtitleFont: .callout) + { + OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction) + } accessory: { + EmptyView() + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + } + + func directHeaderLeadingAction(for route: AgentRoute) -> OpenClawSidebarHeaderAction? { + self.directRoute == route ? self.headerLeadingAction : nil + } + func detailSummaryCard( icon: String, title: String, diff --git a/apps/ios/Sources/Design/AgentProTab+DetailComponents.swift b/apps/ios/Sources/Design/AgentProTab+DetailComponents.swift index e4ccd68d4940..42c002fe16d4 100644 --- a/apps/ios/Sources/Design/AgentProTab+DetailComponents.swift +++ b/apps/ios/Sources/Design/AgentProTab+DetailComponents.swift @@ -1,5 +1,4 @@ import OpenClawKit -import OpenClawProtocol import SwiftUI extension AgentProTab { diff --git a/apps/ios/Sources/Design/AgentProTab+GatewayData.swift b/apps/ios/Sources/Design/AgentProTab+GatewayData.swift index cd2f2ccaab57..f6dee7e20f37 100644 --- a/apps/ios/Sources/Design/AgentProTab+GatewayData.swift +++ b/apps/ios/Sources/Design/AgentProTab+GatewayData.swift @@ -58,16 +58,9 @@ extension AgentProTab { } func agentRosterState(for agent: AgentSummary) -> AgentRosterState { - guard self.gatewayConnected else { return .idle } + guard self.gatewayConnected else { return .ready } if agent.id == self.activeAgentID { return .online } - if self.cronJobsContain(agentID: agent.id) { return .busy } - return .idle - } - - func cronJobsContain(agentID: String) -> Bool { - self.recentCronJobs.contains { job in - self.normalized(job.agentid) == agentID && job.enabled - } + return .ready } func modelLabel(for agent: AgentSummary) -> String? { @@ -124,7 +117,7 @@ extension AgentProTab { @MainActor func refreshOverview(force: Bool) async { guard self.scenePhase == .active else { return } - guard self.appModel.isOperatorGatewayConnected else { + guard self.liveGatewayConnected else { self.overview = nil self.overviewErrorText = nil self.overviewLoading = false diff --git a/apps/ios/Sources/Design/AgentProTab+Overview.swift b/apps/ios/Sources/Design/AgentProTab+Overview.swift index 2ce8cd391bc9..80cac8ccb975 100644 --- a/apps/ios/Sources/Design/AgentProTab+Overview.swift +++ b/apps/ios/Sources/Design/AgentProTab+Overview.swift @@ -5,18 +5,19 @@ import SwiftUI extension AgentProTab { var rosterHeader: some View { VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 3) { - Text("Agents") - .font(.system(size: 28, weight: .bold)) - Text("\(self.sortedAgents.count) total") - .font(.subheadline) - .foregroundStyle(.secondary) + OpenClawAdaptiveHeaderRow( + title: self.headerTitle, + subtitle: "\(self.sortedAgents.count) total", + titleFont: .system(size: 28, weight: .bold), + subtitleFont: .subheadline, + subtitleLineLimit: 1) + { + if let headerLeadingAction { + OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction) } - - Spacer(minLength: 8) - + } accessory: { HStack(spacing: 10) { + self.gatewayPillButton self.headerIconButton( systemName: "magnifyingglass", label: "Search agents", @@ -56,6 +57,19 @@ extension AgentProTab { .padding(.top, 6) } + @ViewBuilder + private var gatewayPillButton: some View { + if let openSettings { + Button(action: openSettings) { + OpenClawGatewayCompactPill() + } + .buttonStyle(.plain) + .accessibilityHint("Opens Settings / Gateway") + } else { + OpenClawGatewayCompactPill() + } + } + var agentFilters: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { @@ -140,7 +154,7 @@ extension AgentProTab { value: self.instancesValue, detail: self.instancesDetail, color: self.instancesColor, - route: .nodes) + route: .instances) self.metricTile( icon: "clock.arrow.circlepath", title: "Cron", @@ -285,7 +299,7 @@ extension AgentProTab { Circle().strokeBorder(self.iconButtonStroke, lineWidth: 1) } } - .accessibilityLabel(isActive ? "Active agent" : "Make active agent") + .accessibilityLabel(isActive ? "Default agent" : "Set default agent") } .padding(.vertical, 14) .padding(.horizontal, 13) @@ -514,10 +528,8 @@ extension AgentProTab { true case .online: self.agentRosterState(for: agent) == .online - case .busy: - self.agentRosterState(for: agent) == .busy - case .idle: - self.agentRosterState(for: agent) == .idle + case .ready: + self.agentRosterState(for: agent) == .ready } guard matchesFilter else { return false } @@ -544,6 +556,12 @@ extension AgentProTab { GatewayStatusBuilder.build(appModel: self.appModel) == .connected } + var liveGatewayConnected: Bool { + !self.appModel.isAppleReviewDemoModeEnabled && + self.gatewayConnected && + self.appModel.isOperatorGatewayConnected + } + private var searchFieldFill: Color { self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.78) } diff --git a/apps/ios/Sources/Design/AgentProTab+Skills.swift b/apps/ios/Sources/Design/AgentProTab+Skills.swift index 692edd0e084b..4a461eec2dbf 100644 --- a/apps/ios/Sources/Design/AgentProTab+Skills.swift +++ b/apps/ios/Sources/Design/AgentProTab+Skills.swift @@ -107,7 +107,7 @@ extension AgentProTab { } .buttonStyle(.bordered) .controlSize(.small) - .disabled(self.clawHubLoading || !self.gatewayConnected) + .disabled(self.clawHubLoading || !self.liveGatewayConnected) .accessibilityLabel("Search ClawHub") } @@ -212,6 +212,7 @@ extension AgentProTab { } var skillPolicySummary: String { + if self.appModel.isAppleReviewDemoModeEnabled { return "Demo mode keeps live skill changes disabled." } guard self.gatewayConnected else { return "Connect a gateway to edit skills." } guard let filter = self.agentSkillFilter else { return "All available skills are allowed for this agent." @@ -439,14 +440,13 @@ extension AgentProTab { func skillEditorControls(_ skill: SkillStatusEntryLite) -> some View { ProCard(radius: AgentLayout.cardRadius) { VStack(alignment: .leading, spacing: 12) { - Toggle( + self.skillEditorToggleRow( "Enabled globally", - isOn: Binding( - get: { skill.isGloballyEnabled }, - set: { enabled in - Task { await self.updateSkillGlobalEnabled(skill, enabled: enabled) } - })) - .disabled(self.isSkillConfigBusy(skill)) + isOn: skill.isGloballyEnabled, + disabled: self.isSkillConfigBusy(skill)) + { enabled in + Task { await self.updateSkillGlobalEnabled(skill, enabled: enabled) } + } if let primaryEnv = skill.primaryEnv, !primaryEnv.isEmpty { VStack(alignment: .leading, spacing: 8) { @@ -480,6 +480,43 @@ extension AgentProTab { .padding(.horizontal, OpenClawProMetric.pagePadding) } + func skillEditorToggleRow( + _ title: String, + isOn: Bool, + disabled: Bool, + onToggle: @escaping (Bool) -> Void) -> some View + { + // Native Toggle rows in this sheet can ignore visible-row taps on iOS 26. + // Keep the switch semantics explicit so the control always dispatches the mutation. + Button { + onToggle(!isOn) + } label: { + HStack { + Text(title) + Spacer(minLength: 8) + self.skillEditorSwitchIndicator(isOn: isOn) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(disabled) + .accessibilityLabel(title) + .accessibilityValue(isOn ? "On" : "Off") + } + + func skillEditorSwitchIndicator(isOn: Bool) -> some View { + Capsule() + .fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35)) + .frame(width: 52, height: 32) + .overlay(alignment: isOn ? .trailing : .leading) { + Circle() + .fill(Color.white) + .frame(width: 28, height: 28) + .padding(2) + .shadow(color: Color.black.opacity(0.14), radius: 1, x: 0, y: 1) + } + } + func skillEditorSetup(_ skill: SkillStatusEntryLite) -> some View { ProCard(radius: AgentLayout.cardRadius) { VStack(alignment: .leading, spacing: 10) { @@ -565,7 +602,7 @@ extension AgentProTab { @MainActor func patchAgentSkills(_ skills: [String]?, busyKey: String) async { - guard self.gatewayConnected else { return } + guard self.liveGatewayConnected else { return } self.skillMutationBusyKeys.insert(busyKey) self.skillMutationErrorText = nil self.skillMutationStatusText = nil @@ -584,7 +621,10 @@ extension AgentProTab { } let raw = try Self.agentSkillsPatchRaw(agentId: self.activeAgentID, skills: skills) - let params = ConfigPatchParams(raw: raw, baseHash: baseHash) + let params = ConfigPatchParams( + raw: raw, + baseHash: baseHash, + replacePaths: ["agents.list[].skills"]) let data = try JSONEncoder().encode(params) guard let json = String(data: data, encoding: .utf8) else { throw SkillMutationError.invalidPatchPayload @@ -640,7 +680,7 @@ extension AgentProTab { @MainActor func installClawHubSkill(_ result: ClawHubSearchResultLite) async { - guard self.gatewayConnected else { return } + guard self.liveGatewayConnected else { return } self.clawHubInstallSlug = result.slug self.clawHubErrorText = nil defer { self.clawHubInstallSlug = nil } @@ -656,7 +696,7 @@ extension AgentProTab { @MainActor func searchClawHubSkills() async { - guard self.gatewayConnected else { return } + guard self.liveGatewayConnected else { return } self.clawHubLoading = true self.clawHubErrorText = nil defer { self.clawHubLoading = false } @@ -675,6 +715,7 @@ extension AgentProTab { _ skill: SkillStatusEntryLite, action: () async throws -> String) async { + guard self.liveGatewayConnected else { return } let key = skill.effectiveSkillKey self.skillConfigBusyKeys.insert(key) self.skillConfigMessages[key] = nil @@ -697,6 +738,9 @@ extension AgentProTab { params: some Encodable, timeoutSeconds: Int) async throws -> Data { + guard self.liveGatewayConnected else { + throw SkillMutationError.liveGatewayUnavailable + } let data = try JSONEncoder().encode(params) guard let json = String(data: data, encoding: .utf8) else { throw SkillMutationError.invalidPatchPayload @@ -708,6 +752,9 @@ extension AgentProTab { } func requestConfigSnapshot() async throws -> ConfigSnapshotLite { + guard self.liveGatewayConnected else { + throw SkillMutationError.liveGatewayUnavailable + } let data = try await self.appModel.operatorSession.request( method: "config.get", paramsJSON: "{}", diff --git a/apps/ios/Sources/Design/AgentProTab.swift b/apps/ios/Sources/Design/AgentProTab.swift index 2fd3e3e64e58..b7a7411fe621 100644 --- a/apps/ios/Sources/Design/AgentProTab.swift +++ b/apps/ios/Sources/Design/AgentProTab.swift @@ -1,11 +1,15 @@ import OpenClawKit -import OpenClawProtocol import SwiftUI struct AgentProTab: View { @Environment(NodeAppModel.self) var appModel @Environment(\.colorScheme) var colorScheme @Environment(\.scenePhase) var scenePhase + let directRoute: AgentRoute? + let headerLeadingAction: OpenClawSidebarHeaderAction? + let headerTitle: String + let openSettings: (() -> Void)? + @State var navigationPath: [AgentRoute] = [] @State var overview: AgentOverviewSnapshot? @State var overviewErrorText: String? @State var overviewLoading: Bool = false @@ -31,8 +35,9 @@ struct AgentProTab: View { @State var cronActionStatusText: String? enum AgentRoute: Hashable { + case agents case skills - case nodes + case instances case cron case usage case dreaming @@ -63,8 +68,7 @@ struct AgentProTab: View { enum AgentRosterFilter: String, CaseIterable, Identifiable { case all case online - case busy - case idle + case ready var id: Self { self @@ -74,8 +78,7 @@ struct AgentProTab: View { switch self { case .all: "All" case .online: "Online" - case .busy: "Busy" - case .idle: "Idle" + case .ready: "Ready" } } } @@ -90,22 +93,19 @@ struct AgentProTab: View { enum AgentRosterState: Equatable { case online - case busy - case idle + case ready var title: String { switch self { case .online: "Online" - case .busy: "Busy" - case .idle: "Idle" + case .ready: "Ready" } } var color: Color { switch self { case .online: OpenClawBrand.ok - case .busy: OpenClawBrand.warn - case .idle: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0) + case .ready: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0) } } } @@ -124,8 +124,40 @@ struct AgentProTab: View { } } + init( + directRoute: AgentRoute? = nil, + headerLeadingAction: OpenClawSidebarHeaderAction? = nil, + headerTitle: String = "Agents", + openSettings: (() -> Void)? = nil) + { + self.directRoute = directRoute + self.headerLeadingAction = headerLeadingAction + self.headerTitle = headerTitle + self.openSettings = openSettings + } + var body: some View { - NavigationStack { + Group { + if let directRoute { + self.directDestination(for: directRoute) + } else { + self.overviewNavigation + } + } + .task(id: self.overviewTaskID) { + await self.refreshOverview(force: false) + } + .sheet(item: self.$skillEditorSelection) { selection in + if let skill = self.skillByKey(selection.id) { + self.skillEditorSheet(skill) + } else { + self.missingSkillEditorSheet + } + } + } + + private var overviewNavigation: some View { + NavigationStack(path: self.$navigationPath) { ZStack { OpenClawProBackground() ScrollView { @@ -142,22 +174,18 @@ struct AgentProTab: View { .refreshable { await self.refreshOverview(force: true) } - .safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset) } .navigationBarHidden(true) .navigationDestination(for: AgentRoute.self) { route in self.destination(for: route) } } - .task(id: self.overviewTaskID) { - await self.refreshOverview(force: false) - } - .sheet(item: self.$skillEditorSelection) { selection in - if let skill = self.skillByKey(selection.id) { - self.skillEditorSheet(skill) - } else { - self.missingSkillEditorSheet - } - } + } + + private func directDestination(for route: AgentRoute) -> some View { + self.destination(for: route) + .toolbar( + self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden, + for: .navigationBar) } } diff --git a/apps/ios/Sources/Design/ChatProTab.swift b/apps/ios/Sources/Design/ChatProTab.swift index a8362a74e148..11e57bd7ed85 100644 --- a/apps/ios/Sources/Design/ChatProTab.swift +++ b/apps/ios/Sources/Design/ChatProTab.swift @@ -6,6 +6,26 @@ struct ChatProTab: View { @Environment(NodeAppModel.self) private var appModel @Environment(\.colorScheme) private var colorScheme @State private var viewModel: OpenClawChatViewModel? + @State private var viewModelUsesAppleReviewDemoTransport = false + let headerLeadingAction: OpenClawSidebarHeaderAction? + let headerTitle: String? + let headerSubtitle: String? + let showsAgentBadge: Bool + let openSettings: (() -> Void)? + + init( + headerLeadingAction: OpenClawSidebarHeaderAction? = nil, + headerTitle: String? = nil, + headerSubtitle: String? = nil, + showsAgentBadge: Bool = true, + openSettings: (() -> Void)? = nil) + { + self.headerLeadingAction = headerLeadingAction + self.headerTitle = headerTitle + self.headerSubtitle = headerSubtitle + self.showsAgentBadge = showsAgentBadge + self.openSettings = openSettings + } var body: some View { NavigationStack { @@ -24,8 +44,10 @@ struct ChatProTab: View { assistantAvatarTint: OpenClawBrand.accent, showsAssistantAvatars: false, composerChrome: .clean, - messagePlaceholder: "Message \(self.agentDisplayName)...", + isComposerEnabled: self.gatewayConnected, + messagePlaceholder: self.messagePlaceholder, talkControl: self.talkControl) + .id(ObjectIdentifier(viewModel)) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } else { ProCard { @@ -41,7 +63,9 @@ struct ChatProTab: View { Spacer() } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .navigationBarHidden(true) } .task { @@ -50,6 +74,10 @@ struct ChatProTab: View { .onChange(of: self.appModel.chatSessionKey) { _, _ in self.syncChatViewModel() } + .onChange(of: self.appModel.isAppleReviewDemoModeEnabled) { _, _ in + self.syncChatViewModel() + self.viewModel?.refresh() + } .onChange(of: self.appModel.isOperatorGatewayConnected) { _, connected in guard connected else { return } self.syncChatViewModel() @@ -58,7 +86,30 @@ struct ChatProTab: View { } private var header: some View { - HStack(spacing: 11) { + OpenClawAdaptiveHeaderRow( + title: self.headerDisplayTitle, + subtitle: self.headerDisplaySubtitle, + titleFont: .headline.weight(.semibold), + subtitleFont: .caption, + subtitleLineLimit: 1) + { + HStack(spacing: 11) { + if let headerLeadingAction { + OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction) + } + self.headerIdentityBadge + } + } accessory: { + self.connectionPillButton + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + .padding(.top, 8) + .padding(.bottom, 4) + } + + @ViewBuilder + private var headerIdentityBadge: some View { + if self.showsAgentBadge { Text(self.agentBadge) .font(.system(size: self.agentBadge.count > 2 ? 13 : 16, weight: .bold, design: .rounded)) .foregroundStyle(.white) @@ -77,32 +128,36 @@ struct ChatProTab: View { endPoint: .bottomTrailing))) .overlay(Circle().strokeBorder(.white.opacity(0.18), lineWidth: 1)) .shadow(color: OpenClawBrand.accent.opacity(0.18), radius: 10, y: 5) - - VStack(alignment: .leading, spacing: 1) { - Text(self.agentDisplayName) - .font(.headline.weight(.semibold)) - .lineLimit(1) - Text("AI Assistant") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer(minLength: 8) - - self.connectionPill + } else { + ProIconBadge(systemName: "bubble.left", color: OpenClawBrand.accent) } - .padding(.horizontal, OpenClawProMetric.pagePadding) - .padding(.top, 8) - .padding(.bottom, 4) } private func syncChatViewModel() { let sessionKey = self.appModel.chatSessionKey + let usesDemoTransport = self.appModel.isAppleReviewDemoModeEnabled guard let viewModel else { + self.viewModelUsesAppleReviewDemoTransport = usesDemoTransport self.viewModel = OpenClawChatViewModel( sessionKey: sessionKey, - transport: IOSGatewayChatTransport(gateway: self.appModel.operatorSession), + transport: usesDemoTransport + ? AppleReviewDemoChatTransport() + : IOSGatewayChatTransport(gateway: self.appModel.operatorSession), + onSessionChanged: { sessionKey in + self.appModel.focusChatSession(sessionKey) + }, + diagnosticsLog: { message in + GatewayDiagnostics.log(message) + }) + return + } + if self.viewModelUsesAppleReviewDemoTransport != usesDemoTransport { + self.viewModelUsesAppleReviewDemoTransport = usesDemoTransport + self.viewModel = OpenClawChatViewModel( + sessionKey: sessionKey, + transport: usesDemoTransport + ? AppleReviewDemoChatTransport() + : IOSGatewayChatTransport(gateway: self.appModel.operatorSession), onSessionChanged: { sessionKey in self.appModel.focusChatSession(sessionKey) }, @@ -112,7 +167,7 @@ struct ChatProTab: View { return } guard viewModel.sessionKey != sessionKey else { return } - viewModel.switchSession(to: sessionKey) + viewModel.syncSession(to: sessionKey) } private var talkControl: OpenClawChatTalkControl { @@ -130,34 +185,95 @@ struct ChatProTab: View { } private var activeAgentID: String { - self.normalized(self.appModel.selectedAgentId) - ?? self.normalized(self.appModel.gatewayDefaultAgentId) + self.normalized(self.appModel.chatAgentId) ?? "main" } + @ViewBuilder + private var connectionPillButton: some View { + if let openSettings { + Button(action: openSettings) { + self.connectionPill + } + .buttonStyle(.plain) + .accessibilityHint("Opens Settings / Gateway") + } else { + self.connectionPill + } + } + private var connectionPill: some View { HStack(spacing: 6) { - ProStatusDot(color: self.gatewayConnected ? OpenClawBrand.ok : .orange) - Text(self.gatewayConnected ? "Connected" : "Connecting") + ProStatusDot(color: self.gatewayPillColor) + Text(Self.gatewayPillTitle(state: self.gatewayDisplayState, isGatewayUsable: self.gatewayConnected)) .font(.caption.weight(.semibold)) .lineLimit(1) } - .foregroundStyle(self.gatewayConnected ? OpenClawBrand.ok : .orange) + .foregroundStyle(self.gatewayPillColor) .padding(.horizontal, 10) .frame(height: 30) .background { Capsule() - .fill((self.gatewayConnected ? OpenClawBrand.ok : Color.orange).opacity(0.11)) + .fill(self.gatewayPillColor.opacity(0.11)) } .overlay { Capsule() - .strokeBorder((self.gatewayConnected ? OpenClawBrand.ok : Color.orange).opacity(0.16), lineWidth: 1) + .strokeBorder(self.gatewayPillColor.opacity(0.16), lineWidth: 1) } } private var gatewayConnected: Bool { - GatewayStatusBuilder.build(appModel: self.appModel) == .connected && - self.appModel.isOperatorGatewayConnected + guard self.gatewayDisplayState == .connected else { + return false + } + return self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected + } + + private var gatewayDisplayState: GatewayDisplayState { + GatewayStatusBuilder.build(appModel: self.appModel) + } + + private var gatewayPillColor: Color { + switch self.gatewayDisplayState { + case .connected: + self.gatewayConnected ? OpenClawBrand.ok : .secondary + case .connecting: + OpenClawBrand.accent + case .error: + OpenClawBrand.warn + case .disconnected: + .secondary + } + } + + nonisolated static func gatewayPillTitle(state: GatewayDisplayState, isGatewayUsable: Bool) -> String { + switch state { + case .connected: + isGatewayUsable ? "Connected" : "Unavailable" + case .connecting: + "Connecting" + case .error: + "Attention" + case .disconnected: + "Offline" + } + } + + private var messagePlaceholder: String { + self.gatewayConnected ? "Message \(self.agentDisplayName)..." : "Connect to a gateway" + } + + private var headerDisplayTitle: String { + self.normalized(self.headerTitle) + ?? Self.defaultHeaderTitle(showsAgentBadge: self.showsAgentBadge, agentDisplayName: self.agentDisplayName) + } + + private var headerDisplaySubtitle: String { + self.normalized(self.headerSubtitle) ?? "AI Assistant" + } + + nonisolated static func defaultHeaderTitle(showsAgentBadge: Bool, agentDisplayName: String) -> String { + showsAgentBadge ? agentDisplayName : "Chat" } private var chatUserAccent: Color { @@ -169,7 +285,7 @@ struct ChatProTab: View { } private var agentDisplayName: String { - self.normalized(self.activeAgent?.name) ?? self.appModel.activeAgentName + self.normalized(self.activeAgent?.name) ?? self.appModel.chatAgentName } private var agentBadge: String { diff --git a/apps/ios/Sources/Design/CommandCenterSupport.swift b/apps/ios/Sources/Design/CommandCenterSupport.swift index 3b1d2e0625d4..2125ba36d202 100644 --- a/apps/ios/Sources/Design/CommandCenterSupport.swift +++ b/apps/ios/Sources/Design/CommandCenterSupport.swift @@ -23,7 +23,7 @@ struct CommandPanel: View { tint: self.tint, isProminent: self.isProminent, padding: self.padding, - radius: 12) + radius: OpenClawProMetric.cardRadius) { self.content } @@ -34,40 +34,15 @@ struct CommandControlBackground: View { @Environment(\.colorScheme) private var colorScheme var body: some View { - LinearGradient( - colors: self.colorScheme == .dark ? self.darkColors : self.lightColors, - startPoint: .top, - endPoint: .bottom) + Color(uiColor: self.colorScheme == .dark ? .systemBackground : .systemGroupedBackground) .overlay(alignment: .top) { if self.colorScheme == .light { - LinearGradient( - colors: [ - Color.white.opacity(0.34), - Color.clear, - ], - startPoint: .topLeading, - endPoint: .bottomTrailing) - .frame(height: 260) + Color.white.opacity(0.20) + .frame(height: 140) } } .ignoresSafeArea() } - - private var darkColors: [Color] { - [ - Color(red: 12 / 255, green: 13 / 255, blue: 15 / 255), - Color(red: 7 / 255, green: 8 / 255, blue: 10 / 255), - Color(red: 4 / 255, green: 5 / 255, blue: 6 / 255), - ] - } - - private var lightColors: [Color] { - [ - Color(red: 247 / 255, green: 248 / 255, blue: 249 / 255), - Color(red: 251 / 255, green: 252 / 255, blue: 253 / 255), - .white, - ] - } } struct CommandSessionRow: View { @@ -114,19 +89,19 @@ struct CommandSessionRow: View { } } .padding(.horizontal, 10) - .padding(.vertical, 9) + .padding(.vertical, 8) .background { - RoundedRectangle(cornerRadius: 10, style: .continuous) + RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous) .fill(self.rowFill) .overlay { - RoundedRectangle(cornerRadius: 10, style: .continuous) + RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous) .strokeBorder(self.rowBorder, lineWidth: 1) } } } private var progressLabel: String { - guard let progress = self.item.progress else { + guard let progress = item.progress else { return self.item.state } if self.item.state == "offline" || self.item.state == "off" || self.item.state == "idle" { @@ -136,49 +111,39 @@ struct CommandSessionRow: View { } private var rowFill: Color { - self.colorScheme == .dark ? Color.white.opacity(0.035) : Color.black.opacity(0.025) + self.colorScheme == .dark ? Color.white.opacity(0.035) : Color(uiColor: .systemBackground) } private var rowBorder: Color { - self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.black.opacity(0.045) + Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.24 : 0.22) } } -struct CommandApprovalRow: View { - let item: CommandCenterTab.ApprovalItem +struct CommandViewMoreRow: View { + @Environment(\.colorScheme) private var colorScheme var body: some View { - HStack(spacing: 10) { - Image(systemName: self.item.icon) - .font(.caption.weight(.bold)) - .foregroundStyle(.white) - .frame(width: 30, height: 30) - .background { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(self.item.color) - } - VStack(alignment: .leading, spacing: 2) { - Text(self.item.title) - .font(.subheadline.weight(.semibold)) - .lineLimit(1) - Text(self.item.detail) - .font(.caption2.weight(.medium)) - .foregroundStyle(.secondary) - .lineLimit(1) + Text("View More") + .font(.subheadline.weight(.bold)) + .foregroundStyle(OpenClawBrand.accent) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background { + RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous) + .fill(self.rowFill) + .overlay { + RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous) + .strokeBorder(self.rowBorder, lineWidth: 1) + } } - Spacer(minLength: 8) - Text(self.item.priority) - .font(.caption.weight(.bold)) - .foregroundStyle(self.item.color) - .padding(.horizontal, 9) - .padding(.vertical, 5) - .background { - Capsule() - .fill(self.item.color.opacity(0.10)) - } - } - .padding(.horizontal, 8) - .padding(.vertical, 7) + } + + private var rowFill: Color { + self.colorScheme == .dark ? Color.white.opacity(0.035) : Color(uiColor: .systemBackground) + } + + private var rowBorder: Color { + Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.24 : 0.22) } } @@ -209,72 +174,13 @@ struct CommandEmptyStateRow: View { Spacer(minLength: 0) } .padding(.horizontal, 8) - .padding(.vertical, 9) - .background { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color.black.opacity(0.06)) - .overlay { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(Color.primary.opacity(0.055), lineWidth: 1) - } - } - } -} - -struct CommandTaskRow: View { - let item: CommandCenterTab.WorkItem - - var body: some View { - HStack(alignment: .center, spacing: 6) { - Text(self.item.title) - .font(.footnote.weight(.semibold)) - .lineLimit(1) - .minimumScaleFactor(0.80) - .frame(maxWidth: .infinity, minHeight: 20, alignment: .leading) - Text(self.item.detail) - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) - .lineLimit(1) - .minimumScaleFactor(0.78) - .frame(width: 64, alignment: .leading) - if let progress = self.item.progress { - ProProgressBar(progress: progress, color: self.item.color) - .frame(width: 56) - } - Text(self.item.state) - .font(.footnote.weight(.medium)) - .foregroundStyle(self.item.progress == nil ? self.item.color : .secondary) - .lineLimit(1) - .frame(width: self.item.progress == nil ? 58 : 34, alignment: .trailing) - } .padding(.vertical, 8) - } -} - -struct CommandLiveActivityRow: View { - let title: String - let value: String - let color: Color - - var body: some View { - HStack(spacing: 8) { - ProStatusDot(color: self.color) - Text(self.title) - .font(.subheadline.weight(.semibold)) - .lineLimit(1) - Spacer(minLength: 8) - Text(self.value) - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 10) - .padding(.vertical, 9) .background { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color.black.opacity(0.08)) + RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous) + .fill(Color(uiColor: .systemBackground)) .overlay { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(Color.primary.opacity(0.06), lineWidth: 1) + RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous) + .strokeBorder(Color(uiColor: .separator).opacity(0.22), lineWidth: 1) } } } diff --git a/apps/ios/Sources/Design/CommandCenterTab.swift b/apps/ios/Sources/Design/CommandCenterTab.swift index f0f81c192f66..9d42c049565e 100644 --- a/apps/ios/Sources/Design/CommandCenterTab.swift +++ b/apps/ios/Sources/Design/CommandCenterTab.swift @@ -2,10 +2,17 @@ import OpenClawChatUI import SwiftUI struct CommandCenterTab: View { + static let recentSessionsFetchLimit = 200 + @Environment(NodeAppModel.self) private var appModel @Environment(\.colorScheme) private var colorScheme + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.scenePhase) private var scenePhase - @State private var activeChatSessions: [OpenClawChatSessionEntry] = [] + @State private var defaultChatSessionEntry: OpenClawChatSessionEntry? + @State private var recentChatSessions: [OpenClawChatSessionEntry] = [] + var headerTitle: String = "OpenClaw" + var headerLeadingAction: OpenClawSidebarHeaderAction? + var showsHeaderMark: Bool = true var openChat: () -> Void var openSettings: () -> Void @@ -26,47 +33,88 @@ struct CommandCenterTab: View { let route: WorkRoute } - struct ApprovalItem: Identifiable { - let id: String - let icon: String - let title: String - let detail: String - let priority: String - let color: Color - } - var body: some View { NavigationStack { - ZStack { - CommandControlBackground() - self.commandAmbientOverlay - ScrollView { - VStack(alignment: .leading, spacing: 10) { - self.header - self.gatewayCard - self.pendingApprovals - self.activeTasks - self.liveActivity - self.startWorkAction + GeometryReader { geometry in + ZStack { + CommandControlBackground() + self.commandAmbientOverlay + ScrollView { + VStack(alignment: .leading, spacing: 14) { + self.header + self.gatewayCard + if Self.usesSplitSectionsLayout( + horizontalSizeClass: self.horizontalSizeClass, + containerWidth: geometry.size.width) + { + HStack(alignment: .top, spacing: 12) { + self.defaultChatSessionSection + .frame(maxWidth: .infinity, alignment: .topLeading) + self.recentSessions + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } else { + self.defaultChatSessionSection + .padding(.horizontal, OpenClawProMetric.pagePadding) + self.recentSessions + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + } + .padding(.top, 18) + .padding(.bottom, 18) } - .padding(.top, 16) - .padding(.bottom, 18) + .safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset) } - .safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset) } .navigationBarHidden(true) } - .task(id: self.activeSessionsRefreshID) { - await self.refreshActiveSessionsIfNeeded() + .task(id: self.recentSessionsRefreshID) { + await self.refreshRecentSessionsIfNeeded() } } + static func usesSplitSectionsLayout( + horizontalSizeClass: UserInterfaceSizeClass?, + containerWidth: CGFloat) -> Bool + { + guard horizontalSizeClass == .regular else { return false } + return containerWidth >= 1000 + } + + static func shouldShowHeaderMark( + hasLeadingAction: Bool, + showsHeaderMark: Bool) -> Bool + { + !hasLeadingAction && showsHeaderMark + } + private var header: some View { - HStack(alignment: .center, spacing: 11) { - OpenClawProMark(size: 31, shadowRadius: 9) - Text("OpenClaw") - .font(.system(size: 27, weight: .bold, design: .rounded)) - Spacer() + OpenClawAdaptiveHeaderRow( + title: self.headerTitle, + subtitle: self.gatewaySubtitle, + titleFont: .title3.weight(.semibold), + subtitleFont: .caption, + subtitleLineLimit: 1) + { + if let headerLeadingAction { + OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction) + } else if Self.shouldShowHeaderMark( + hasLeadingAction: headerLeadingAction != nil, + showsHeaderMark: self.showsHeaderMark) + { + OpenClawProMark(size: 28, shadowRadius: 5) + } + } accessory: { + Button(action: self.openSettings) { + ProCapsule( + title: self.gatewayStateText, + color: self.gatewayStatusColor, + icon: self.gatewayConnected ? "checkmark.circle.fill" : "wifi.slash") + } + .buttonStyle(.plain) + .accessibilityLabel("Gateway \(self.gatewayStateText)") + .accessibilityHint("Opens Settings / Gateway") } .padding(.horizontal, OpenClawProMetric.pagePadding) } @@ -94,7 +142,7 @@ struct CommandCenterTab: View { title: "Gateway", value: self.gatewayStateText, color: self.gatewayStatusColor, - icon: self.gatewayConnected ? "hourglass" : "wifi.slash") + icon: self.gatewayConnected ? "checkmark.circle.fill" : "wifi.slash") HStack(spacing: 0) { self.gatewayFact( @@ -152,160 +200,61 @@ struct CommandCenterTab: View { .padding(.horizontal, 10) } - private var pendingApprovals: some View { - self.pendingApprovalsContent - .padding(.horizontal, OpenClawProMetric.pagePadding) - } - - private var pendingApprovalsContent: some View { - CommandPanel( - tint: self.pendingApproval == nil ? nil : OpenClawBrand.warn, - isProminent: self.pendingApproval != nil, - padding: self.pendingApproval == nil ? 11 : 13) - { - VStack(alignment: .leading, spacing: 10) { + private var defaultChatSessionSection: some View { + CommandPanel(padding: 12) { + VStack(spacing: 10) { self.cardHeader( - title: "Pending approvals", - value: self.pendingApproval == nil ? nil : "Review requests ›", - color: OpenClawBrand.accentHot, - badgeValue: self.approvalItems.isEmpty ? nil : "\(self.approvalItems.count)") - - if self.approvalItems.isEmpty { - CommandEmptyStateRow( - icon: "checkmark.shield.fill", - title: "No approvals waiting", - detail: self - .gatewayConnected ? "Gateway requests will appear here." : "Connect to the gateway.") - } else { - VStack(spacing: 0) { - ForEach(Array(self.approvalItems.enumerated()), id: \.element.id) { index, item in - CommandApprovalRow(item: item) - if index < self.approvalItems.count - 1 { - Divider().padding(.leading, 48) - } - } - } - .padding(.vertical, 4) - .background { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(self.approvalRowsFill) - .overlay { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder( - Color.primary.opacity(self.colorScheme == .dark ? 0.08 : 0.04), - lineWidth: 1) - } - } - } - - if let pendingApproval { - HStack(spacing: 8) { - Button { - Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once") } - } label: { - Label("Allow", systemImage: "checkmark") - } - .buttonStyle(.borderedProminent) - .disabled(self.appModel.pendingExecApprovalPromptResolving) - - if pendingApproval.allowsAllowAlways { - Button { - Task { - await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-always") - } - } label: { - Label("Always", systemImage: "checkmark.shield") - } - .buttonStyle(.bordered) - .disabled(self.appModel.pendingExecApprovalPromptResolving) - } - - Button(role: .destructive) { - Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "deny") } - } label: { - Label("Deny", systemImage: "xmark") - } - .buttonStyle(.bordered) - .disabled(self.appModel.pendingExecApprovalPromptResolving) - - Spacer(minLength: 0) - } - .controlSize(.small) - } - } - } - } - - private var activeTasks: some View { - CommandPanel(padding: 0) { - VStack(spacing: 0) { - self.cardHeader( - title: "Active sessions", - value: self.activeSessionsSummaryText, - color: .secondary) - .padding(.horizontal, 12) - .padding(.top, 10) - .padding(.bottom, 3) - - VStack(spacing: 8) { - ForEach(self.visibleActiveSessionRows) { item in - Button { - self.open(item.route) - } label: { - CommandSessionRow(item: item) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 10) - .padding(.bottom, 10) - } - } - .padding(.horizontal, OpenClawProMetric.pagePadding) - } - - private var liveActivity: some View { - CommandPanel(padding: 0) { - VStack(spacing: 0) { - self.cardHeader( - title: "Live activity", + title: "Agent session", value: nil, color: OpenClawBrand.accent) - .padding(.horizontal, 12) - .padding(.top, 11) - .padding(.bottom, 3) - CommandLiveActivityRow( - title: self.liveActivityTitle, - value: self.liveActivityValue, - color: self.liveActivityColor) - .padding(.horizontal, 14) - .padding(.bottom, 10) + Button { + self.open(.chat(nil)) + } label: { + CommandSessionRow(item: self.defaultChatWorkItem) + } + .buttonStyle(.plain) } } - .padding(.horizontal, OpenClawProMetric.pagePadding) } - private var startWorkAction: some View { - CommandPanel(tint: OpenClawBrand.accent, isProminent: true, padding: 9) { - Button(action: self.openChat) { - Label("Start work", systemImage: "play.fill") - .font(.subheadline.weight(.bold)) - .foregroundStyle(.white) - .frame(maxWidth: .infinity) - .frame(height: 48) - .background { - RoundedRectangle(cornerRadius: 13, style: .continuous) - .fill(LinearGradient( - colors: [OpenClawBrand.accentHot, OpenClawBrand.accent], - startPoint: .topLeading, - endPoint: .bottomTrailing)) - .shadow(color: OpenClawBrand.accentHot.opacity(0.34), radius: 18, y: 8) + private var recentSessions: some View { + CommandPanel(padding: 12) { + VStack(spacing: 10) { + self.cardHeader( + title: "Recent sessions", + value: nil, + color: .secondary) + + if self.recentSessionPreviewRows.isEmpty { + CommandEmptyStateRow( + icon: self.gatewayConnected ? "bubble.left.and.text.bubble.right.fill" : "wifi.slash", + title: self.gatewayConnected ? "No recent sessions" : "Gateway offline", + detail: self + .gatewayConnected ? "Start a chat and it will appear here." : "Connect to the gateway.") + } else { + VStack(spacing: 8) { + ForEach(self.recentSessionPreviewRows) { item in + Button { + self.open(item.route) + } label: { + CommandSessionRow(item: item) + } + .buttonStyle(.plain) + } + + if self.hasMoreRecentSessions { + NavigationLink { + CommandSessionsScreen(openChat: self.openChat) + } label: { + CommandViewMoreRow() + } + .buttonStyle(.plain) + } } + } } - .buttonStyle(.plain) } - .padding(.horizontal, OpenClawProMetric.pagePadding) } private func cardHeader( @@ -318,7 +267,8 @@ struct CommandCenterTab: View { { HStack(spacing: 8) { Text(title) - .font(.subheadline.weight(.bold)) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) if let badgeValue { Text(badgeValue) .font(.caption2.weight(.bold)) @@ -378,164 +328,69 @@ struct CommandCenterTab: View { return "\(self.appModel.gatewayAgents.count)" } - private var activeSessionsSummaryText: String { - let count = self.activeSessionRows.count - if count == 0 { - return self.gatewayConnected ? "No sessions" : "Offline" - } - if self.sessionWorkItems.isEmpty { - return self.gatewayConnected ? "\(count) ready" : "Offline" - } - return "\(count) \(count == 1 ? "session" : "sessions")" + private var defaultChatWorkItem: WorkItem { + let isOpen = self.appModel.chatSessionKey == self.appModel.defaultChatSessionKey + return WorkItem( + id: "default-chat", + icon: isOpen ? "bubble.left.and.text.bubble.right.fill" : "bubble.left.fill", + title: self.appModel.activeAgentName, + detail: self.defaultChatActivityText, + state: isOpen ? "open" : "default", + trailing: "chat", + color: isOpen ? OpenClawBrand.accent : OpenClawBrand.ok, + progress: nil, + route: .chat(nil)) } - private var approvalItems: [ApprovalItem] { - if let pendingApproval { - return [ - ApprovalItem( - id: "pending-real", - icon: "terminal.fill", - title: pendingApproval.commandPreview ?? "Review gateway action", - detail: "Agent: \(self.appModel.activeAgentName)", - priority: self.appModel.pendingExecApprovalPromptResolving ? "Resolving" : "High", - color: OpenClawBrand.danger), - ApprovalItem( - id: "pending-context", - icon: "doc.text.fill", - title: pendingApproval.allowsAllowAlways ? "Permission can be saved" : "One-time approval", - detail: "Gateway request", - priority: pendingApproval.allowsAllowAlways ? "Medium" : "Review", - color: OpenClawBrand.warn), - ] + private var defaultChatActivityText: String { + guard let updatedAt = defaultChatSessionEntry?.updatedAt, updatedAt > 0 else { + return "No recent activity" } - - return [] + return Self.relativeTimeText(forMilliseconds: updatedAt) } - private var approvalRowsFill: Color { - self.colorScheme == .dark ? Color.black.opacity(0.12) : Color.black.opacity(0.022) - } - - private var activeSessionRows: [WorkItem] { + private var recentSessionRows: [WorkItem] { self.sessionItems } - private var visibleActiveSessionRows: [WorkItem] { - Array(self.activeSessionRows.prefix(3)) + private var recentSessionPreviewRows: [WorkItem] { + Array(self.recentSessionRows.prefix(3)) } - private var liveActivityTitle: String { - if let session = self.activeChatSessions.first(where: { !Self.isHiddenInternalSession($0.key) }) { - return "\(Self.sessionTitle(session)) updated" - } - if self.pendingApproval != nil { - return "Approval waiting" - } - return self.gatewayConnected ? "Gateway connected" : self.gatewayStateText + private var hasMoreRecentSessions: Bool { + self.sessionWorkItems.count > self.recentSessionPreviewRows.count } - private var liveActivityValue: String { - if let session = self.activeChatSessions.first(where: { !Self.isHiddenInternalSession($0.key) }), - let updatedAt = session.updatedAt, - updatedAt > 0 - { - return Self.relativeTimeText(forMilliseconds: updatedAt) - } - if self.pendingApproval != nil { - return "review" - } - return self.gatewayConnected ? self.gatewayAddressText : self.gatewayDisplayStatusValue - } - - private var liveActivityColor: Color { - if self.pendingApproval != nil { return OpenClawBrand.warn } - return self.gatewayConnected ? OpenClawBrand.ok : .secondary - } - - private var gatewayDisplayStatusValue: String { - let status = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) - return status.isEmpty ? self.gatewayStateText : status - } - - private var activeSessionsRefreshID: String { + private var recentSessionsRefreshID: String { [ - self.appModel.isOperatorGatewayConnected ? "connected" : "offline", + self.sessionListMode, self.appModel.chatSessionKey, self.scenePhase == .active ? "active" : "inactive", ].joined(separator: ":") } + private var sessionListAvailable: Bool { + self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected + } + + private var sessionListMode: String { + if self.appModel.isAppleReviewDemoModeEnabled { return "demo" } + return self.appModel.isOperatorGatewayConnected ? "operator" : "offline" + } + private var sessionItems: [WorkItem] { - let liveItems = self.sessionWorkItems - if !liveItems.isEmpty { return liveItems } - return self.defaultSessionItems + self.sessionWorkItems } private var sessionWorkItems: [WorkItem] { let currentSessionKey = self.appModel.chatSessionKey - return self.activeChatSessions - .filter { !Self.isHiddenInternalSession($0.key) } - .prefix(4) + return self.recentChatSessions + .filter { Self.isRecentChatSession($0.key, defaultSessionKey: self.appModel.defaultChatSessionKey) } .map { session in - let isCurrent = session.key == currentSessionKey - return WorkItem( - id: "chat-session-\(session.key)", - icon: isCurrent ? "bubble.left.and.text.bubble.right.fill" : "bubble.left.fill", - title: Self.sessionTitle(session), - detail: Self.sessionDetail(session), - state: isCurrent ? "current" : "recent", - trailing: "chat", - color: isCurrent ? OpenClawBrand.accent : OpenClawBrand.ok, - progress: nil, - route: .chat(session.key)) + Self.sessionWorkItem(for: session, currentSessionKey: currentSessionKey) } } - private var defaultSessionItems: [WorkItem] { - [ - WorkItem( - id: "main-chat", - icon: "bubble.left.and.text.bubble.right.fill", - title: "Main chat", - detail: self.appModel.activeAgentName, - state: self.gatewayConnected ? "ready" : "offline", - trailing: "session", - color: self.gatewayConnected ? OpenClawBrand.ok : .secondary, - progress: nil, - route: .chat(self.appModel.chatSessionKey)), - WorkItem( - id: "talk-mode", - icon: "waveform", - title: "Talk", - detail: self.appModel.talkMode.statusText, - state: self.appModel.talkMode.isEnabled ? "active" : "off", - trailing: "voice", - color: self.appModel.talkMode.isEnabled ? OpenClawBrand.ok : .secondary, - progress: nil, - route: .settings), - WorkItem( - id: "device-capture", - icon: self.appModel.screenRecordActive ? "record.circle.fill" : "display", - title: "Device capture", - detail: self.appModel.screenRecordActive ? "Screen capture is active" : "Screen and device tools", - state: self.appModel.screenRecordActive ? "running" : "idle", - trailing: "device", - color: self.appModel.screenRecordActive ? OpenClawBrand.warn : .secondary, - progress: nil, - route: .settings), - WorkItem( - id: "agent-roster", - icon: "person.2.fill", - title: "Agents", - detail: self.gatewayConnected ? "\(self.appModel.gatewayAgents.count) available" : "Roster unavailable", - state: self.gatewayConnected ? "online" : "offline", - trailing: "gateway", - color: self.gatewayConnected ? OpenClawBrand.ok : .secondary, - progress: nil, - route: .settings), - ] - } - private func open(_ route: WorkRoute) { switch route { case let .chat(sessionKey): @@ -546,42 +401,55 @@ struct CommandCenterTab: View { } } - private func refreshActiveSessionsIfNeeded() async { + private func refreshRecentSessionsIfNeeded() async { guard self.scenePhase == .active else { return } - guard self.appModel.isOperatorGatewayConnected else { - if !self.activeChatSessions.isEmpty { - self.activeChatSessions = [] + guard self.sessionListAvailable else { + if self.defaultChatSessionEntry != nil { + self.defaultChatSessionEntry = nil + } + if !self.recentChatSessions.isEmpty { + self.recentChatSessions = [] } return } do { - let transport = IOSGatewayChatTransport(gateway: appModel.operatorSession) - let response = try await transport.listSessions(limit: 12) - self.activeChatSessions = Self.sessionChoices( + let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled + ? AppleReviewDemoChatTransport() + : IOSGatewayChatTransport(gateway: self.appModel.operatorSession) + let response = try await transport.listSessions(limit: Self.recentSessionsFetchLimit) + self.defaultChatSessionEntry = response.sessions.first { + $0.key == self.appModel.defaultChatSessionKey + } + self.recentChatSessions = Self.sessionChoices( response.sessions, - currentSessionKey: self.appModel.chatSessionKey) + currentSessionKey: self.appModel.chatSessionKey, + defaultSessionKey: self.appModel.defaultChatSessionKey) } catch { - self.activeChatSessions = [] + self.defaultChatSessionEntry = nil + self.recentChatSessions = [] } } private static func sessionChoices( _ sessions: [OpenClawChatSessionEntry], - currentSessionKey: String) -> [OpenClawChatSessionEntry] + currentSessionKey: String, + defaultSessionKey: String) -> [OpenClawChatSessionEntry] { let sorted = sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) } var result: [OpenClawChatSessionEntry] = [] var included = Set() - if let current = sorted.first(where: { $0.key == currentSessionKey }) { + if Self.isRecentChatSession(currentSessionKey, defaultSessionKey: defaultSessionKey), + let current = sorted.first(where: { $0.key == currentSessionKey }) + { result.append(current) included.insert(current.key) } for session in sorted { guard !included.contains(session.key) else { continue } - guard !Self.isHiddenInternalSession(session.key) else { continue } + guard Self.isRecentChatSession(session.key, defaultSessionKey: defaultSessionKey) else { continue } result.append(session) included.insert(session.key) if result.count >= 4 { break } @@ -590,7 +458,24 @@ struct CommandCenterTab: View { return result } - private static func sessionTitle(_ session: OpenClawChatSessionEntry) -> String { + static func sessionWorkItem( + for session: OpenClawChatSessionEntry, + currentSessionKey: String) -> WorkItem + { + let isCurrent = session.key == currentSessionKey + return WorkItem( + id: "chat-session-\(session.key)", + icon: isCurrent ? "bubble.left.and.text.bubble.right.fill" : "bubble.left.fill", + title: Self.sessionTitle(session), + detail: Self.sessionDetail(session), + state: isCurrent ? "open" : "recent", + trailing: "chat", + color: isCurrent ? OpenClawBrand.accent : OpenClawBrand.ok, + progress: nil, + route: .chat(session.key)) + } + + fileprivate static func sessionTitle(_ session: OpenClawChatSessionEntry) -> String { if let title = redactedSessionTitle(for: session.key) { return title } @@ -606,7 +491,7 @@ struct CommandCenterTab: View { return session.key } - private static func redactedSessionTitle(for key: String) -> String? { + fileprivate static func redactedSessionTitle(for key: String) -> String? { let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) let lowercased = trimmed.lowercased() guard !trimmed.isEmpty else { return nil } @@ -625,7 +510,7 @@ struct CommandCenterTab: View { return nil } - private static func humanizedSessionKey(_ key: String) -> String? { + fileprivate static func humanizedSessionKey(_ key: String) -> String? { let words = key .replacingOccurrences(of: "_", with: "-") .split(separator: "-") @@ -645,14 +530,14 @@ struct CommandCenterTab: View { .joined(separator: " ") } - private static func sessionDetail(_ session: OpenClawChatSessionEntry) -> String { + fileprivate static func sessionDetail(_ session: OpenClawChatSessionEntry) -> String { if let updatedAt = session.updatedAt, updatedAt > 0 { return self.relativeTimeText(forMilliseconds: updatedAt) } return session.key } - private static func relativeTimeText(forMilliseconds milliseconds: Double) -> String { + fileprivate static func relativeTimeText(forMilliseconds milliseconds: Double) -> String { let date = Date(timeIntervalSince1970: milliseconds / 1000) let formatter = RelativeDateTimeFormatter() formatter.dateTimeStyle = .numeric @@ -660,12 +545,53 @@ struct CommandCenterTab: View { return formatter.localizedString(for: date, relativeTo: .now) } - private static func isHiddenInternalSession(_ key: String) -> Bool { + fileprivate nonisolated static func isHiddenInternalSession(_ key: String) -> Bool { let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return false } return trimmed == "onboarding" || trimmed.hasSuffix(":onboarding") } + nonisolated static func isRecentChatSession(_ key: String, defaultSessionKey: String) -> Bool { + let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + if trimmed == defaultSessionKey { return false } + let normalized = trimmed.lowercased() + let defaultBase = self.sessionBaseKey(defaultSessionKey) + if !normalized.contains(":"), + self.isDirectSessionBase(normalized, defaultBase: defaultBase) + { + return false + } + if self.isHiddenInternalSession(trimmed) { return false } + return !self.isAgentDeviceSession(trimmed, defaultSessionKey: defaultSessionKey) + } + + private nonisolated static func isAgentDeviceSession(_ key: String, defaultSessionKey: String) -> Bool { + let parts = key + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: ":", omittingEmptySubsequences: false) + guard parts.count >= 3, parts[0].lowercased() == "agent" else { return false } + guard parts.count == 3 || parts[3].lowercased() == "thread" else { return false } + + let base = String(parts[2]).trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let defaultKey = self.sessionBaseKey(defaultSessionKey) + return self.isDirectSessionBase(base, defaultBase: defaultKey) + } + + private nonisolated static func isDirectSessionBase(_ base: String, defaultBase: String) -> Bool { + base == defaultBase || base == "main" || base == "global" || base.hasPrefix("node-") + } + + private nonisolated static func sessionBaseKey(_ key: String) -> String { + let parts = key + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: ":", omittingEmptySubsequences: false) + guard parts.count >= 3, parts[0].lowercased() == "agent" else { + return key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + return String(parts[2]).trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + private var gatewaySubtitle: String { if let server = normalized(appModel.gatewayServerName) { return "\(self.appModel.activeAgentName) on \(server)" @@ -676,10 +602,6 @@ struct CommandCenterTab: View { return self.appModel.gatewayDisplayStatusText } - private var pendingApproval: NodeAppModel.ExecApprovalPrompt? { - self.appModel.pendingExecApprovalPrompt - } - private func normalized(_ value: String?) -> String? { Self.normalized(value) } @@ -690,3 +612,178 @@ struct CommandCenterTab: View { return trimmed.isEmpty ? nil : trimmed } } + +struct CommandSessionsScreen: View { + @Environment(NodeAppModel.self) private var appModel + @Environment(\.dismiss) private var dismiss + @State private var sessions: [OpenClawChatSessionEntry] = [] + @State private var isLoading = false + @State private var loadErrorText: String? + let headerLeadingAction: OpenClawSidebarHeaderAction? + let openChat: () -> Void + + init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, openChat: @escaping () -> Void) { + self.headerLeadingAction = headerLeadingAction + self.openChat = openChat + } + + var body: some View { + ZStack { + CommandControlBackground() + ScrollView { + VStack(alignment: .leading, spacing: 10) { + self.header + self.sessionsPanel + } + .padding(.top, 16) + .padding(.bottom, 18) + } + .safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset) + } + .navigationTitle("Sessions") + .navigationBarTitleDisplayMode(.inline) + .task(id: self.refreshID) { + await self.refreshSessions() + } + } + + private var header: some View { + HStack(alignment: .top, spacing: 12) { + if let headerLeadingAction { + OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Sessions") + .font(.system(size: 27, weight: .bold, design: .rounded)) + Text(self.headerDetail) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + + private var sessionsPanel: some View { + CommandPanel(padding: 0) { + VStack(spacing: 0) { + HStack(spacing: 8) { + Text("Recent sessions") + .font(.subheadline.weight(.bold)) + Spacer(minLength: 8) + if self.isLoading { + ProgressView() + .controlSize(.small) + } + } + .padding(.horizontal, 12) + .padding(.top, 10) + .padding(.bottom, 3) + + if let loadErrorText { + CommandEmptyStateRow( + icon: "exclamationmark.triangle.fill", + title: "Sessions unavailable", + detail: loadErrorText) + .padding(.horizontal, 10) + .padding(.bottom, 10) + } else if self.sessionRows.isEmpty { + CommandEmptyStateRow( + icon: self.appModel + .isCommandSessionListAvailable ? "bubble.left.and.text.bubble.right.fill" : "wifi.slash", + title: self.appModel.isCommandSessionListAvailable ? "No recent sessions" : "Gateway offline", + detail: self.appModel + .isCommandSessionListAvailable ? "Start a chat and it will appear here." : + "Connect to the gateway.") + .padding(.horizontal, 10) + .padding(.bottom, 10) + } else { + VStack(spacing: 8) { + ForEach(self.sessionRows) { item in + Button { + self.open(item) + } label: { + CommandSessionRow(item: item) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 10) + .padding(.bottom, 10) + } + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + + private var headerDetail: String { + if self.isLoading, self.sessions.isEmpty { return "Loading recent sessions" } + let count = self.sessionRows.count + if count == 0 { + return self.appModel.isCommandSessionListAvailable ? "No recent sessions" : "Gateway offline" + } + return "\(count) \(count == 1 ? "session" : "sessions")" + } + + private var sessionRows: [CommandCenterTab.WorkItem] { + self.sessions + .filter { CommandCenterTab.isRecentChatSession( + $0.key, + defaultSessionKey: self.appModel.defaultChatSessionKey) } + .sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) } + .map { + CommandCenterTab.sessionWorkItem( + for: $0, + currentSessionKey: self.appModel.chatSessionKey) + } + } + + private var refreshID: String { + self.appModel.commandSessionListMode + } + + private func open(_ item: CommandCenterTab.WorkItem) { + switch item.route { + case let .chat(sessionKey): + self.appModel.openChat(sessionKey: sessionKey) + self.dismiss() + self.openChat() + case .settings: + break + } + } + + private func refreshSessions() async { + guard self.appModel.isCommandSessionListAvailable else { + self.sessions = [] + self.loadErrorText = nil + return + } + + self.isLoading = true + self.loadErrorText = nil + defer { self.isLoading = false } + + do { + let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled + ? AppleReviewDemoChatTransport() + : IOSGatewayChatTransport(gateway: self.appModel.operatorSession) + let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit) + self.sessions = response.sessions + } catch { + self.sessions = [] + self.loadErrorText = "Try again after the gateway reconnects." + } + } +} + +extension NodeAppModel { + fileprivate var isCommandSessionListAvailable: Bool { + self.isAppleReviewDemoModeEnabled || self.isOperatorGatewayConnected + } + + fileprivate var commandSessionListMode: String { + if self.isAppleReviewDemoModeEnabled { return "demo" } + return self.isOperatorGatewayConnected ? "operator" : "offline" + } +} diff --git a/apps/ios/Sources/Design/IPadActivityScreen.swift b/apps/ios/Sources/Design/IPadActivityScreen.swift new file mode 100644 index 000000000000..3b697e508eb2 --- /dev/null +++ b/apps/ios/Sources/Design/IPadActivityScreen.swift @@ -0,0 +1,244 @@ +import OpenClawChatUI +import OpenClawKit +import SwiftUI + +struct IPadActivityScreen: View { + @Environment(NodeAppModel.self) private var appModel + @Environment(\.scenePhase) private var scenePhase + @State private var sessions: [OpenClawChatSessionEntry] = [] + @State private var isLoading = false + @State private var loadErrorText: String? + let headerLeadingAction: OpenClawSidebarHeaderAction? + let openChat: () -> Void + let openSettings: () -> Void + + init( + headerLeadingAction: OpenClawSidebarHeaderAction? = nil, + openChat: @escaping () -> Void, + openSettings: @escaping () -> Void) + { + self.headerLeadingAction = headerLeadingAction + self.openChat = openChat + self.openSettings = openSettings + } + + var body: some View { + IPadSidebarScreenChrome( + title: "Activity", + subtitle: "Live device and gateway activity.", + headerLeadingAction: self.headerLeadingAction, + gatewayAction: self.openSettings) + { + ProMetricGrid(metrics: self.metrics) + self.activityFeed + } + .task(id: self.refreshID) { + await self.refreshSessions() + } + .refreshable { + await self.refreshSessions() + } + } + + private var metrics: [ProMetric] { + [ + ProMetric( + icon: self.gatewayConnected ? "checkmark.circle.fill" : "wifi.slash", + title: "Gateway", + value: self.gatewayStateText, + color: self.gatewayConnected ? OpenClawBrand.ok : .secondary), + ProMetric( + icon: "person.2.fill", + title: "Agents", + value: self.gatewayConnected ? "\(self.appModel.gatewayAgents.count)" : "offline", + color: OpenClawBrand.accent), + ProMetric( + icon: "bubble.left.and.text.bubble.right", + title: "Sessions", + value: self.isLoading ? "..." : "\(self.sessionRows.count)", + color: OpenClawBrand.accentHot), + ] + } + + private var activityFeed: some View { + ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) { + VStack(spacing: 0) { + ProPanelHeader( + title: "Recent activity", + value: self.isLoading ? "Loading" : nil, + actionTitle: "Refresh", + action: { + Task { await self.refreshSessions() } + }) + + if let pendingExecApprovalPrompt = self.appModel.pendingExecApprovalPrompt { + ProStatusRow( + icon: "hand.raised.fill", + title: "Approval needed", + detail: pendingExecApprovalPrompt.commandPreview ?? pendingExecApprovalPrompt.commandText, + value: "pending", + color: OpenClawBrand.warn, + actionTitle: nil, + action: nil) + Divider().padding(.leading, 58) + } + + ProStatusRow( + icon: self.gatewayConnected ? "network" : "wifi.slash", + title: "Gateway", + detail: self.gatewayDetailText, + value: self.gatewayStateText.lowercased(), + color: self.gatewayConnected ? OpenClawBrand.ok : .secondary, + actionTitle: self.gatewayConnected ? nil : "Settings", + action: self.gatewayConnected ? nil : self.openSettings) + + Divider().padding(.leading, 58) + + ProStatusRow( + icon: "square.and.arrow.down", + title: "Share intake", + detail: self.appModel.lastShareEventText, + value: "iPad", + color: OpenClawBrand.accent, + actionTitle: nil, + action: nil) + + if self.isLoading, self.sessions.isEmpty { + Divider().padding(.leading, 58) + ProStatusRow( + icon: "hourglass", + title: "Loading sessions", + detail: "Fetching recent activity from the gateway.", + value: "loading", + color: OpenClawBrand.accent, + actionTitle: nil, + action: nil) + } else if let loadErrorText { + Divider().padding(.leading, 58) + ProStatusRow( + icon: "exclamationmark.triangle.fill", + title: "Sessions unavailable", + detail: loadErrorText, + value: "error", + color: OpenClawBrand.warn, + actionTitle: nil, + action: nil) + } else if self.sessionRows.isEmpty { + Divider().padding(.leading, 58) + ProStatusRow( + icon: "bubble.left.and.text.bubble.right", + title: self.sessionsAvailable ? "No recent sessions" : "Session activity offline", + detail: self.sessionsAvailable + ? "Start a chat and it will appear here." + : "Connect to the gateway to load recent chat activity.", + value: self.sessionsAvailable ? "empty" : "offline", + color: .secondary, + actionTitle: self.sessionsAvailable ? "Chat" : nil, + action: self.sessionsAvailable ? self.openChat : nil) + } else { + ForEach(self.sessionRows) { row in + Divider().padding(.leading, 58) + ProStatusRow( + icon: row.icon, + title: row.title, + detail: row.detail, + value: row.state, + color: row.color, + actionTitle: "Open", + action: { + self.open(row) + }) + } + } + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + + private var refreshID: String { + [ + self.sessionsMode, + self.appModel.chatSessionKey, + self.scenePhase == .active ? "active" : "inactive", + ].joined(separator: ":") + } + + private var gatewayConnected: Bool { + GatewayStatusBuilder.build(appModel: self.appModel) == .connected + } + + private var gatewayStateText: String { + guard !self.gatewayConnected else { return "Online" } + let status = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) + return status.isEmpty ? "Offline" : status + } + + private var gatewayDetailText: String { + self.normalized(self.appModel.gatewayRemoteAddress) + ?? self.normalized(self.appModel.gatewayServerName) + ?? "No gateway connection" + } + + private var sessionsAvailable: Bool { + self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected + } + + private var sessionsMode: String { + if self.appModel.isAppleReviewDemoModeEnabled { return "demo" } + return self.appModel.isOperatorGatewayConnected ? "operator" : "offline" + } + + private var sessionRows: [CommandCenterTab.WorkItem] { + self.sessions + .filter { CommandCenterTab.isRecentChatSession( + $0.key, + defaultSessionKey: self.appModel.defaultChatSessionKey) } + .sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) } + .prefix(8) + .map { + CommandCenterTab.sessionWorkItem( + for: $0, + currentSessionKey: self.appModel.chatSessionKey) + } + } + + private func refreshSessions() async { + guard self.scenePhase == .active else { return } + guard self.sessionsAvailable else { + self.sessions = [] + self.loadErrorText = nil + return + } + + self.isLoading = true + self.loadErrorText = nil + defer { self.isLoading = false } + + do { + let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled + ? AppleReviewDemoChatTransport() + : IOSGatewayChatTransport(gateway: self.appModel.operatorSession) + let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit) + self.sessions = response.sessions + } catch { + self.sessions = [] + self.loadErrorText = "Try again after the gateway reconnects." + } + } + + private func open(_ item: CommandCenterTab.WorkItem) { + switch item.route { + case let .chat(sessionKey): + self.appModel.openChat(sessionKey: sessionKey) + self.openChat() + case .settings: + self.openSettings() + } + } + + private func normalized(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/apps/ios/Sources/Design/IPadSidebarFeaturePreviews.swift b/apps/ios/Sources/Design/IPadSidebarFeaturePreviews.swift new file mode 100644 index 000000000000..30e3d13b4b1f --- /dev/null +++ b/apps/ios/Sources/Design/IPadSidebarFeaturePreviews.swift @@ -0,0 +1,672 @@ +import SwiftUI + +#if DEBUG +#Preview("Activity states") { + IPadActivityStatesPreview() +} + +#Preview("Workboard states") { + IPadWorkboardStatesPreview() +} + +#Preview("Skill Workshop states") { + IPadSkillWorkshopStatesPreview() +} + +#Preview( + "Skill Workshop iPad kanban lanes", + traits: .fixedLayout(width: 1180, height: 820)) +{ + IPadSkillWorkshopKanbanPreview() +} + +#Preview("Workboard phone queue rows") { + IPadWorkboardCompactRowsPreview() +} + +#Preview("Skill Workshop phone queue rows") { + IPadSkillWorkshopCompactRowsPreview() +} + +#Preview( + "Workboard phone landscape", + traits: .fixedLayout(width: 852, height: 393), + .landscapeLeft) +{ + IPadSidebarTaskScreenPreviewHost { + IPadWorkboardScreen(openChat: {}, openSettings: {}) + } +} + +#Preview( + "Skill Workshop phone landscape", + traits: .fixedLayout(width: 852, height: 393), + .landscapeLeft) +{ + IPadSidebarTaskScreenPreviewHost { + IPadSkillWorkshopScreen(openSettings: {}) + } +} + +private struct IPadWorkboardCompactRowsPreview: View { + private let statuses = ["todo", "ready", "running", "review", "blocked", "done"] + private let cards = IPadWorkboardPreviewFixtures.cards + + var body: some View { + ZStack { + OpenClawProBackground() + ScrollView { + VStack(alignment: .leading, spacing: 12) { + self.previewHeader + ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) { + VStack(spacing: 0) { + ProPanelHeader( + title: "Queue", + value: "\(self.cards.count)", + actionTitle: nil, + action: nil) + ForEach(Array(self.cards.enumerated()), id: \.element.id) { index, card in + if index > 0 { + Divider().padding(.leading, 58) + } + IPadWorkboardQueueRow( + card: card, + statuses: self.statuses, + isBusy: card.id == "preview-running", + inspect: {}, + openSession: {}, + move: { _ in }, + archive: {}) + } + } + } + + ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) { + ProStatusRow( + icon: "tray", + title: "No cards", + detail: "Create a card or change the filter.", + value: "empty", + color: .secondary, + actionTitle: nil, + action: nil) + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + .padding(.vertical, 18) + } + } + .environment(\.horizontalSizeClass, .compact) + .environment(\.verticalSizeClass, .regular) + } + + private var previewHeader: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Phone queue") + .font(.headline) + Text("Tap for detail, swipe or long-press for card actions.") + .font(.caption) + .foregroundStyle(.secondary) + } + } +} + +private struct IPadSkillWorkshopCompactRowsPreview: View { + private let proposals = IPadSkillWorkshopPreviewFixtures.proposals + + var body: some View { + ZStack { + OpenClawProBackground() + ScrollView { + VStack(alignment: .leading, spacing: 12) { + self.previewHeader + ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) { + VStack(spacing: 0) { + ProPanelHeader( + title: "Queue", + value: "\(self.proposals.count)", + actionTitle: nil, + action: nil) + ForEach(Array(self.proposals.enumerated()), id: \.element.id) { index, proposal in + if index > 0 { + Divider().padding(.leading, 58) + } + IPadSkillProposalRow( + proposal: proposal, + isSelected: proposal.id == "preview-pending", + isBusy: proposal.id == "preview-held") + } + } + } + + ProCard(radius: OpenClawProMetric.cardRadius) { + ProStatusRow( + icon: "hammer", + title: "No proposals", + detail: "New proposals will appear here when agents draft skills.", + value: "empty", + color: .secondary, + actionTitle: nil, + action: nil) + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + .padding(.vertical, 18) + } + } + .environment(\.horizontalSizeClass, .compact) + .environment(\.verticalSizeClass, .regular) + } + + private var previewHeader: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Phone proposals") + .font(.headline) + Text("Tap for detail, swipe or long-press for proposal actions.") + .font(.caption) + .foregroundStyle(.secondary) + } + } +} + +private struct IPadSidebarTaskScreenPreviewHost: View { + @State private var appModel = NodeAppModel() + @ViewBuilder var content: Content + + var body: some View { + NavigationStack { + self.content + } + .environment(self.appModel) + .environment(\.horizontalSizeClass, .regular) + .environment(\.verticalSizeClass, .compact) + } +} + +private struct IPadActivityStatesPreview: View { + private let connectedSessions = [ + CommandCenterTab.WorkItem( + id: "preview-main", + icon: "bubble.left.and.text.bubble.right", + title: "Main", + detail: "Updated just now", + state: "active", + trailing: "open", + color: OpenClawBrand.ok, + progress: nil, + route: .chat("main")), + CommandCenterTab.WorkItem( + id: "preview-ipad-audit", + icon: "bubble.left.and.text.bubble.right", + title: "iPad audit", + detail: "Updated 8m ago", + state: "recent", + trailing: "open", + color: OpenClawBrand.accent, + progress: nil, + route: .chat("ipad-audit")), + ] + + var body: some View { + ZStack { + OpenClawProBackground() + ScrollView { + VStack(alignment: .leading, spacing: 18) { + self.previewHeader("Connected") + self.activityCard( + gatewayTitle: "Gateway", + gatewayDetail: "tailscale.local:18789", + gatewayValue: "online", + gatewayColor: OpenClawBrand.ok, + sessionRows: self.connectedSessions, + tailRows: []) + + self.previewHeader("Loading") + self.activityCard( + gatewayTitle: "Gateway", + gatewayDetail: "Fetching recent activity from the gateway.", + gatewayValue: "online", + gatewayColor: OpenClawBrand.ok, + sessionRows: [], + tailRows: [ + ActivityPreviewRow( + icon: "hourglass", + title: "Loading sessions", + detail: "Fetching recent activity from the gateway.", + value: "loading", + color: OpenClawBrand.accent), + ]) + + self.previewHeader("Empty") + self.activityCard( + gatewayTitle: "Gateway", + gatewayDetail: "tailscale.local:18789", + gatewayValue: "online", + gatewayColor: OpenClawBrand.ok, + sessionRows: [], + tailRows: [ + ActivityPreviewRow( + icon: "bubble.left.and.text.bubble.right", + title: "No recent sessions", + detail: "Start a chat and it will appear here.", + value: "empty", + color: .secondary), + ]) + + self.previewHeader("Error") + self.activityCard( + gatewayTitle: "Gateway", + gatewayDetail: "No gateway connection", + gatewayValue: "offline", + gatewayColor: .secondary, + sessionRows: [], + tailRows: [ + ActivityPreviewRow( + icon: "exclamationmark.triangle.fill", + title: "Sessions unavailable", + detail: "Try again after the gateway reconnects.", + value: "error", + color: OpenClawBrand.warn), + ]) + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + .padding(.vertical, 18) + } + } + } + + private func previewHeader(_ title: String) -> some View { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + } + + private func activityCard( + gatewayTitle: String, + gatewayDetail: String, + gatewayValue: String, + gatewayColor: Color, + sessionRows: [CommandCenterTab.WorkItem], + tailRows: [ActivityPreviewRow]) -> some View + { + ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) { + VStack(spacing: 0) { + ProPanelHeader( + title: "Recent activity", + value: nil, + actionTitle: "Refresh", + action: {}) + ProStatusRow( + icon: gatewayValue == "online" ? "network" : "wifi.slash", + title: gatewayTitle, + detail: gatewayDetail, + value: gatewayValue, + color: gatewayColor, + actionTitle: gatewayValue == "online" ? nil : "Settings", + action: {}) + Divider().padding(.leading, 58) + ProStatusRow( + icon: "square.and.arrow.down", + title: "Share intake", + detail: "No share events yet.", + value: "iPad", + color: OpenClawBrand.accent, + actionTitle: nil, + action: nil) + ForEach(sessionRows) { row in + Divider().padding(.leading, 58) + ProStatusRow( + icon: row.icon, + title: row.title, + detail: row.detail, + value: row.state, + color: row.color, + actionTitle: "Open", + action: {}) + } + ForEach(tailRows) { row in + Divider().padding(.leading, 58) + ProStatusRow( + icon: row.icon, + title: row.title, + detail: row.detail, + value: row.value, + color: row.color, + actionTitle: nil, + action: nil) + } + } + } + } + + private struct ActivityPreviewRow: Identifiable { + let id = UUID() + let icon: String + let title: String + let detail: String + let value: String + let color: Color + } +} + +private struct IPadWorkboardStatesPreview: View { + private let statuses = ["todo", "running", "review"] + private let connectedCards = IPadWorkboardPreviewFixtures.cards + var body: some View { + ZStack { + OpenClawProBackground() + ScrollView { + VStack(alignment: .leading, spacing: 18) { + self.previewHeader("Connected") + self.connectedBoard + + self.previewHeader("Empty") + IPadWorkboardKanbanColumn( + status: "todo", + cards: [], + statuses: self.statuses, + busyCardID: nil, + openSession: { _ in }, + inspect: { _ in }, + move: { _, _ in }, + archive: { _ in }) + .frame(maxWidth: 320) + + self.previewHeader("Loading") + ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) { + ProStatusRow( + icon: "arrow.clockwise", + title: "Loading cards", + detail: "Refreshing the workboard from the gateway.", + value: "loading", + color: OpenClawBrand.accent, + actionTitle: nil, + action: nil) + } + + self.previewHeader("Error") + ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) { + ProStatusRow( + icon: "exclamationmark.triangle", + title: "Cards unavailable", + detail: "Check the gateway connection, then refresh.", + value: "error", + color: OpenClawBrand.warn, + actionTitle: "Retry", + action: {}) + } + } + .padding(18) + } + } + } + + private var connectedBoard: some View { + ScrollView(.horizontal) { + HStack(alignment: .top, spacing: 12) { + ForEach(self.statuses, id: \.self) { status in + IPadWorkboardKanbanColumn( + status: status, + cards: self.connectedCards.filter { $0.status == status }, + statuses: self.statuses, + busyCardID: nil, + openSession: { _ in }, + inspect: { _ in }, + move: { _, _ in }, + archive: { _ in }) + .frame(width: 282) + } + } + } + } + + private func previewHeader(_ title: String) -> some View { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + } +} + +private enum IPadWorkboardPreviewFixtures { + static let cards = [ + IPadWorkboardCard( + id: "preview-todo", + title: "Prep iPad sidebar audit", + notes: "Confirm portrait drawer behavior before device install.", + status: "todo", + priority: "normal", + labels: ["iPad", "UI"], + agentId: "main", + sessionKey: nil, + position: 0, + updatedAt: nil, + metadata: nil), + IPadWorkboardCard( + id: "preview-running", + title: "Verify phone workboard queue", + notes: "Single-list compact flow with detail sheet actions.", + status: "running", + priority: "high", + labels: ["phone"], + agentId: "main", + sessionKey: "session-preview", + position: 1, + updatedAt: nil, + metadata: nil), + IPadWorkboardCard( + id: "preview-review", + title: "Review adaptive shell", + notes: "Make sure shared destinations stay device-specific.", + status: "review", + priority: "normal", + labels: ["shell"], + agentId: "main", + sessionKey: nil, + position: 2, + updatedAt: nil, + metadata: nil), + ] +} + +private struct IPadSkillWorkshopStatesPreview: View { + private let proposals = IPadSkillWorkshopPreviewFixtures.proposals + + var body: some View { + ZStack { + OpenClawProBackground() + ScrollView { + VStack(alignment: .leading, spacing: 18) { + self.previewHeader("Connected") + self.queueCard(self.proposals, selectedID: "preview-pending", busyID: nil) + + self.previewHeader("Loading") + self.queueCard(self.proposals, selectedID: "preview-pending", busyID: "preview-pending") + + self.previewHeader("Empty") + ProCard(radius: OpenClawProMetric.cardRadius) { + ProStatusRow( + icon: "hammer", + title: "No proposals", + detail: "New proposals will appear here when agents draft skills.", + value: "empty", + color: .secondary, + actionTitle: nil, + action: nil) + } + + self.previewHeader("Offline / Error") + ProCard(radius: OpenClawProMetric.cardRadius) { + ProStatusRow( + icon: "wifi.slash", + title: "Workshop offline", + detail: "Connect to the gateway to load Skill Workshop proposals.", + value: "offline", + color: .secondary, + actionTitle: nil, + action: nil) + Divider().padding(.leading, 58) + ProStatusRow( + icon: "exclamationmark.triangle", + title: "Proposal unavailable", + detail: "Try again after the gateway reconnects.", + value: "error", + color: OpenClawBrand.warn, + actionTitle: nil, + action: nil) + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + .padding(.vertical, 18) + } + } + } + + private func previewHeader(_ title: String) -> some View { + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + } + + private func queueCard( + _ proposals: [IPadSkillProposal], + selectedID: String?, + busyID: String?) -> some View + { + ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) { + VStack(spacing: 0) { + ProPanelHeader( + title: "Queue", + value: "\(proposals.count)", + actionTitle: nil, + action: nil) + ForEach(Array(proposals.enumerated()), id: \.element.id) { index, proposal in + if index > 0 { + Divider().padding(.leading, 58) + } + IPadSkillProposalRow( + proposal: proposal, + isSelected: proposal.id == selectedID, + isBusy: proposal.id == busyID) + } + } + } + } +} + +private struct IPadSkillWorkshopKanbanPreview: View { + private let lanes = IPadSkillWorkshopPreviewFixtures.kanbanStatuses + private let proposals = IPadSkillWorkshopPreviewFixtures.proposals + + var body: some View { + ZStack { + OpenClawProBackground() + VStack(alignment: .leading, spacing: 18) { + self.previewHeader + ScrollView(.horizontal) { + HStack(alignment: .top, spacing: 12) { + ForEach(self.lanes, id: \.self) { status in + IPadSkillProposalKanbanColumn( + status: status, + proposals: self.proposals.filter { $0.status == status }, + selectedProposalID: "preview-pending", + inspectingProposalID: "preview-needs-review", + canApplyProposalMutations: true, + busyAction: nil, + select: { _ in }, + inspect: { _ in }, + apply: { _ in }, + reject: { _ in }) + .frame(width: 282) + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + } + .padding(.vertical, 22) + } + .environment(\.horizontalSizeClass, .regular) + .environment(\.verticalSizeClass, .regular) + } + + private var previewHeader: some View { + VStack(alignment: .leading, spacing: 4) { + Text("iPad kanban") + .font(.headline) + Text("Wide layout with populated, empty, held, and custom proposal lanes.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } +} + +private enum IPadSkillWorkshopPreviewFixtures { + static let kanbanStatuses = [ + "pending", + "quarantined", + "stale", + "applied", + "rejected", + "needs-review", + "manual_QA", + ] + + static let proposals = [ + Self.proposal( + id: "preview-pending", + status: "pending", + title: "Add Tailscale gateway helper", + description: "Drafts a helper skill for checking local Tailscale reachability before pairing.", + minutesAgo: 9), + Self.proposal( + id: "preview-applied", + status: "applied", + title: "Summarize channel health", + description: "Adds a lightweight status summary for channel clients and recent routing failures.", + minutesAgo: 47), + Self.proposal( + id: "preview-held", + status: "quarantined", + title: "Desktop automation bridge", + description: "Held for review because it requests broader file access than mobile should expose.", + minutesAgo: 128), + Self.proposal( + id: "preview-needs-review", + status: "needs-review", + title: "Review pairing diagnostics", + description: "Adds a diagnostic checklist before trusting a new gateway certificate.", + minutesAgo: 32), + Self.proposal( + id: "preview-manual-qa", + status: "manual_QA", + title: "Manual QA runbook", + description: "Generates a device checklist for iPhone portrait and iPad split layouts.", + minutesAgo: 15), + ] + + private static func proposal( + id: String, + status: String, + title: String, + description: String, + minutesAgo: Int) -> IPadSkillProposal + { + let updatedAt = ISO8601DateFormatter().string(from: Date().addingTimeInterval(Double(-minutesAgo * 60))) + return IPadSkillProposal( + entry: IPadSkillProposalManifestEntry( + id: id, + kind: "skill", + status: status, + title: title, + description: description, + skillName: title, + skillKey: id, + createdAt: updatedAt, + updatedAt: updatedAt, + scanState: "complete"), + previous: nil) + } +} +#endif diff --git a/apps/ios/Sources/Design/IPadSidebarFeatureScreens.swift b/apps/ios/Sources/Design/IPadSidebarFeatureScreens.swift new file mode 100644 index 000000000000..c560bfd44ed3 --- /dev/null +++ b/apps/ios/Sources/Design/IPadSidebarFeatureScreens.swift @@ -0,0 +1,17 @@ +import Foundation + +struct EmptyParams: Encodable {} + +enum IPadSidebarGatewayError: Error { + case offline + case invalidPayload + + var message: String { + switch self { + case .offline: + "Gateway offline." + case .invalidPayload: + "Could not encode request." + } + } +} diff --git a/apps/ios/Sources/Design/IPadSidebarScreenChrome.swift b/apps/ios/Sources/Design/IPadSidebarScreenChrome.swift new file mode 100644 index 000000000000..df8cd21cfa45 --- /dev/null +++ b/apps/ios/Sources/Design/IPadSidebarScreenChrome.swift @@ -0,0 +1,71 @@ +import SwiftUI + +struct IPadSidebarScreenChrome: View { + @Environment(\.verticalSizeClass) private var verticalSizeClass + let title: String + let subtitle: String + let headerLeadingAction: OpenClawSidebarHeaderAction? + let gatewayAction: (() -> Void)? + @ViewBuilder var content: Content + + init( + title: String, + subtitle: String, + headerLeadingAction: OpenClawSidebarHeaderAction? = nil, + gatewayAction: (() -> Void)? = nil, + @ViewBuilder content: () -> Content) + { + self.title = title + self.subtitle = subtitle + self.headerLeadingAction = headerLeadingAction + self.gatewayAction = gatewayAction + self.content = content() + } + + var body: some View { + ZStack { + OpenClawProBackground() + ScrollView { + VStack(alignment: .leading, spacing: self.isCompactHeight ? 10 : 16) { + OpenClawAdaptiveHeaderRow( + title: self.title, + subtitle: self.subtitle, + titleFont: self.isCompactHeight ? .headline.weight(.semibold) : .title2.weight(.semibold), + subtitleLineLimit: self.isCompactHeight ? 1 : 2) + { + if let headerLeadingAction { + OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction) + } + } accessory: { + self.gatewayPill + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + self.content + } + .padding(.vertical, self.isCompactHeight ? 10 : 18) + } + .safeAreaPadding(.bottom, self.bottomScrollInset) + } + } + + private var isCompactHeight: Bool { + self.verticalSizeClass == .compact + } + + @ViewBuilder + private var gatewayPill: some View { + if let gatewayAction { + Button(action: gatewayAction) { + OpenClawGatewayCompactPill() + } + .buttonStyle(.plain) + .accessibilityHint("Opens Settings / Gateway") + } else { + OpenClawGatewayCompactPill() + } + } + + private var bottomScrollInset: CGFloat { + self.isCompactHeight ? 150 : OpenClawProMetric.bottomScrollInset + } +} diff --git a/apps/ios/Sources/Design/IPadSkillWorkshopScreen.swift b/apps/ios/Sources/Design/IPadSkillWorkshopScreen.swift new file mode 100644 index 000000000000..0ad46971c547 --- /dev/null +++ b/apps/ios/Sources/Design/IPadSkillWorkshopScreen.swift @@ -0,0 +1,1191 @@ +import OpenClawKit +import SwiftUI + +struct IPadSkillWorkshopScreen: View { + @Environment(NodeAppModel.self) private var appModel + @Environment(\.scenePhase) private var scenePhase + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.verticalSizeClass) private var verticalSizeClass + @State private var proposals: [IPadSkillProposal] = [] + @State private var selectedProposalID: String? + @State private var selectedAgentScopeID = "" + @State private var statusFilter = "pending" + @State private var query = "" + @State private var isLoading = false + @State private var inspectingProposalID: String? + @State private var busyAction: IPadSkillProposalAction? + @State private var errorText: String? + @State private var noticeText: String? + @State private var presentedProposalRoute: IPadSkillProposalSheetRoute? + let headerLeadingAction: OpenClawSidebarHeaderAction? + let openSettings: () -> Void + + init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, openSettings: @escaping () -> Void = {}) { + self.headerLeadingAction = headerLeadingAction + self.openSettings = openSettings + } + + var body: some View { + IPadSidebarScreenChrome( + title: "Skill Workshop", + subtitle: "Review and apply proposed skills.", + headerLeadingAction: self.headerLeadingAction, + gatewayAction: self.openSettings) + { + if self.isCompactWidth { + self.compactFiltersCard + } else { + ProMetricGrid(metrics: self.metrics) + self.filtersCard + } + self.proposalContent + } + .task(id: self.refreshID) { + await self.loadProposals(force: false) + } + .refreshable { + await self.loadProposals(force: true) + } + .onChange(of: self.statusFilter) { _, _ in + self.syncSelectedProposalIDForVisibleProposals() + } + .onChange(of: self.query) { _, _ in + self.syncSelectedProposalIDForVisibleProposals() + } + .sheet(item: self.$presentedProposalRoute) { route in + NavigationStack { + ScrollView { + self.presentedProposalDetail(proposalID: route.proposalID) + .padding(.horizontal, OpenClawProMetric.pagePadding) + .padding(.vertical, 16) + } + .background(OpenClawProBackground()) + .navigationTitle("Proposal") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + self.presentedProposalRoute = nil + } + } + } + } + } + } + + private var metrics: [ProMetric] { + [ + ProMetric( + icon: "clock", + title: "Pending", + value: "\(self.count("pending"))", + color: OpenClawBrand.warn), + ProMetric( + icon: "checkmark.circle", + title: "Applied", + value: "\(self.count("applied"))", + color: OpenClawBrand.ok), + ProMetric( + icon: "shield", + title: "Held", + value: "\(self.count("quarantined") + self.count("stale"))", + color: .secondary), + ] + } + + private var filtersCard: some View { + ProCard(radius: OpenClawProMetric.cardRadius) { + VStack(alignment: .leading, spacing: 12) { + self.agentScopeMenu + self.proposalSearchField + Picker("Status", selection: self.$statusFilter) { + ForEach(Self.proposalStatusFilters, id: \.self) { filter in + Text(Self.proposalStatusFilterLabel(filter)).tag(filter) + } + } + .pickerStyle(.segmented) + .controlSize(.small) + .tint(OpenClawBrand.accent) + HStack(spacing: 8) { + Button { + Task { await self.loadProposals(force: true) } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(self.neutralControlTint) + .disabled(self.isLoading) + + if self.isLoading { + ProgressView().controlSize(.small) + } + } + if let noticeText { + Text(noticeText) + .font(.caption2) + .foregroundStyle(OpenClawBrand.accent) + } + if let errorText { + Text(errorText) + .font(.caption2) + .foregroundStyle(OpenClawBrand.warn) + } + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + + private var compactFiltersCard: some View { + ProCard(radius: OpenClawProMetric.cardRadius) { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + VStack(alignment: .leading, spacing: 3) { + Text("\(self.filteredProposals.count) proposals") + .font(.headline) + Text(self.statusFilterLabel) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 8) + if self.isLoading { + ProgressView().controlSize(.small) + } + } + + self.agentScopeMenu + Picker("Status", selection: self.$statusFilter) { + ForEach(Self.proposalStatusFilters, id: \.self) { filter in + Text(Self.proposalStatusFilterLabel(filter)).tag(filter) + } + } + .pickerStyle(.segmented) + .controlSize(.small) + .tint(OpenClawBrand.accent) + + self.proposalSearchField + + HStack(spacing: 8) { + Button { + Task { await self.loadProposals(force: true) } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(self.neutralControlTint) + .disabled(self.isLoading) + } + if let noticeText { + Text(noticeText) + .font(.caption2) + .foregroundStyle(OpenClawBrand.accent) + } + if let errorText { + Text(errorText) + .font(.caption2) + .foregroundStyle(OpenClawBrand.warn) + } + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + + private var proposalSearchField: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + TextField("Search proposals", text: self.$query) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.subheadline) + if !self.query.isEmpty { + Button { + self.query = "" + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + } + } + + private var agentScopeMenu: some View { + HStack(spacing: 8) { + Text("Agent") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Menu { + Button("Default agent") { + self.selectedAgentScopeID = "" + } + ForEach(self.agentScopeOptions, id: \.id) { option in + Button(option.title) { + self.selectedAgentScopeID = option.id + } + } + } label: { + HStack(spacing: 6) { + Text(self.agentScopeLabel) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2.weight(.bold)) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(self.neutralControlTint) + .disabled(self.agentScopeOptions.isEmpty) + .accessibilityLabel("Skill Workshop agent scope") + } + } + + private var neutralControlTint: Color { + Color.primary.opacity(0.55) + } + + private var proposalContent: some View { + VStack(alignment: .leading, spacing: 12) { + if self.filteredProposals.isEmpty { + ProCard(radius: OpenClawProMetric.cardRadius) { + ProStatusRow( + icon: self.canRead ? "hammer" : "wifi.slash", + title: self.canRead ? "No proposals" : "No proposals loaded", + detail: self.canRead + ? "New proposals will appear here when agents draft skills." + : "Connect from Settings to load Skill Workshop proposals.", + value: self.canRead ? "empty" : nil, + color: .secondary, + actionTitle: nil, + action: nil) + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } else { + if self.isCompactWidth { + VStack(alignment: .leading, spacing: 12) { + self.proposalList + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } else { + self.proposalBoard + } + } + } + } + + private var proposalBoard: some View { + ScrollView(.horizontal) { + HStack(alignment: .top, spacing: 12) { + ForEach(self.visibleProposalLaneStatuses, id: \.self) { status in + IPadSkillProposalKanbanColumn( + status: status, + proposals: self.proposals(forLaneStatus: status), + selectedProposalID: self.selectedProposalID, + inspectingProposalID: self.inspectingProposalID, + canApplyProposalMutations: self.canApplyProposalMutations, + busyAction: self.busyAction, + select: { proposal in + self.selectProposal( + proposal, + opensSheet: true, + forceInspect: false) + }, + inspect: { proposal in + self.selectProposal( + proposal, + opensSheet: true, + forceInspect: true) + }, + apply: { proposal in + Task { await self.run(.apply, proposal: proposal) } + }, + reject: { proposal in + Task { await self.run(.reject, proposal: proposal) } + }) + .frame(width: 282) + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + .scrollIndicators(.visible) + } + + private var proposalList: some View { + ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) { + VStack(spacing: 0) { + ProPanelHeader( + title: "Queue", + value: "\(self.filteredProposals.count)", + actionTitle: nil, + action: nil) + ForEach(Array(self.filteredProposals.enumerated()), id: \.element.id) { index, proposal in + if index > 0 { + Divider().padding(.leading, 58) + } + Button { + self.selectProposal( + proposal, + opensSheet: self.isCompactWidth, + forceInspect: false) + } label: { + IPadSkillProposalRow( + proposal: proposal, + isSelected: proposal.id == self.selectedProposalID, + isBusy: self.inspectingProposalID == proposal.id) + } + .buttonStyle(.plain) + .contextMenu { + Button("Inspect") { + self.selectProposal( + proposal, + opensSheet: true, + forceInspect: true) + } + if proposal.status == "pending" { + Button("Apply") { + Task { await self.run(.apply, proposal: proposal) } + } + .disabled(!self.canApplyProposalMutations || self.busyAction != nil) + Button("Reject", role: .destructive) { + Task { await self.run(.reject, proposal: proposal) } + } + .disabled(!self.canApplyProposalMutations || self.busyAction != nil) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if proposal.status == "pending" { + Button("Apply") { + Task { await self.run(.apply, proposal: proposal) } + } + .tint(OpenClawBrand.ok) + .disabled(!self.canApplyProposalMutations || self.busyAction != nil) + Button("Reject", role: .destructive) { + Task { await self.run(.reject, proposal: proposal) } + } + .disabled(!self.canApplyProposalMutations || self.busyAction != nil) + } + } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + Button("Inspect") { + self.selectProposal( + proposal, + opensSheet: true, + forceInspect: true) + } + .tint(OpenClawBrand.accent) + } + } + } + } + } + + @ViewBuilder + private func presentedProposalDetail(proposalID: String) -> some View { + if let proposal = proposal(withID: proposalID) { + self.proposalDetailCard(proposal) + } else { + ProCard(radius: OpenClawProMetric.cardRadius) { + ProStatusRow( + icon: "hammer", + title: "Proposal unavailable", + detail: "Return to the queue and choose another proposal.", + value: "missing", + color: .secondary, + actionTitle: nil, + action: nil) + } + } + } + + private func proposalDetailCard(_ proposal: IPadSkillProposal) -> some View { + ProCard(radius: OpenClawProMetric.cardRadius) { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top, spacing: 12) { + ProIconBadge(systemName: "hammer", color: proposal.statusColor) + VStack(alignment: .leading, spacing: 4) { + Text(proposal.title) + .font(.headline) + Text(proposal.description) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + ProValuePill(value: proposal.status, color: proposal.statusColor) + } + + if self.inspectingProposalID == proposal.id { + ProgressView().controlSize(.small) + } + + if let content = proposal.content, !content.isEmpty { + Text(content) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(16) + .textSelection(.enabled) + } else { + Text("Select refresh to load the proposal body.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if !proposal.supportFiles.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("Support files") + .font(.subheadline.weight(.semibold)) + ForEach(proposal.supportFiles, id: \.path) { file in + Text(file.path) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + + if proposal.status == "pending" { + if self.isCompactWidth { + VStack(spacing: 8) { + self.proposalApplyButton(proposal) + self.proposalRejectButton(proposal) + self.proposalInspectButton(proposal) + } + } else { + HStack(spacing: 8) { + self.proposalApplyButton(proposal) + self.proposalRejectButton(proposal) + self.proposalInspectButton(proposal) + } + } + if !self.canApplyProposalMutations { + self.adminScopeNotice + } + } + } + } + } + + private func proposalApplyButton(_ proposal: IPadSkillProposal) -> some View { + Button { + Task { await self.run(.apply, proposal: proposal) } + } label: { + Label("Apply", systemImage: "checkmark.circle") + .frame(maxWidth: self.isCompactWidth ? .infinity : nil) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(!self.canApplyProposalMutations || self.busyAction != nil) + } + + private func proposalRejectButton(_ proposal: IPadSkillProposal) -> some View { + Button(role: .destructive) { + Task { await self.run(.reject, proposal: proposal) } + } label: { + Label("Reject", systemImage: "xmark.circle") + .frame(maxWidth: self.isCompactWidth ? .infinity : nil) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!self.canApplyProposalMutations || self.busyAction != nil) + } + + private func proposalInspectButton(_ proposal: IPadSkillProposal) -> some View { + Button { + Task { await self.inspect(proposalID: proposal.id, force: true) } + } label: { + Label("Inspect", systemImage: "doc.text.magnifyingglass") + .frame(maxWidth: self.isCompactWidth ? .infinity : nil) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(self.inspectingProposalID != nil) + } + + private var refreshID: String { + [ + self.canRead ? "connected" : "offline", + self.scenePhase == .active ? "active" : "inactive", + self.selectedAgentScopeID.isEmpty ? "default" : self.selectedAgentScopeID, + ].joined(separator: ":") + } + + private var canRead: Bool { + self.appModel.isOperatorGatewayConnected + } + + private var canWrite: Bool { + self.appModel.isOperatorGatewayConnected && !self.appModel.isAppleReviewDemoModeEnabled + } + + private var canApplyProposalMutations: Bool { + Self.shouldEnableProposalMutation( + canWrite: self.canWrite, + hasOperatorAdminScope: self.appModel.hasOperatorAdminScope) + } + + private var agentScopeOptions: [IPadSkillWorkshopAgentScope] { + let defaultID = Self.normalizedScopeID(self.appModel.gatewayDefaultAgentId) + return self.appModel.gatewayAgents + .filter { Self.normalizedScopeID($0.id) != defaultID } + .map { agent in + let name = Self.normalizedScopeID(agent.name) + return IPadSkillWorkshopAgentScope( + id: Self.normalizedScopeID(agent.id), + title: name.isEmpty ? agent.id : name) + } + .filter { !$0.id.isEmpty } + .sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } + } + + private var agentScopeLabel: String { + let selected = Self.normalizedScopeID(self.selectedAgentScopeID) + guard !selected.isEmpty else { return self.defaultAgentScopeLabel } + return self.agentScopeOptions.first(where: { $0.id == selected })?.title ?? selected + } + + private var defaultAgentScopeLabel: String { + let defaultID = Self.normalizedScopeID(self.appModel.gatewayDefaultAgentId) + if let match = appModel.gatewayAgents.first(where: { Self.normalizedScopeID($0.id) == defaultID }) { + let name = Self.normalizedScopeID(match.name) + return name.isEmpty ? "Default agent" : name + } + let activeName = Self.normalizedScopeID(self.appModel.activeAgentName) + return activeName.isEmpty ? "Default agent" : activeName + } + + private var selectedAgentParam: String? { + let selected = Self.normalizedScopeID(self.selectedAgentScopeID) + return selected.isEmpty ? nil : selected + } + + static func shouldEnableProposalMutation(canWrite: Bool, hasOperatorAdminScope: Bool) -> Bool { + canWrite && hasOperatorAdminScope + } + + private var adminScopeNotice: some View { + HStack(spacing: 8) { + Image(systemName: "lock.shield") + .foregroundStyle(OpenClawBrand.warn) + Text("Admin scope required.") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer(minLength: 8) + } + .padding(.top, 2) + } + + private var isCompactWidth: Bool { + Self.usesCompactTaskFlow( + horizontalSizeClass: self.horizontalSizeClass, + verticalSizeClass: self.verticalSizeClass) + } + + static func usesCompactTaskFlow( + horizontalSizeClass: UserInterfaceSizeClass?, + verticalSizeClass: UserInterfaceSizeClass?) -> Bool + { + horizontalSizeClass == .compact || verticalSizeClass == .compact + } + + static let proposalStatusFilters = ["pending", "held", "applied", "rejected", "all"] + + static let defaultProposalStatusBoardLanes = ["pending", "quarantined", "stale", "applied", "rejected"] + + static func proposalStatusFilterLabel(_ filter: String) -> String { + switch filter { + case "pending": "Pending" + case "held": "Held" + case "applied": "Applied" + case "rejected": "Rejected" + default: "All" + } + } + + static func proposalLaneLabel(_ status: String) -> String { + switch status { + case "quarantined": "Quarantined" + case "stale": "Stale" + case "pending", "applied", "rejected": + self.proposalStatusFilterLabel(status) + default: + self.titleCasedProposalStatus(status) + } + } + + static func titleCasedProposalStatus(_ status: String) -> String { + status + .replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: "-", with: " ") + .split(separator: " ") + .map { word in + let text = String(word) + if text == text.uppercased() { + return text + } + return text.prefix(1).uppercased() + text.dropFirst().lowercased() + } + .joined(separator: " ") + } + + static func proposalStatusBoardLanes(filter: String, proposalStatuses: [String]) -> [String] { + let customStatuses = proposalStatuses + .filter { !self.defaultProposalStatusBoardLanes.contains($0) } + .sorted() + switch filter { + case "all": + return self.defaultProposalStatusBoardLanes + customStatuses + case "held": + return ["quarantined", "stale"] + case "pending", "applied", "rejected": + return [filter] + default: + return [filter] + } + } + + static func proposalStatusMatchesFilter(status: String, filter: String) -> Bool { + switch filter { + case "all": + true + case "held": + status == "quarantined" || status == "stale" + default: + status == filter + } + } + + static func nextSelectedProposalID( + current: String?, + proposals: [(id: String, status: String)], + filter: String) -> String? + { + let filtered = proposals.filter { Self.proposalStatusMatchesFilter(status: $0.status, filter: filter) } + return Self.nextSelectedProposalID(current: current, visibleProposalIDs: filtered.map(\.id)) + } + + static func nextSelectedProposalID(current: String?, visibleProposalIDs: [String]) -> String? { + guard !visibleProposalIDs.isEmpty else { return nil } + if let current, visibleProposalIDs.contains(current) { + return current + } + return visibleProposalIDs.first + } + + static func normalizedScopeID(_ value: String?) -> String { + (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var statusFilterLabel: String { + Self.proposalStatusFilterLabel(self.statusFilter) + } + + private var filteredProposals: [IPadSkillProposal] { + let trimmedQuery = self.query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return self.proposals + .filter { proposal in + Self.proposalStatusMatchesFilter(status: proposal.status, filter: self.statusFilter) + } + .filter { proposal in + guard !trimmedQuery.isEmpty else { return true } + return [ + proposal.title, + proposal.description, + proposal.skillName, + proposal.skillKey, + ] + .joined(separator: " ") + .lowercased() + .contains(trimmedQuery) + } + } + + private var visibleProposalLaneStatuses: [String] { + Self.proposalStatusBoardLanes( + filter: self.statusFilter, + proposalStatuses: self.proposals.map(\.status)) + } + + private func proposals(forLaneStatus status: String) -> [IPadSkillProposal] { + let trimmedQuery = self.query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return self.proposals + .filter { $0.status == status } + .filter { proposal in + guard !trimmedQuery.isEmpty else { return true } + return [ + proposal.title, + proposal.description, + proposal.skillName, + proposal.skillKey, + ] + .joined(separator: " ") + .lowercased() + .contains(trimmedQuery) + } + .sorted { $0.updatedAtMs > $1.updatedAtMs } + } + + private func syncSelectedProposalIDForVisibleProposals() { + let nextID = Self.nextSelectedProposalID( + current: self.selectedProposalID, + visibleProposalIDs: self.filteredProposals.map(\.id)) + guard self.selectedProposalID != nextID else { return } + self.selectedProposalID = nextID + } + + private func count(_ status: String) -> Int { + self.proposals.count(where: { $0.status == status }) + } + + private func proposal(withID id: String) -> IPadSkillProposal? { + self.proposals.first { $0.id == id } + } + + private func selectProposal( + _ proposal: IPadSkillProposal, + opensSheet: Bool, + forceInspect: Bool) + { + self.selectedProposalID = proposal.id + if opensSheet { + self.presentedProposalRoute = IPadSkillProposalSheetRoute(proposalID: proposal.id) + } + Task { await self.inspect(proposalID: proposal.id, force: forceInspect) } + } + + private func loadProposals(force: Bool) async { + guard self.scenePhase == .active else { return } + guard self.canRead else { + self.proposals = [] + self.errorText = nil + return + } + guard !self.isLoading else { return } + + self.isLoading = true + self.errorText = nil + defer { self.isLoading = false } + + do { + let data = try await request( + method: "skills.proposals.list", + params: IPadSkillProposalListParams(agentId: selectedAgentParam), + timeoutSeconds: 20) + let response = try JSONDecoder().decode(IPadSkillProposalManifest.self, from: data) + let previousByID = Dictionary(uniqueKeysWithValues: proposals.map { ($0.id, $0) }) + let next = response.proposals + .map { IPadSkillProposal(entry: $0, previous: previousByID[$0.id]) } + .sorted { $0.updatedAtMs > $1.updatedAtMs } + self.proposals = next + self.syncSelectedProposalIDForVisibleProposals() + if let selectedProposalID { + await self.inspect(proposalID: selectedProposalID, force: force) + } + } catch { + if force || self.proposals.isEmpty { + self.errorText = Self.message(for: error) + } + } + } + + private func inspect(proposalID: String, force: Bool) async { + guard self.canRead else { return } + guard force || self.proposals.first(where: { $0.id == proposalID })?.content == nil else { return } + guard self.inspectingProposalID == nil else { return } + + self.inspectingProposalID = proposalID + self.errorText = nil + defer { self.inspectingProposalID = nil } + + do { + let data = try await request( + method: "skills.proposals.inspect", + params: IPadSkillProposalInspectParams( + agentId: selectedAgentParam, + proposalId: proposalID), + timeoutSeconds: 20) + let response = try JSONDecoder().decode(IPadSkillProposalInspectResponse.self, from: data) + self.merge(IPadSkillProposal(inspect: response, previous: self.proposals.first { $0.id == proposalID })) + } catch { + self.errorText = Self.message(for: error) + } + } + + private func run(_ action: IPadSkillProposalAction.Kind, proposal: IPadSkillProposal) async { + guard self.canApplyProposalMutations, self.busyAction == nil else { return } + self.busyAction = IPadSkillProposalAction(kind: action, proposalID: proposal.id) + self.errorText = nil + self.noticeText = nil + defer { self.busyAction = nil } + + do { + let method = action == .apply ? "skills.proposals.apply" : "skills.proposals.reject" + _ = try await self.request( + method: method, + params: IPadSkillProposalInspectParams( + agentId: self.selectedAgentParam, + proposalId: proposal.id), + timeoutSeconds: 30) + self.noticeText = action == .apply ? "Proposal applied." : "Proposal rejected." + await self.loadProposals(force: true) + } catch { + self.errorText = Self.message(for: error) + } + } + + private func merge(_ proposal: IPadSkillProposal) { + self.proposals.removeAll { $0.id == proposal.id } + self.proposals.append(proposal) + self.proposals.sort { $0.updatedAtMs > $1.updatedAtMs } + } + + private func request(method: String, params: some Encodable, timeoutSeconds: Int) async throws -> Data { + guard self.canRead else { throw IPadSidebarGatewayError.offline } + let data = try JSONEncoder().encode(params) + guard let json = String(data: data, encoding: .utf8) else { + throw IPadSidebarGatewayError.invalidPayload + } + return try await self.appModel.operatorSession.request( + method: method, + paramsJSON: json, + timeoutSeconds: timeoutSeconds) + } + + private static func message(for error: Error) -> String { + if let gatewayError = error as? IPadSidebarGatewayError { + return gatewayError.message + } + return error.localizedDescription + } +} + +struct IPadSkillProposalKanbanColumn: View { + let status: String + let proposals: [IPadSkillProposal] + let selectedProposalID: String? + let inspectingProposalID: String? + let canApplyProposalMutations: Bool + let busyAction: IPadSkillProposalAction? + let select: (IPadSkillProposal) -> Void + let inspect: (IPadSkillProposal) -> Void + let apply: (IPadSkillProposal) -> Void + let reject: (IPadSkillProposal) -> Void + + var body: some View { + ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) { + VStack(spacing: 0) { + ProPanelHeader( + title: IPadSkillWorkshopScreen.proposalLaneLabel(self.status), + value: "\(self.proposals.count)", + actionTitle: nil, + action: nil) + + if self.proposals.isEmpty { + ProStatusRow( + icon: "hammer", + title: "No \(IPadSkillWorkshopScreen.proposalLaneLabel(self.status).lowercased()) proposals", + detail: "Matching proposals appear here after gateway refresh.", + value: "empty", + color: .secondary, + actionTitle: nil, + action: nil) + } else { + ForEach(Array(self.proposals.enumerated()), id: \.element.id) { index, proposal in + if index > 0 { + Divider().padding(.leading, 12) + } + IPadSkillProposalKanbanCard( + proposal: proposal, + isSelected: proposal.id == self.selectedProposalID, + isInspecting: proposal.id == self.inspectingProposalID, + canApplyProposalMutations: self.canApplyProposalMutations, + isBusy: self.busyAction != nil, + select: { + self.select(proposal) + }, + inspect: { + self.inspect(proposal) + }, + apply: { + self.apply(proposal) + }, + reject: { + self.reject(proposal) + }) + } + } + } + } + } +} + +private struct IPadSkillProposalKanbanCard: View { + let proposal: IPadSkillProposal + let isSelected: Bool + let isInspecting: Bool + let canApplyProposalMutations: Bool + let isBusy: Bool + let select: () -> Void + let inspect: () -> Void + let apply: () -> Void + let reject: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Button(action: self.select) { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 10) { + ProIconBadge( + systemName: self.isInspecting ? "hourglass" : "hammer", + color: self.proposal.statusColor) + VStack(alignment: .leading, spacing: 4) { + Text(self.proposal.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(self.isSelected ? OpenClawBrand.accent : .primary) + .lineLimit(2) + Text(self.proposal.description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + } + } + HStack(spacing: 8) { + ProValuePill(value: self.proposal.status, color: self.proposal.statusColor) + Spacer(minLength: 4) + Text(self.proposal.ageLabel) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + + HStack(spacing: 8) { + if self.proposal.status == "pending" { + Button(action: self.apply) { + Image(systemName: "checkmark.circle") + } + .accessibilityLabel("Apply Proposal") + .buttonStyle(.bordered) + .controlSize(.mini) + .disabled(!self.canApplyProposalMutations || self.isBusy) + + Button(role: .destructive, action: self.reject) { + Image(systemName: "xmark.circle") + } + .accessibilityLabel("Reject Proposal") + .buttonStyle(.bordered) + .controlSize(.mini) + .disabled(!self.canApplyProposalMutations || self.isBusy) + } + + Button(action: self.inspect) { + Image(systemName: "doc.text.magnifyingglass") + } + .accessibilityLabel("Inspect Proposal") + .buttonStyle(.bordered) + .controlSize(.mini) + .disabled(self.isInspecting) + } + } + .padding(12) + .background( + self.isSelected ? OpenClawBrand.accent.opacity(0.08) : Color.clear, + in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .contentShape(Rectangle()) + .contextMenu { + Button("Inspect", action: self.inspect) + if self.proposal.status == "pending" { + Button("Apply", action: self.apply) + .disabled(!self.canApplyProposalMutations || self.isBusy) + Button("Reject", role: .destructive, action: self.reject) + .disabled(!self.canApplyProposalMutations || self.isBusy) + } + } + } +} + +struct IPadSkillProposalRow: View { + let proposal: IPadSkillProposal + let isSelected: Bool + let isBusy: Bool + + var body: some View { + HStack(alignment: .top, spacing: 12) { + ProIconBadge(systemName: self.isBusy ? "hourglass" : "hammer", color: self.proposal.statusColor) + VStack(alignment: .leading, spacing: 4) { + Text(self.proposal.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(self.isSelected ? OpenClawBrand.accent : .primary) + .lineLimit(1) + Text(self.proposal.description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer(minLength: 8) + Text(self.proposal.ageLabel) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + self.isSelected ? Color.red.opacity(0.08) : Color.clear, + in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } +} + +private struct IPadSkillProposalSheetRoute: Identifiable { + let proposalID: String + + var id: String { + self.proposalID + } +} + +struct IPadSkillProposalAction: Equatable { + enum Kind { + case apply + case reject + } + + let kind: Kind + let proposalID: String +} + +private struct IPadSkillProposalManifest: Decodable { + let proposals: [IPadSkillProposalManifestEntry] +} + +struct IPadSkillProposalManifestEntry: Decodable { + let id: String + let kind: String + let status: String + let title: String + let description: String + let skillName: String + let skillKey: String + let createdAt: String + let updatedAt: String + let scanState: String +} + +private struct IPadSkillWorkshopAgentScope: Identifiable { + let id: String + let title: String +} + +private struct IPadSkillProposalListParams: Encodable { + let agentId: String? +} + +private struct IPadSkillProposalInspectParams: Encodable { + let agentId: String? + let proposalId: String +} + +struct IPadSkillProposalInspectResponse: Decodable { + let record: IPadSkillProposalRecord + let content: String + let supportFiles: [IPadSkillProposalSupportFile]? +} + +struct IPadSkillProposalRecord: Decodable { + let id: String + let kind: String + let status: String + let title: String + let description: String + let createdAt: String + let updatedAt: String + let target: IPadSkillProposalTarget +} + +struct IPadSkillProposalTarget: Decodable { + let skillName: String + let skillKey: String +} + +struct IPadSkillProposalSupportFile: Decodable { + let path: String + let content: String? +} + +struct IPadSkillProposal: Identifiable { + let id: String + let kind: String + let status: String + let title: String + let description: String + let skillName: String + let skillKey: String + let createdAtMs: Double + let updatedAtMs: Double + var content: String? + var supportFiles: [IPadSkillProposalSupportFile] + + init(entry: IPadSkillProposalManifestEntry, previous: IPadSkillProposal?) { + self.id = entry.id + self.kind = entry.kind + self.status = entry.status + self.title = entry.title.isEmpty ? entry.skillName : entry.title + self.description = entry.description + self.skillName = entry.skillName + self.skillKey = entry.skillKey + self.createdAtMs = Self.parseDate(entry.createdAt) + self.updatedAtMs = Self.parseDate(entry.updatedAt) + self.content = previous?.updatedAtMs == self.updatedAtMs ? previous?.content : nil + self.supportFiles = previous?.updatedAtMs == self.updatedAtMs ? previous?.supportFiles ?? [] : [] + } + + init(inspect: IPadSkillProposalInspectResponse, previous: IPadSkillProposal?) { + let record = inspect.record + self.id = record.id + self.kind = record.kind + self.status = record.status + self.title = record.title.isEmpty ? record.target.skillName : record.title + self.description = record.description + self.skillName = record.target.skillName + self.skillKey = record.target.skillKey + self.createdAtMs = Self.parseDate(record.createdAt) + self.updatedAtMs = Self.parseDate(record.updatedAt) + self.content = Self.stripFrontmatter(inspect.content) + self.supportFiles = inspect.supportFiles ?? previous?.supportFiles ?? [] + } + + var ageLabel: String { + let diff = max(0, Date().timeIntervalSince1970 * 1000 - self.updatedAtMs) + let minutes = Int(diff / 60000) + if minutes < 1 { return "now" } + if minutes < 60 { return "\(minutes)m" } + let hours = minutes / 60 + if hours < 24 { return "\(hours)h" } + return "\(hours / 24)d" + } + + var statusColor: Color { + switch self.status { + case "pending": OpenClawBrand.warn + case "applied": OpenClawBrand.ok + case "rejected": .secondary + case "quarantined", "stale": OpenClawBrand.warn + default: OpenClawBrand.accent + } + } + + private static func parseDate(_ value: String) -> Double { + (ISO8601DateFormatter().date(from: value)?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000 + } + + private static func stripFrontmatter(_ value: String) -> String { + let pattern = #"(?s)^---\r?\n.*?\r?\n---\r?\n?"# + return value.replacingOccurrences(of: pattern, with: "", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/apps/ios/Sources/Design/IPadWorkboardScreen.swift b/apps/ios/Sources/Design/IPadWorkboardScreen.swift new file mode 100644 index 000000000000..f24197d68852 --- /dev/null +++ b/apps/ios/Sources/Design/IPadWorkboardScreen.swift @@ -0,0 +1,1395 @@ +import OpenClawKit +import SwiftUI + +struct IPadWorkboardScreen: View { + @Environment(NodeAppModel.self) private var appModel + @Environment(\.scenePhase) private var scenePhase + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.verticalSizeClass) private var verticalSizeClass + @State private var cards: [IPadWorkboardCard] = [] + @State private var statuses: [String] = IPadWorkboardDefaults.statuses + @State private var selectedStatus = "active" + @State private var selectedBoardID = "" + @State private var knownBoardIDs: [String] = [] + @State private var query = "" + @State private var isLoading = false + @State private var errorText: String? + @State private var draftTitle = "" + @State private var draftNotes = "" + @State private var isCreatingCard = false + @State private var busyCardID: String? + @State private var dispatchSummaryText: String? + @State private var presentedSheet: IPadWorkboardSheet? + let headerLeadingAction: OpenClawSidebarHeaderAction? + let openChat: () -> Void + let openSettings: () -> Void + + init( + headerLeadingAction: OpenClawSidebarHeaderAction? = nil, + openChat: @escaping () -> Void, + openSettings: @escaping () -> Void = {}) + { + self.headerLeadingAction = headerLeadingAction + self.openChat = openChat + self.openSettings = openSettings + } + + var body: some View { + IPadSidebarScreenChrome( + title: "Workboard", + subtitle: self.currentWorkboardSubtitle, + headerLeadingAction: self.headerLeadingAction, + gatewayAction: self.openSettings) + { + if self.isCompactWidth { + self.compactQueueControls + self.compactCardsPanel + } else { + ProMetricGrid(metrics: self.metrics) + self.controlsCard + self.kanbanBoard + } + } + .task(id: self.refreshID) { + await self.loadCards(force: false) + } + .refreshable { + await self.loadCards(force: true) + } + .sheet(item: self.$presentedSheet) { sheet in + switch sheet { + case .create: + NavigationStack { + self.createCardSheet + } + case let .card(card): + IPadWorkboardCardDetailSheet( + card: card, + statuses: self.statuses, + isBusy: self.busyCardID == card.id, + canWrite: self.canWrite, + openSession: { self.open(card) }, + move: { status in Task { await self.move(card, to: status) } }, + archive: { Task { await self.archive(card) } }) + } + } + } + + private var metrics: [ProMetric] { + [ + ProMetric( + icon: "tray.full", + title: "Cards", + value: "\(self.cards.count)", + color: OpenClawBrand.accent), + ProMetric( + icon: "figure.run", + title: "Running", + value: "\(self.cards.count(where: { $0.status == "running" }))", + color: OpenClawBrand.ok), + ProMetric( + icon: "exclamationmark.triangle", + title: "Blocked", + value: "\(self.cards.count(where: { $0.status == "blocked" }))", + color: OpenClawBrand.warn), + ] + } + + private var controlsCard: some View { + ProCard(radius: OpenClawProMetric.cardRadius) { + VStack(alignment: .leading, spacing: 12) { + self.boardScopeMenu + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + TextField("Search cards", text: self.$query) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.subheadline) + if !self.query.isEmpty { + Button { + self.query = "" + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + } + if self.isCompactWidth { + self.statusMenu + } else { + Picker("Scope", selection: self.$selectedStatus) { + Text("Active").tag("active") + ForEach(self.statuses, id: \.self) { status in + Text(IPadWorkboardDefaults.label(for: status)).tag(status) + } + } + .pickerStyle(.segmented) + .controlSize(.small) + .tint(OpenClawBrand.accent) + } + + HStack(spacing: 8) { + self.newCardButton(expands: false) + + Button { + Task { await self.dispatchCards() } + } label: { + Label("Dispatch", systemImage: "bolt.fill") + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!self.canWrite || self.isLoading) + + Button { + Task { await self.loadCards(force: true) } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(self.neutralControlTint) + .disabled(self.isLoading) + + if self.isLoading { + ProgressView().controlSize(.small) + } + } + + if let dispatchSummaryText { + Text(dispatchSummaryText) + .font(.caption2) + .foregroundStyle(OpenClawBrand.accent) + } + if let errorText { + Text(errorText) + .font(.caption2) + .foregroundStyle(OpenClawBrand.warn) + } + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + + private var compactQueueControls: some View { + ProCard(radius: OpenClawProMetric.cardRadius) { + VStack(alignment: .leading, spacing: 9) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text("\(self.filteredCards.count) cards") + .font(.headline) + Spacer(minLength: 8) + self.compactRefreshButton + } + + self.compactBoardScopeMenu + self.compactStatusPicker + + if self.canWrite { + HStack(spacing: 8) { + self.newCardButton(expands: true) + + Button { + Task { await self.dispatchCards() } + } label: { + Label("Dispatch", systemImage: "bolt.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(self.isLoading) + } + } else { + Text(Self.compactWriteUnavailableMessage(canRead: self.canRead)) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + if let dispatchSummaryText { + Text(dispatchSummaryText) + .font(.caption2) + .foregroundStyle(OpenClawBrand.accent) + } + if let errorText { + Text(errorText) + .font(.caption2) + .foregroundStyle(OpenClawBrand.warn) + } + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + + private var compactRefreshButton: some View { + Button { + Task { await self.loadCards(force: true) } + } label: { + Image(systemName: "arrow.clockwise") + .font(.caption.weight(.semibold)) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .foregroundStyle(self.neutralControlTint) + .accessibilityLabel("Refresh workboard") + .disabled(self.isLoading) + } + + private func newCardButton(expands: Bool) -> some View { + Button { + self.beginCreateCard() + } label: { + Label("New Card", systemImage: "plus") + .frame(maxWidth: expands ? .infinity : nil) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(self.isCreatingCard) + .accessibilityHint("Opens card title and notes entry") + } + + private var compactBoardScopeMenu: some View { + Menu { + Button("All boards") { + self.selectedBoardID = "" + } + ForEach(self.boardScopeOptions, id: \.self) { boardID in + Button(Self.boardScopeLabel(for: boardID)) { + self.selectedBoardID = boardID + } + } + } label: { + HStack(spacing: 8) { + Image(systemName: "rectangle.stack") + .font(.caption.weight(.semibold)) + Text(self.boardScopeLabel) + .font(.caption.weight(.semibold)) + .lineLimit(1) + Spacer(minLength: 4) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2.weight(.bold)) + } + .padding(.horizontal, 10) + .frame(height: 32) + .background(Color.primary.opacity(0.06), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1) + } + } + .buttonStyle(.plain) + .foregroundStyle(.primary) + .accessibilityLabel("Workboard board scope") + } + + private var compactStatusPicker: some View { + ScrollView(.horizontal) { + HStack(spacing: 8) { + self.compactStatusChip("active") + ForEach(self.compactStatuses, id: \.self) { status in + self.compactStatusChip(status) + } + } + .padding(.vertical, 1) + } + .scrollIndicators(.hidden) + .overlay(alignment: .trailing) { + LinearGradient( + colors: [.clear, Color(uiColor: .secondarySystemGroupedBackground)], + startPoint: .leading, + endPoint: .trailing) + .frame(width: 24) + .allowsHitTesting(false) + } + } + + private func compactStatusChip(_ status: String) -> some View { + Button { + self.selectedStatus = status + } label: { + Text(IPadWorkboardDefaults.label(for: status)) + .font(.caption2.weight(.semibold)) + .lineLimit(1) + .padding(.horizontal, 10) + .frame(height: 30) + .background( + self.selectedStatus == status + ? OpenClawBrand.accent.opacity(0.12) + : Color.primary.opacity(0.06), + in: Capsule()) + .overlay { + Capsule() + .strokeBorder( + self.selectedStatus == status + ? OpenClawBrand.accent.opacity(0.42) + : Color.primary.opacity(0.08), + lineWidth: 1) + } + } + .buttonStyle(.plain) + .foregroundStyle(self.selectedStatus == status ? OpenClawBrand.accent : .primary) + .accessibilityLabel("Show \(IPadWorkboardDefaults.label(for: status)) cards") + } + + private var boardScopeMenu: some View { + HStack(spacing: 8) { + Text("Board") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Menu { + Button("All boards") { + self.selectedBoardID = "" + } + ForEach(self.boardScopeOptions, id: \.self) { boardID in + Button(Self.boardScopeLabel(for: boardID)) { + self.selectedBoardID = boardID + } + } + } label: { + HStack(spacing: 6) { + Text(self.boardScopeLabel) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2.weight(.bold)) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(self.neutralControlTint) + .accessibilityLabel("Workboard board scope") + } + } + + private var statusMenu: some View { + HStack(spacing: 8) { + Text("Status") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Menu { + Button("Active") { + self.selectedStatus = "active" + } + ForEach(self.statuses, id: \.self) { status in + Button(IPadWorkboardDefaults.label(for: status)) { + self.selectedStatus = status + } + } + } label: { + HStack(spacing: 6) { + Text(IPadWorkboardDefaults.label(for: self.selectedStatus)) + .font(.subheadline.weight(.semibold)) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2.weight(.bold)) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(self.neutralControlTint) + } + } + + private var neutralControlTint: Color { + Color.primary.opacity(0.55) + } + + private var kanbanBoard: some View { + ScrollView(.horizontal) { + HStack(alignment: .top, spacing: 12) { + ForEach(self.visibleKanbanStatuses, id: \.self) { status in + IPadWorkboardKanbanColumn( + status: status, + cards: self.cards(forKanbanStatus: status), + statuses: self.statuses, + busyCardID: self.busyCardID, + openSession: { card in + self.open(card) + }, + inspect: { card in + self.presentedSheet = .card(card) + }, + move: { card, status in + Task { await self.move(card, to: status) } + }, + archive: { card in + Task { await self.archive(card) } + }) + .frame(width: 282) + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + .padding(.bottom, 12) + } + .scrollIndicators(.visible) + } + + private var compactCardsPanel: some View { + ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) { + VStack(spacing: 0) { + ProPanelHeader( + title: "Queue", + value: "\(self.filteredCards.count)", + actionTitle: nil, + action: nil) + if self.filteredCards.isEmpty { + ProStatusRow( + icon: self.canRead ? "tray" : "wifi.slash", + title: self.canRead ? "No cards" : "No cards loaded", + detail: self.canRead + ? "Create a card or change the filter." + : "Connect from Settings to load workboard cards.", + value: self.canRead ? "empty" : nil, + color: .secondary, + actionTitle: nil, + action: nil) + } else { + ForEach(Array(self.filteredCards.enumerated()), id: \.element.id) { index, card in + if index > 0 { + Divider().padding(.leading, 58) + } + IPadWorkboardQueueRow( + card: card, + statuses: self.statuses, + isBusy: self.busyCardID == card.id, + inspect: { + self.presentedSheet = .card(card) + }, + openSession: { + self.open(card) + }, + move: { status in + Task { await self.move(card, to: status) } + }, + archive: { + Task { await self.archive(card) } + }) + } + } + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + + private var createCardSheet: some View { + Form { + Section("Card") { + TextField("Title", text: self.$draftTitle) + .textInputAutocapitalization(.sentences) + .submitLabel(.next) + TextField("Notes", text: self.$draftNotes, axis: .vertical) + .lineLimit(3...6) + .textInputAutocapitalization(.sentences) + } + if let errorText { + Section { + Text(errorText) + .foregroundStyle(OpenClawBrand.warn) + } + } + } + .navigationTitle("New Card") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.presentedSheet = nil + } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + if await self.createCard() { + self.presentedSheet = nil + } + } + } label: { + Text(self.isCreatingCard ? "Creating..." : "Create") + } + .disabled(self.isCreatingCard) + .accessibilityHint(self.createUnavailableMessage ?? "Creates a workboard card") + } + } + } + + private var refreshID: String { + [ + self.canRead ? "connected" : "offline", + self.scenePhase == .active ? "active" : "inactive", + self.selectedBoardID.isEmpty ? "all" : self.selectedBoardID, + ].joined(separator: ":") + } + + private var canRead: Bool { + self.appModel.isOperatorGatewayConnected + } + + private var canWrite: Bool { + self.appModel.isOperatorGatewayConnected && !self.appModel + .isAppleReviewDemoModeEnabled + } + + private var currentWorkboardSubtitle: String { + Self.workboardSubtitle( + boardScopeLabel: self.boardScopeLabel, + selectedStatus: self.selectedStatus) + } + + private var boardScopeOptions: [String] { + Self.boardScopeOptions( + knownBoardIDs: self.knownBoardIDs, + cardBoardIDs: self.cards.map { self.boardID(for: $0) }) + } + + private var boardScopeLabel: String { + self.selectedBoardID.isEmpty ? "All boards" : Self.boardScopeLabel(for: self.selectedBoardID) + } + + private var selectedBoardParam: String? { + Self.normalizedScopeID(self.selectedBoardID).isEmpty ? nil : Self.normalizedScopeID(self.selectedBoardID) + } + + private var trimmedDraftTitle: String { + self.draftTitle.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var createUnavailableMessage: String? { + if self.isCreatingCard { + return "Card creation is already in progress." + } + if !self.canWrite { + return Self.compactWriteUnavailableMessage(canRead: self.canRead) + } + if self.trimmedDraftTitle.isEmpty { + return "Enter a title to create a card." + } + return nil + } + + private var isCompactWidth: Bool { + Self.usesCompactTaskFlow( + horizontalSizeClass: self.horizontalSizeClass, + verticalSizeClass: self.verticalSizeClass) + } + + static func usesCompactTaskFlow( + horizontalSizeClass: UserInterfaceSizeClass?, + verticalSizeClass: UserInterfaceSizeClass?) -> Bool + { + horizontalSizeClass == .compact || verticalSizeClass == .compact + } + + static func workboardSubtitle(boardScopeLabel: String, selectedStatus: String) -> String { + "\(boardScopeLabel) / \(IPadWorkboardDefaults.label(for: selectedStatus))" + } + + static func compactWriteUnavailableMessage(canRead: Bool) -> String { + canRead ? "Read-only gateway." : "Connect from Settings to create, move, and dispatch cards." + } + + static func boardScopeOptions(knownBoardIDs: [String], cardBoardIDs: [String]) -> [String] { + Array(Set((knownBoardIDs + cardBoardIDs).map { self.normalizedScopeID($0) }.filter { !$0.isEmpty })) + .sorted() + } + + private var visibleKanbanStatuses: [String] { + if self.selectedStatus == "active" { + return self.statuses.filter { $0 != "done" } + } + if self.statuses.contains(self.selectedStatus) { + return [self.selectedStatus] + } + return self.statuses + } + + private var compactStatuses: [String] { + let preferred = ["todo", "ready", "running", "review", "blocked", "scheduled", "done"] + let known = preferred.filter { self.statuses.contains($0) } + let custom = self.statuses.filter { !preferred.contains($0) } + return known + custom + } + + private func cards(forKanbanStatus status: String) -> [IPadWorkboardCard] { + self.cards + .filter { card in + card.status == status && (self.selectedStatus != "active" || card.metadata?.archivedAt == nil) + } + .filter { self.matchesQuery($0) } + .sorted { $0.position < $1.position } + } + + private var filteredCards: [IPadWorkboardCard] { + self.cards + .filter { card in + if self.selectedStatus == "active" { + return card.metadata?.archivedAt == nil && card.status != "done" + } + return card.status == self.selectedStatus + } + .filter { self.matchesQuery($0) } + .sorted { left, right in + if left.status != right.status { + return IPadWorkboardDefaults.rank(left.status) < IPadWorkboardDefaults.rank(right.status) + } + return left.position < right.position + } + } + + private func matchesQuery(_ card: IPadWorkboardCard) -> Bool { + let trimmedQuery = self.query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmedQuery.isEmpty else { return true } + return [ + card.title, + card.notes, + card.agentId, + card.sessionKey, + card.labels.joined(separator: " "), + ] + .compactMap(\.self) + .joined(separator: " ") + .lowercased() + .contains(trimmedQuery) + } + + private func loadCards(force: Bool) async { + guard self.scenePhase == .active else { return } + guard self.canRead else { + self.cards = [] + self.errorText = nil + return + } + if self.isLoading { return } + + self.isLoading = true + self.errorText = nil + defer { self.isLoading = false } + + if !self.statuses.contains(self.selectedStatus), self.selectedStatus != "active" { + self.selectedStatus = "active" + } + + do { + try await self.applyCardsResponse(self.fetchCards()) + await self.loadBoardScopes(force: force) + } catch { + if force || self.cards.isEmpty { + self.errorText = Self.message(for: error) + } + } + } + + private func beginCreateCard() { + self.draftTitle = "" + self.draftNotes = "" + self.errorText = nil + self.presentedSheet = .create + } + + private func createCard() async -> Bool { + if let createUnavailableMessage { + self.errorText = createUnavailableMessage + return false + } + + self.isCreatingCard = true + self.errorText = nil + defer { self.isCreatingCard = false } + + do { + let status = self.statuses.contains(self.selectedStatus) ? self.selectedStatus : "todo" + let data = try await request( + method: "workboard.cards.create", + params: IPadWorkboardCreateParams( + title: trimmedDraftTitle, + notes: draftNotes.trimmingCharacters(in: .whitespacesAndNewlines), + status: status, + priority: "normal", + labels: [], + agentId: "", + sessionKey: nil, + position: self.nextPosition(for: status), + boardId: self.selectedBoardParam), + timeoutSeconds: 20) + try self.replace(Self.decodeCardResponse(data)) + self.draftTitle = "" + self.draftNotes = "" + return true + } catch { + self.errorText = Self.message(for: error) + return false + } + } + + private func move(_ card: IPadWorkboardCard, to status: String) async { + guard self.canWrite, self.busyCardID == nil else { return } + self.busyCardID = card.id + self.errorText = nil + defer { self.busyCardID = nil } + + do { + let data = try await request( + method: "workboard.cards.move", + params: IPadWorkboardMoveParams( + id: card.id, + status: status, + position: self.nextPosition(for: status, excluding: card.id)), + timeoutSeconds: 20) + try self.replace(Self.decodeCardResponse(data)) + } catch { + self.errorText = Self.message(for: error) + } + } + + private func archive(_ card: IPadWorkboardCard) async { + guard self.canWrite, self.busyCardID == nil else { return } + self.busyCardID = card.id + self.errorText = nil + defer { self.busyCardID = nil } + + do { + let data = try await request( + method: "workboard.cards.archive", + params: IPadWorkboardArchiveParams( + id: card.id, + archived: card.metadata?.archivedAt == nil), + timeoutSeconds: 20) + try self.replace(Self.decodeCardResponse(data)) + } catch { + self.errorText = Self.message(for: error) + } + } + + private func dispatchCards() async { + guard self.canWrite, !self.isLoading else { return } + self.isLoading = true + self.errorText = nil + self.dispatchSummaryText = nil + defer { self.isLoading = false } + + do { + let data = try await request( + method: "workboard.cards.dispatch", + params: IPadWorkboardListParams(boardId: selectedBoardParam), + timeoutSeconds: 45) + self.dispatchSummaryText = try JSONDecoder() + .decode(IPadWorkboardDispatchSummary.self, from: data) + .summaryText + try await self.applyCardsResponse(self.fetchCards()) + } catch { + self.errorText = Self.message(for: error) + } + } + + private func open(_ card: IPadWorkboardCard) { + guard let sessionKey = normalized(card.sessionKey) else { return } + self.appModel.openChat(sessionKey: sessionKey) + self.openChat() + } + + private func replace(_ card: IPadWorkboardCard) { + self.cards.removeAll { $0.id == card.id } + self.cards.append(card) + self.cards.sort { $0.position < $1.position } + } + + private func fetchCards() async throws -> IPadWorkboardCardsResponse { + let data = try await request( + method: "workboard.cards.list", + params: IPadWorkboardListParams(boardId: selectedBoardParam), + timeoutSeconds: 20) + return try JSONDecoder().decode(IPadWorkboardCardsResponse.self, from: data) + } + + private func applyCardsResponse(_ response: IPadWorkboardCardsResponse) { + self.cards = response.cards.sorted { $0.position < $1.position } + self.statuses = self.normalizedStatuses(response.statuses) + self.rememberBoardIDs(from: response.cards) + } + + private func loadBoardScopes(force: Bool) async { + do { + let data = try await request( + method: "workboard.boards.list", + params: EmptyParams(), + timeoutSeconds: 20) + let response = try JSONDecoder().decode(IPadWorkboardBoardsResponse.self, from: data) + self.rememberBoardIDs(from: response.boards) + } catch { + if force, self.knownBoardIDs.isEmpty { + self.errorText = Self.message(for: error) + } + } + } + + private func request(method: String, params: some Encodable, timeoutSeconds: Int) async throws -> Data { + guard self.canRead else { throw IPadSidebarGatewayError.offline } + let data = try JSONEncoder().encode(params) + guard let json = String(data: data, encoding: .utf8) else { + throw IPadSidebarGatewayError.invalidPayload + } + return try await self.appModel.operatorSession.request( + method: method, + paramsJSON: json, + timeoutSeconds: timeoutSeconds) + } + + private func normalizedStatuses(_ statuses: [String]?) -> [String] { + let normalized = (statuses ?? []) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return normalized.isEmpty ? IPadWorkboardDefaults.statuses : normalized + } + + private func nextPosition(for status: String, excluding cardID: String? = nil) -> Double { + let maxPosition = self.cards + .filter { $0.status == status && $0.id != cardID } + .map(\.position) + .max() ?? 0 + return maxPosition + 1000 + } + + private static func decodeCardResponse(_ data: Data) throws -> IPadWorkboardCard { + try JSONDecoder().decode(IPadWorkboardCardResponse.self, from: data).card + } + + private func normalized(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func boardID(for card: IPadWorkboardCard) -> String { + Self.normalizedScopeID(card.metadata?.automation?.boardId).isEmpty + ? "default" + : Self.normalizedScopeID(card.metadata?.automation?.boardId) + } + + private func rememberBoardIDs(from cards: [IPadWorkboardCard]) { + let discovered = cards.map { self.boardID(for: $0) } + self.knownBoardIDs = Array(Set(self.knownBoardIDs + discovered)).sorted() + } + + private func rememberBoardIDs(from boards: [IPadWorkboardBoardSummary]) { + let discovered = boards.map(\.id) + self.knownBoardIDs = Self.boardScopeOptions( + knownBoardIDs: self.knownBoardIDs, + cardBoardIDs: discovered) + } + + static func normalizedScopeID(_ value: String?) -> String { + (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func boardScopeLabel(for boardID: String) -> String { + let normalized = self.normalizedScopeID(boardID) + return normalized.isEmpty ? "All boards" : normalized + } + + private static func message(for error: Error) -> String { + if let gatewayError = error as? IPadSidebarGatewayError { + return gatewayError.message + } + return error.localizedDescription + } +} + +struct IPadWorkboardKanbanColumn: View { + let status: String + let cards: [IPadWorkboardCard] + let statuses: [String] + let busyCardID: String? + let openSession: (IPadWorkboardCard) -> Void + let inspect: (IPadWorkboardCard) -> Void + let move: (IPadWorkboardCard, String) -> Void + let archive: (IPadWorkboardCard) -> Void + + var body: some View { + ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) { + VStack(spacing: 0) { + ProPanelHeader( + title: IPadWorkboardDefaults.label(for: self.status), + value: "\(self.cards.count)", + actionTitle: nil, + action: nil) + + if self.cards.isEmpty { + ProStatusRow( + icon: "tray", + title: "No \(IPadWorkboardDefaults.label(for: self.status).lowercased()) cards", + detail: "Cards moved into this lane appear here.", + value: "empty", + color: .secondary, + actionTitle: nil, + action: nil) + } else { + ForEach(Array(self.cards.enumerated()), id: \.element.id) { index, card in + if index > 0 { + Divider().padding(.leading, 12) + } + IPadWorkboardKanbanCard( + card: card, + statuses: self.statuses, + isBusy: self.busyCardID == card.id, + openSession: { + self.openSession(card) + }, + inspect: { + self.inspect(card) + }, + move: { status in + self.move(card, status) + }, + archive: { + self.archive(card) + }) + } + } + } + } + } +} + +private struct IPadWorkboardKanbanCard: View { + let card: IPadWorkboardCard + let statuses: [String] + let isBusy: Bool + let openSession: () -> Void + let inspect: () -> Void + let move: (String) -> Void + let archive: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Button(action: self.inspect) { + VStack(alignment: .leading, spacing: 7) { + HStack(alignment: .top, spacing: 10) { + ProIconBadge(systemName: self.icon, color: self.color) + VStack(alignment: .leading, spacing: 4) { + Text(self.card.title) + .font(.subheadline.weight(.semibold)) + .lineLimit(2) + Text(self.detail) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + } + } + + if !self.card.labels.isEmpty { + Text(self.card.labels.prefix(3).joined(separator: ", ")) + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + + HStack(spacing: 8) { + if self.card.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + Button(action: self.openSession) { + Image(systemName: "bubble.left.and.text.bubble.right") + } + .accessibilityLabel("Open Session") + .buttonStyle(.bordered) + .controlSize(.mini) + } + + Menu { + ForEach(self.statuses, id: \.self) { status in + Button("Move to \(IPadWorkboardDefaults.label(for: status))") { + self.move(status) + } + } + Button(self.card.metadata?.archivedAt == nil ? "Archive" : "Unarchive", action: self.archive) + } label: { + Image(systemName: self.isBusy ? "hourglass" : "ellipsis") + .frame(width: 22, height: 22) + } + .accessibilityLabel("Card Actions") + .buttonStyle(.bordered) + .controlSize(.mini) + .disabled(self.isBusy) + + Spacer(minLength: 4) + ProValuePill(value: IPadWorkboardDefaults.label(for: self.card.status), color: self.color) + } + } + .padding(12) + .contentShape(Rectangle()) + } + + private var icon: String { + switch self.card.status { + case "running": "figure.run" + case "review": "checklist" + case "blocked": "exclamationmark.triangle" + case "done": "checkmark.circle" + default: "tray" + } + } + + private var color: Color { + switch self.card.status { + case "running": OpenClawBrand.ok + case "review": OpenClawBrand.accent + case "blocked": OpenClawBrand.warn + case "done": .secondary + default: OpenClawBrand.accentHot + } + } + + private var detail: String { + if let notes = card.notes?.trimmingCharacters(in: .whitespacesAndNewlines), !notes.isEmpty { + return notes + } + if let sessionKey = card.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines), !sessionKey.isEmpty { + return sessionKey + } + return self.card.agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + ? self.card.agentId ?? "Default agent" + : "Default agent" + } +} + +struct IPadWorkboardQueueRow: View { + let card: IPadWorkboardCard + let statuses: [String] + let isBusy: Bool + let inspect: () -> Void + let openSession: () -> Void + let move: (String) -> Void + let archive: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Button(action: self.inspect) { + HStack(alignment: .top, spacing: 12) { + ProIconBadge(systemName: self.icon, color: self.color) + VStack(alignment: .leading, spacing: 5) { + Text(self.card.title) + .font(.subheadline.weight(.semibold)) + .lineLimit(2) + Text(self.detail) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer(minLength: 8) + ProValuePill(value: IPadWorkboardDefaults.label(for: self.card.status), color: self.color) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Menu { + self.actionMenuItems + } label: { + Image(systemName: self.isBusy ? "hourglass" : "ellipsis.circle") + .font(.system(size: 19, weight: .semibold)) + .frame(width: 36, height: 36) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(OpenClawBrand.accent) + .disabled(self.isBusy) + .accessibilityLabel("Card Actions") + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .contextMenu { + self.actionMenuItems + } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + Button("Inspect", action: self.inspect) + .tint(OpenClawBrand.accent) + if self.card.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + Button("Open", action: self.openSession) + .tint(OpenClawBrand.ok) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if let nextStatus { + Button(IPadWorkboardDefaults.label(for: nextStatus)) { + self.move(nextStatus) + } + .tint(OpenClawBrand.accentHot) + } + Button(self.card.metadata?.archivedAt == nil ? "Archive" : "Unarchive", action: self.archive) + .tint(.secondary) + } + } + + @ViewBuilder + private var actionMenuItems: some View { + if self.card.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + Button("Open Session", action: self.openSession) + } + Button("Inspect", action: self.inspect) + ForEach(self.statuses, id: \.self) { status in + Button("Move to \(IPadWorkboardDefaults.label(for: status))") { + self.move(status) + } + } + Button(self.card.metadata?.archivedAt == nil ? "Archive" : "Unarchive", action: self.archive) + } + + private var nextStatus: String? { + guard let currentIndex = statuses.firstIndex(of: card.status) else { + return self.statuses.first + } + let nextIndex = self.statuses.index(after: currentIndex) + guard self.statuses.indices.contains(nextIndex) else { return nil } + return self.statuses[nextIndex] + } + + private var icon: String { + switch self.card.status { + case "running": "figure.run" + case "review": "checklist" + case "blocked": "exclamationmark.triangle" + case "done": "checkmark.circle" + default: "tray" + } + } + + private var color: Color { + switch self.card.status { + case "running": OpenClawBrand.ok + case "review": OpenClawBrand.accent + case "blocked": OpenClawBrand.warn + case "done": .secondary + default: OpenClawBrand.accentHot + } + } + + private var detail: String { + if let notes = card.notes?.trimmingCharacters(in: .whitespacesAndNewlines), !notes.isEmpty { + return notes + } + if let sessionKey = card.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines), !sessionKey.isEmpty { + return sessionKey + } + return self.card.agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + ? self.card.agentId ?? "Default agent" + : "Default agent" + } +} + +private struct IPadWorkboardCardDetailSheet: View { + @Environment(\.dismiss) private var dismiss + let card: IPadWorkboardCard + let statuses: [String] + let isBusy: Bool + let canWrite: Bool + let openSession: () -> Void + let move: (String) -> Void + let archive: () -> Void + + var body: some View { + NavigationStack { + Form { + Section("Card") { + LabeledContent("Title", value: self.card.title) + LabeledContent("Status", value: IPadWorkboardDefaults.label(for: self.card.status)) + if let notes = self.card.notes?.trimmingCharacters(in: .whitespacesAndNewlines), !notes.isEmpty { + Text(notes) + } + } + + Section("Actions") { + if self.card.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + Button("Open Session", action: self.openSession) + } + Menu("Move") { + ForEach(self.statuses, id: \.self) { status in + Button(IPadWorkboardDefaults.label(for: status)) { + self.move(status) + } + } + } + .disabled(!self.canWrite || self.isBusy) + Button(self.card.metadata?.archivedAt == nil ? "Archive" : "Unarchive", action: self.archive) + .disabled(!self.canWrite || self.isBusy) + } + } + .navigationTitle("Card") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + self.dismiss() + } + } + } + } + } +} + +private enum IPadWorkboardSheet: Identifiable { + case create + case card(IPadWorkboardCard) + + var id: String { + switch self { + case .create: + "create" + case let .card(card): + "card-\(card.id)" + } + } +} + +private enum IPadWorkboardDefaults { + static let statuses = ["todo", "scheduled", "ready", "running", "review", "blocked", "done"] + + static func label(for status: String) -> String { + status + .replacingOccurrences(of: "_", with: " ") + .split(separator: " ") + .map { $0.prefix(1).uppercased() + $0.dropFirst() } + .joined(separator: " ") + } + + static func rank(_ status: String) -> Int { + self.statuses.firstIndex(of: status) ?? Int.max + } +} + +private struct IPadWorkboardCardsResponse: Decodable { + let cards: [IPadWorkboardCard] + let statuses: [String]? +} + +private struct IPadWorkboardCardResponse: Decodable { + let card: IPadWorkboardCard +} + +private struct IPadWorkboardBoardsResponse: Decodable { + let boards: [IPadWorkboardBoardSummary] +} + +private struct IPadWorkboardBoardSummary: Decodable { + let id: String +} + +struct IPadWorkboardCard: Decodable, Identifiable { + let id: String + let title: String + let notes: String? + let status: String + let priority: String? + let labels: [String] + let agentId: String? + let sessionKey: String? + let position: Double + let updatedAt: Double? + let metadata: IPadWorkboardMetadata? +} + +struct IPadWorkboardMetadata: Decodable { + let archivedAt: Double? + let automation: IPadWorkboardAutomationMetadata? +} + +struct IPadWorkboardAutomationMetadata: Decodable { + let boardId: String? +} + +private struct IPadWorkboardListParams: Encodable { + let boardId: String? +} + +private struct IPadWorkboardCreateParams: Encodable { + let title: String + let notes: String + let status: String + let priority: String + let labels: [String] + let agentId: String + let sessionKey: String? + let position: Double + let boardId: String? +} + +private struct IPadWorkboardMoveParams: Encodable { + let id: String + let status: String + let position: Double +} + +private struct IPadWorkboardArchiveParams: Encodable { + let id: String + let archived: Bool +} + +struct IPadWorkboardDispatchSummary: Decodable { + private let startedCount: Int + private let startFailureCount: Int + private let promotedCount: Int + private let blockedCount: Int + private let reclaimedCount: Int + private let orchestratedCount: Int + private let dispatchCount: Int + + private enum CodingKeys: String, CodingKey { + case started + case startFailures + case promoted + case blocked + case reclaimed + case orchestrated + case count + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.startedCount = Self.arrayCount(container, .started) + self.startFailureCount = Self.arrayCount(container, .startFailures) + self.promotedCount = Self.arrayCount(container, .promoted) + self.blockedCount = Self.arrayCount(container, .blocked) + self.reclaimedCount = Self.arrayCount(container, .reclaimed) + self.orchestratedCount = Self.arrayCount(container, .orchestrated) + self.dispatchCount = (try? container.decode(Int.self, forKey: .count)) ?? 0 + } + + var summaryText: String { + let total = max( + dispatchCount, + self.startedCount + self.promotedCount + self.reclaimedCount + self.orchestratedCount + + self.blockedCount + self.startFailureCount) + if total == 0, self.startFailureCount == 0, self.blockedCount == 0 { + return "No cards dispatched." + } + let outcomes = [ + Self.outcomeText(self.startedCount, "started"), + Self.outcomeText(self.promotedCount, "promoted"), + Self.outcomeText(self.reclaimedCount, "reclaimed"), + Self.outcomeText(self.orchestratedCount, "orchestrated"), + Self.outcomeText(self.blockedCount, "blocked"), + Self.outcomeText(self.startFailureCount, "failed"), + ].compactMap(\.self) + guard !outcomes.isEmpty else { + return "\(total) dispatched." + } + return "\(total) dispatched: \(outcomes.joined(separator: ", "))." + } + + private static func arrayCount( + _ container: KeyedDecodingContainer, + _ key: CodingKeys) -> Int + { + (try? container.decode([IPadWorkboardDispatchEntry].self, forKey: key).count) ?? 0 + } + + private static func outcomeText(_ count: Int, _ label: String) -> String? { + guard count > 0 else { return nil } + return "\(count) \(label)" + } +} + +private struct IPadWorkboardDispatchEntry: Decodable {} diff --git a/apps/ios/Sources/Design/OpenClawBrand.swift b/apps/ios/Sources/Design/OpenClawBrand.swift index 173a492ab8a6..a5a0598b03d3 100644 --- a/apps/ios/Sources/Design/OpenClawBrand.swift +++ b/apps/ios/Sources/Design/OpenClawBrand.swift @@ -47,13 +47,6 @@ enum AppAppearancePreference: String, CaseIterable, Identifiable { } enum OpenClawBrand { - static let lightCanvasTop = Color(red: 246 / 255.0, green: 247 / 255.0, blue: 249 / 255.0) - static let lightCanvasMiddle = Color(red: 250 / 255.0, green: 251 / 255.0, blue: 252 / 255.0) - static let lightCanvasBottom = Color.white - static let darkCanvasTop = Color(red: 3 / 255.0, green: 7 / 255.0, blue: 7 / 255.0) - static let darkCanvasMiddle = Color(red: 13 / 255.0, green: 17 / 255.0, blue: 17 / 255.0) - static let darkCanvasBottom = Color(red: 17 / 255.0, green: 18 / 255.0, blue: 20 / 255.0) - 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) @@ -81,11 +74,6 @@ enum OpenClawBrand { ? UIColor(red: 34 / 255.0, green: 36 / 255.0, blue: 39 / 255.0, alpha: 1) : UIColor.white }) - static let graphiteSoft = Color(uiColor: UIColor { traits in - traits.userInterfaceStyle == .dark - ? UIColor(red: 148 / 255.0, green: 163 / 255.0, blue: 184 / 255.0, alpha: 1) - : UIColor(red: 102 / 255.0, green: 112 / 255.0, blue: 133 / 255.0, alpha: 1) - }) static var sheetBackground: LinearGradient { LinearGradient( @@ -97,40 +85,6 @@ enum OpenClawBrand { startPoint: .topLeading, endPoint: .bottomTrailing) } - - static var toolbarChrome: LinearGradient { - LinearGradient( - colors: [ - graphiteElevated.opacity(0.92), - graphite.opacity(0.78), - ], - startPoint: .topLeading, - endPoint: .bottomTrailing) - } - - static func glassFill(brighten: Bool) -> Color { - Color.black.opacity(brighten ? 0.10 : 0.22) - } - - static func glassStroke(brighten: Bool, increasedContrast: Bool, active: Bool = false) -> Color { - if active { - return self.accent.opacity(increasedContrast ? 0.70 : 0.46) - } - return Color.white.opacity(increasedContrast ? 0.50 : (brighten ? 0.24 : 0.16)) - } - - static func formSectionHeader(_ title: String) -> some View { - Text(title) - .font(.caption.weight(.semibold)) - .foregroundStyle(self.accent) - .textCase(.uppercase) - } - - static func canvasColors(for colorScheme: ColorScheme) -> [Color] { - colorScheme == .dark - ? [self.darkCanvasTop, self.darkCanvasMiddle, self.darkCanvasBottom] - : [self.lightCanvasTop, self.lightCanvasMiddle, self.lightCanvasBottom] - } } extension View { diff --git a/apps/ios/Sources/Design/OpenClawDocsScreen.swift b/apps/ios/Sources/Design/OpenClawDocsScreen.swift new file mode 100644 index 000000000000..70ed060f1037 --- /dev/null +++ b/apps/ios/Sources/Design/OpenClawDocsScreen.swift @@ -0,0 +1,129 @@ +import SwiftUI + +struct OpenClawDocsScreen: View { + private let docsURL = URL(string: "https://docs.openclaw.ai")! + private let gatewayURL = URL(string: "https://docs.openclaw.ai/gateway")! + private let pairingURL = URL(string: "https://docs.openclaw.ai/channels/pairing")! + let headerLeadingAction: OpenClawSidebarHeaderAction? + let gatewayAction: (() -> Void)? + + init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, gatewayAction: (() -> Void)? = nil) { + self.headerLeadingAction = headerLeadingAction + self.gatewayAction = gatewayAction + } + + var body: some View { + ZStack { + OpenClawProBackground() + ScrollView { + VStack(alignment: .leading, spacing: 16) { + self.headerCard + self.linkCard + self.versionCard + } + .padding(.vertical, 18) + } + } + .navigationTitle("Docs") + .navigationBarTitleDisplayMode(.inline) + } + + private var headerCard: some View { + ProCard(radius: OpenClawProMetric.cardRadius) { + OpenClawAdaptiveHeaderRow( + title: "Docs", + subtitle: "Gateway setup, pairing, channels, and mobile node reference.", + titleFont: .headline, + subtitleFont: .caption) + { + HStack(alignment: .top, spacing: 12) { + if let headerLeadingAction { + OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction) + } + ProIconBadge(systemName: "book", color: OpenClawBrand.accent) + } + } accessory: { + self.gatewayPill + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + + @ViewBuilder + private var gatewayPill: some View { + if let gatewayAction { + Button(action: gatewayAction) { + OpenClawGatewayCompactPill() + } + .buttonStyle(.plain) + .accessibilityHint("Opens Settings / Gateway") + } else { + OpenClawGatewayCompactPill() + } + } + + private var linkCard: some View { + ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) { + VStack(spacing: 0) { + self.docsLinkRow( + title: "Docs Home", + detail: "Browse the current OpenClaw reference.", + icon: "book", + url: self.docsURL) + Divider().padding(.leading, 58) + self.docsLinkRow( + title: "Gateway", + detail: "Connection, auth, and diagnostics.", + icon: "network", + url: self.gatewayURL) + Divider().padding(.leading, 58) + self.docsLinkRow( + title: "Pairing", + detail: "Mobile setup codes, QR, and node approval.", + icon: "qrcode", + url: self.pairingURL) + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + + private var versionCard: some View { + ProCard(radius: OpenClawProMetric.cardRadius) { + HStack(spacing: 10) { + Text("Version") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer(minLength: 8) + Text("v\(DeviceInfoHelper.openClawVersionString())") + .font(.caption.weight(.bold)) + .foregroundStyle(.primary) + .textSelection(.enabled) + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + + private func docsLinkRow(title: String, detail: String, icon: String, url: URL) -> some View { + Link(destination: url) { + HStack(spacing: 12) { + ProIconBadge(systemName: icon, color: OpenClawBrand.accent) + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.subheadline.weight(.semibold)) + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 8) + Image(systemName: "arrow.up.right") + .font(.caption.weight(.bold)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} diff --git a/apps/ios/Sources/Design/OpenClawProComponents.swift b/apps/ios/Sources/Design/OpenClawProComponents.swift index 07a69d5aa55d..070b3089d591 100644 --- a/apps/ios/Sources/Design/OpenClawProComponents.swift +++ b/apps/ios/Sources/Design/OpenClawProComponents.swift @@ -1,32 +1,22 @@ import SwiftUI enum OpenClawProMetric { - static let pagePadding: CGFloat = 20 - static let cardRadius: CGFloat = 14 - static let controlRadius: CGFloat = 12 + static let pagePadding: CGFloat = 18 + static let cardRadius: CGFloat = 10 + static let controlRadius: CGFloat = 8 static let bottomScrollInset: CGFloat = 96 - static let heroRadius: CGFloat = 22 } struct OpenClawProBackground: View { @Environment(\.colorScheme) private var colorScheme var body: some View { - LinearGradient( - colors: OpenClawBrand.canvasColors(for: self.colorScheme), - startPoint: .top, - endPoint: .bottom) + Color(uiColor: self.colorScheme == .dark ? .systemBackground : .systemGroupedBackground) .ignoresSafeArea() .overlay(alignment: .top) { if self.colorScheme == .light { - LinearGradient( - colors: [ - OpenClawBrand.accent.opacity(0.05), - .clear, - ], - startPoint: .topTrailing, - endPoint: .bottomLeading) - .frame(height: 260) + Color.white.opacity(0.22) + .frame(height: 140) .ignoresSafeArea() } } @@ -65,7 +55,7 @@ struct ProSectionHeader: View { struct ProCard: View { var tint: Color? var isProminent: Bool = false - var padding: CGFloat = 14 + var padding: CGFloat = 12 var radius: CGFloat = OpenClawProMetric.cardRadius @ViewBuilder var content: Content @@ -90,78 +80,39 @@ private struct ProPanelBackground: View { let shape = RoundedRectangle(cornerRadius: self.radius, style: .continuous) shape .fill(self.fill) - .overlay { - ProPanelTexture() - .opacity(self.colorScheme == .dark ? 0.22 : 0.08) - .clipShape(shape) - } .overlay { shape.strokeBorder(self.borderStyle, lineWidth: 1) } .overlay { - shape - .strokeBorder(Color.black.opacity(self.colorScheme == .dark ? 0.40 : 0.055), lineWidth: 0.7) - .padding(1) - } - .overlay(alignment: .top) { - shape - .strokeBorder(Color.white.opacity(self.colorScheme == .dark ? 0.07 : 0.36), lineWidth: 0.7) - .mask(alignment: .top) { - Rectangle().frame(height: 28) - } + if self.isProminent { + shape.strokeBorder( + OpenClawBrand.accent.opacity(self.colorScheme == .dark ? 0.12 : 0.07), + lineWidth: 1) + .padding(1) + } } } private var fill: AnyShapeStyle { - if self.colorScheme == .dark { - let base = self.isProminent - ? Color(red: 15 / 255, green: 17 / 255, blue: 19 / 255) - : Color(red: 10 / 255, green: 12 / 255, blue: 14 / 255) - return AnyShapeStyle(base) + let base = self.isProminent + ? Color(uiColor: .systemBackground) + : Color(uiColor: .secondarySystemGroupedBackground) + if let tint { + let gradient = LinearGradient( + colors: [ + base, + tint.opacity(self.colorScheme == .dark ? 0.08 : 0.045), + base, + ], + startPoint: .topLeading, + endPoint: .bottomTrailing) + return AnyShapeStyle(gradient) } - - let gradient = LinearGradient( - colors: [ - Color.white.opacity(0.98), - (self.tint ?? Color.white).opacity(self.tint == nil ? 0.92 : 0.12), - Color(red: 246 / 255, green: 247 / 255, blue: 249 / 255), - ], - startPoint: .topLeading, - endPoint: .bottomTrailing) - return AnyShapeStyle(gradient) + return AnyShapeStyle(base) } private var borderStyle: AnyShapeStyle { - if self.colorScheme == .dark { - return AnyShapeStyle(Color.white.opacity(self.isProminent ? 0.15 : 0.11)) - } - - let gradient = LinearGradient( - colors: [ - Color.white.opacity(0.72), - (self.tint ?? OpenClawBrand.accent).opacity(0.10), - Color.black.opacity(0.08), - ], - startPoint: .topLeading, - endPoint: .bottomTrailing) - return AnyShapeStyle(gradient) - } -} - -private struct ProPanelTexture: View { - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - Canvas { context, size in - let color = self.colorScheme == .dark ? Color.white.opacity(0.11) : Color.black.opacity(0.08) - for y in stride(from: 2.0, through: size.height, by: 6.5) { - let offset = Int(y / 6.5).isMultiple(of: 2) ? 0.0 : 3.25 - for x in stride(from: 2.0 + offset, through: size.width, by: 6.5) { - let dot = CGRect(x: x, y: y, width: 0.7, height: 0.7) - context.fill(Path(ellipseIn: dot), with: .color(color)) - } - } - } + AnyShapeStyle(Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.26 : 0.30)) } } @@ -250,9 +201,9 @@ private struct ProPanelSurfaceModifier: ViewModifier { } .modifier(ProLightGlassModifier(radius: self.radius)) .shadow( - color: self.colorScheme == .dark ? .black.opacity(0.60) : .black.opacity(0.045), - radius: self.isProminent ? 20 : 12, - y: self.isProminent ? 10 : 6) + color: self.colorScheme == .dark ? .black.opacity(0.22) : .black.opacity(0.028), + radius: self.isProminent ? 9 : 4, + y: self.isProminent ? 4 : 1) } } @@ -262,16 +213,153 @@ struct ProIconBadge: View { var body: some View { Image(systemName: self.systemName) - .font(.subheadline.weight(.semibold)) + .font(.caption.weight(.semibold)) .foregroundStyle(self.color) - .frame(width: 34, height: 34) + .frame(width: 30, height: 30) .background { - RoundedRectangle(cornerRadius: 10, style: .continuous) + RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(self.color.opacity(0.12)) } } } +struct OpenClawSidebarHeaderAction { + let systemName: String + let accessibilityLabel: String + let accessibilityIdentifier: String? + let action: () -> Void + + init( + systemName: String, + accessibilityLabel: String, + accessibilityIdentifier: String? = nil, + action: @escaping () -> Void) + { + self.systemName = systemName + self.accessibilityLabel = accessibilityLabel + self.accessibilityIdentifier = accessibilityIdentifier + self.action = action + } +} + +struct OpenClawSidebarRevealButton: View { + let headerAction: OpenClawSidebarHeaderAction + + init(action: OpenClawSidebarHeaderAction) { + self.headerAction = action + } + + var body: some View { + let button = Button(action: self.headerAction.action) { + Image(systemName: self.headerAction.systemName) + .font(.system(size: 16, weight: .semibold)) + .frame(width: 38, height: 38) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(OpenClawBrand.accent) + .accessibilityLabel(self.headerAction.accessibilityLabel) + + if let accessibilityIdentifier = self.headerAction.accessibilityIdentifier { + button.accessibilityIdentifier(accessibilityIdentifier) + } else { + button + } + } +} + +struct OpenClawSidebarHeaderLeadingSlot: View { + let action: OpenClawSidebarHeaderAction + + var body: some View { + OpenClawSidebarRevealButton(action: self.action) + .frame(width: 44, height: 44, alignment: .center) + } +} + +struct OpenClawAdaptiveHeaderRow: View { + let title: String + let subtitle: String + var titleFont: Font = .title3.weight(.semibold) + var subtitleFont: Font = .subheadline + var subtitleLineLimit: Int? = 2 + @ViewBuilder let leading: Leading + @ViewBuilder let accessory: Accessory + + init( + title: String, + subtitle: String, + titleFont: Font = .title3.weight(.semibold), + subtitleFont: Font = .subheadline, + subtitleLineLimit: Int? = 2, + @ViewBuilder leading: () -> Leading, + @ViewBuilder accessory: () -> Accessory) + { + self.title = title + self.subtitle = subtitle + self.titleFont = titleFont + self.subtitleFont = subtitleFont + self.subtitleLineLimit = subtitleLineLimit + self.leading = leading() + self.accessory = accessory() + } + + var body: some View { + ViewThatFits(in: .horizontal) { + self.horizontalLayout + self.stackedLayout + } + } + + private var horizontalLayout: some View { + HStack(alignment: .top, spacing: 12) { + self.leading + + self.titleBlock + .layoutPriority(1) + + Spacer(minLength: 8) + + self.accessory + .fixedSize(horizontal: true, vertical: false) + } + } + + private var stackedLayout: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 12) { + self.leading + + self.titleBlock + .layoutPriority(1) + + Spacer(minLength: 8) + } + + HStack { + Spacer(minLength: 0) + self.accessory + .fixedSize(horizontal: true, vertical: false) + } + } + } + + private var titleBlock: some View { + VStack(alignment: .leading, spacing: 4) { + Text(self.title) + .font(self.titleFont) + .lineLimit(2) + .minimumScaleFactor(0.86) + .fixedSize(horizontal: false, vertical: true) + Text(self.subtitle) + .font(self.subtitleFont) + .foregroundStyle(.secondary) + .lineLimit(self.subtitleLineLimit) + .fixedSize(horizontal: false, vertical: true) + } + } +} + struct ProStatusDot: View { var color: Color @@ -279,7 +367,6 @@ struct ProStatusDot: View { Circle() .fill(self.color) .frame(width: 8, height: 8) - .shadow(color: self.color.opacity(0.35), radius: 4) } } @@ -311,7 +398,7 @@ struct OpenClawProMark: View { .resizable() .scaledToFit() .frame(width: self.size, height: self.size) - .shadow(color: OpenClawBrand.accent.opacity(0.28), radius: self.shadowRadius, y: self.shadowRadius / 2) + .shadow(color: OpenClawBrand.accent.opacity(0.18), radius: self.shadowRadius, y: self.shadowRadius / 3) .accessibilityLabel("OpenClaw") } } @@ -335,46 +422,6 @@ struct ProProgressBar: View { } } -struct ProWorkRow: View { - let icon: String - let title: String - let detail: String - let state: String - let trailing: String - let color: Color - var progress: Double? - - var body: some View { - HStack(alignment: .top, spacing: 12) { - ProIconBadge(systemName: self.icon, color: self.color) - VStack(alignment: .leading, spacing: 5) { - HStack(alignment: .firstTextBaseline) { - Text(self.title) - .font(.subheadline.weight(.semibold)) - Spacer(minLength: 8) - Text(self.trailing) - .font(.caption2) - .foregroundStyle(.secondary) - } - Text(self.detail) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - HStack(spacing: 8) { - if let progress { - ProProgressBar(progress: progress, color: self.color) - .frame(maxWidth: 120) - } - Text(self.state) - .font(.caption2.weight(.semibold)) - .foregroundStyle(self.color) - } - } - } - .padding(.vertical, 9) - } -} - struct ProCapsule: View { @Environment(\.colorScheme) private var colorScheme let title: String @@ -389,7 +436,10 @@ struct ProCapsule: View { } Text(self.title) .font(.caption.weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.78) } + .fixedSize(horizontal: true, vertical: false) .foregroundStyle(self.color) .padding(.horizontal, 10) .padding(.vertical, 7) @@ -404,91 +454,54 @@ struct ProCapsule: View { } } -struct ProSegmentedControl: View { - @Environment(\.colorScheme) private var colorScheme - let labels: [String] - @Binding var selection: Int +struct OpenClawGatewayCompactPill: View { + @Environment(NodeAppModel.self) private var appModel var body: some View { - HStack(spacing: 4) { - ForEach(Array(self.labels.enumerated()), id: \.offset) { index, label in - Button { - self.selection = index - } label: { - Text(label) - .font(.subheadline.weight(self.selection == index ? .semibold : .regular)) - .frame(maxWidth: .infinity) - .padding(.vertical, 9) - .background(self.segmentFill(isSelected: self.selection == index), in: Capsule()) - } - .buttonStyle(.plain) - } - } - .padding(4) - .background { - Capsule() - .fill(self.trackFill) - .overlay { - Capsule().strokeBorder(self.trackStroke, lineWidth: 1) - } + ProCapsule( + title: self.title, + color: self.color, + icon: self.icon) + .accessibilityLabel("Gateway \(self.title)") + } + + private var title: String { + switch GatewayStatusBuilder.build(appModel: self.appModel) { + case .connected: + "Online" + case .connecting: + "Connecting" + case .error: + "Attention" + case .disconnected: + "Offline" } } - private func segmentFill(isSelected: Bool) -> Color { - guard isSelected else { return .clear } - return self.colorScheme == .dark ? Color.white.opacity(0.12) : Color.primary.opacity(0.08) - } - - private var trackFill: Color { - self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.72) - } - - private var trackStroke: Color { - self.colorScheme == .dark ? Color.white.opacity(0.10) : Color.black.opacity(0.06) - } -} - -struct ProHeroActionButton: View { - @Environment(\.colorScheme) private var colorScheme - let title: String - let detail: String - let systemImage: String - let action: () -> Void - - var body: some View { - Button(action: self.action) { - HStack(spacing: 12) { - Image(systemName: self.systemImage) - .font(.headline.weight(.semibold)) - .foregroundStyle(.white) - .frame(width: 42, height: 42) - .background(OpenClawBrand.accentHot, in: RoundedRectangle(cornerRadius: 13, style: .continuous)) - - VStack(alignment: .leading, spacing: 3) { - Text(self.title) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.primary) - Text(self.detail) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer(minLength: 8) - - Image(systemName: "arrow.right") - .font(.subheadline.weight(.bold)) - .foregroundStyle(OpenClawBrand.accentHot) - } - .padding(12) - .proGlassSurface( - fill: self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.68), - stroke: OpenClawBrand.accent.opacity(self.colorScheme == .dark ? 0.22 : 0.14), - radius: 18, - isProminent: true, - interactive: true) + private var color: Color { + switch GatewayStatusBuilder.build(appModel: self.appModel) { + case .connected: + OpenClawBrand.ok + case .connecting: + OpenClawBrand.accent + case .error: + OpenClawBrand.warn + case .disconnected: + .secondary + } + } + + private var icon: String { + switch GatewayStatusBuilder.build(appModel: self.appModel) { + case .connected: + "checkmark.circle.fill" + case .connecting: + "arrow.triangle.2.circlepath" + case .error: + "exclamationmark.triangle.fill" + case .disconnected: + "wifi.slash" } - .buttonStyle(.plain) } } @@ -530,48 +543,119 @@ struct ProMetricTile: View { } } +struct ProMetric: Identifiable { + let id = UUID() + let icon: String + let title: String + let value: String + let color: Color +} + +struct ProMetricGrid: View { + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + let metrics: [ProMetric] + + var body: some View { + LazyVGrid( + columns: Array(repeating: GridItem(.flexible()), count: self.columnCount), + spacing: 10) + { + ForEach(self.metrics) { metric in + ProMetricTile( + title: metric.title, + value: metric.value, + icon: metric.icon, + color: metric.color) + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + + private var columnCount: Int { + guard self.horizontalSizeClass != .compact else { return 1 } + return min(max(self.metrics.count, 1), 3) + } +} + +struct ProPanelHeader: View { + let title: String + var value: String? + var actionTitle: String? + var actionIcon: String? + var actionAccessibilityLabel: String? + var isActionDisabled = false + var action: (() -> Void)? + + var body: some View { + HStack(spacing: 8) { + Text(self.title) + .font(.subheadline.weight(.semibold)) + if let value { + Text(value) + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + } + Spacer(minLength: 8) + self.actionControl + } + .padding(.horizontal, 14) + .padding(.top, 12) + .padding(.bottom, 8) + } + + @ViewBuilder + private var actionControl: some View { + if let action { + if let actionIcon { + Button(action: action) { + Image(systemName: actionIcon) + } + .accessibilityLabel(self.actionAccessibilityLabel ?? self.actionTitle ?? self.title) + .disabled(self.isActionDisabled) + } else if let actionTitle { + Button(actionTitle, action: action) + .font(.caption.weight(.semibold)) + .disabled(self.isActionDisabled) + } + } + } +} + struct ProStatusRow: View { let icon: String let title: String let detail: String - let value: String + let value: String? let color: Color + var actionTitle: String? + var action: (() -> Void)? var body: some View { - HStack(alignment: .center, spacing: 12) { + HStack(alignment: .top, spacing: 12) { ProIconBadge(systemName: self.icon, color: self.color) VStack(alignment: .leading, spacing: 4) { Text(self.title) .font(.subheadline.weight(.semibold)) + .lineLimit(1) Text(self.detail) .font(.caption) .foregroundStyle(.secondary) - .lineLimit(1) + .lineLimit(2) } Spacer(minLength: 8) - ProValuePill(value: self.value, color: self.color) - } - .padding(.vertical, 11) - } -} - -struct ProTimelineRow: View { - let done: Bool - let title: String - let detail: String - - var body: some View { - HStack(alignment: .top, spacing: 10) { - ProIconBadge( - systemName: self.done ? "checkmark.circle.fill" : "clock.fill", - color: self.done ? OpenClawBrand.ok : OpenClawBrand.warn) - VStack(alignment: .leading, spacing: 3) { - Text(self.title) - .font(.subheadline.weight(.medium)) - Text(self.detail) - .font(.caption) - .foregroundStyle(.secondary) + VStack(alignment: .trailing, spacing: 6) { + if let value { + ProValuePill(value: value, color: self.color) + } + if let actionTitle, let action { + Button(actionTitle, action: action) + .font(.caption.weight(.semibold)) + .buttonStyle(.bordered) + .controlSize(.mini) + } } } + .padding(.horizontal, 14) + .padding(.vertical, 10) } } diff --git a/apps/ios/Sources/Design/OpenClawProScreens.swift b/apps/ios/Sources/Design/OpenClawProScreens.swift deleted file mode 100644 index 7a79d9d5705e..000000000000 --- a/apps/ios/Sources/Design/OpenClawProScreens.swift +++ /dev/null @@ -1,3 +0,0 @@ -import SwiftUI - -// Pro UI surfaces are split by tab to keep SwiftLint file-length signal useful. diff --git a/apps/ios/Sources/Design/RootTabsPhoneControlHub.swift b/apps/ios/Sources/Design/RootTabsPhoneControlHub.swift new file mode 100644 index 000000000000..835cbcfc418d --- /dev/null +++ b/apps/ios/Sources/Design/RootTabsPhoneControlHub.swift @@ -0,0 +1,417 @@ +import OpenClawProtocol +import SwiftUI + +struct RootTabsPhoneControlHub: View { + @Environment(NodeAppModel.self) private var appModel + @Environment(\.verticalSizeClass) private var verticalSizeClass + @State private var navigationPath: [RootTabs.SidebarDestination] = [] + @State private var didApplyInitialDestination = false + + let groups: [RootTabs.SidebarGroup] + let initialDestination: RootTabs.SidebarDestination? + let openRootDestination: (RootTabs.SidebarDestination) -> Void + + var body: some View { + NavigationStack(path: self.$navigationPath) { + ZStack { + OpenClawProBackground() + ScrollView { + VStack(alignment: .leading, spacing: self.isCompactHeight ? 10 : 16) { + self.headerCard + ForEach(self.groups) { group in + self.groupSection(group) + } + self.versionFooter + } + .padding(.vertical, self.isCompactHeight ? 10 : 16) + } + .safeAreaPadding(.bottom, self.bottomScrollInset) + } + .navigationTitle("Control") + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(for: RootTabs.SidebarDestination.self) { destination in + self.detail(for: destination) + .navigationBarBackButtonHidden(true) + .toolbar(.hidden, for: .navigationBar) + } + .onAppear { + self.applyInitialDestinationIfNeeded() + } + } + } + + @ViewBuilder + private var headerCard: some View { + if self.isCompactHeight { + ProCard(padding: 8, radius: OpenClawProMetric.cardRadius) { + HStack(spacing: 12) { + OpenClawProMark(size: 24, shadowRadius: 3) + VStack(alignment: .leading, spacing: 3) { + Text(self.sidebarActiveAgentTitle) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + Text(self.gatewayDisplayLabel) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer(minLength: 8) + ProValuePill(value: self.gatewayStateText, color: self.gatewayStateColor) + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } else { + ProCard(radius: OpenClawProMetric.cardRadius) { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + OpenClawProMark(size: 32, shadowRadius: 4) + VStack(alignment: .leading, spacing: 3) { + Text(self.sidebarActiveAgentTitle) + .font(.headline) + .lineLimit(1) + Text(self.gatewayDisplayLabel) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer(minLength: 8) + ProValuePill(value: self.gatewayStateText, color: self.gatewayStateColor) + } + + self.gatewayActionRow + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + } + + private var gatewayActionRow: some View { + Button { + self.openRootDestination(.gateway) + } label: { + HStack(spacing: 10) { + ProStatusDot(color: self.gatewayStateColor) + VStack(alignment: .leading, spacing: 2) { + Text(self.gatewayStateText) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + Text(self.gatewayDisplayLabel) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer(minLength: 8) + Text(self.gatewayActionTitle) + .font(.caption.weight(.semibold)) + .foregroundStyle(OpenClawBrand.accent) + Image(systemName: "chevron.right") + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + } + .padding(10) + .background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityLabel("Gateway \(self.gatewayStateText)") + .accessibilityHint("Opens Settings / Gateway") + } + + private func groupSection(_ group: RootTabs.SidebarGroup) -> some View { + VStack(alignment: .leading, spacing: self.isCompactHeight ? 6 : 8) { + ProSectionHeader(title: group.title.capitalized) + ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) { + VStack(spacing: 0) { + ForEach(Array(group.destinations.enumerated()), id: \.element.id) { index, destination in + if index > 0 { + Divider().padding(.leading, 58) + } + self.destinationRow(destination) + } + } + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + + @ViewBuilder + private func destinationRow(_ destination: RootTabs.SidebarDestination) -> some View { + if self.opensRootTab(destination) { + Button { + self.openRootDestination(destination) + } label: { + self.rowLabel(destination) + } + .buttonStyle(.plain) + } else { + Button { + self.navigationPath.append(destination) + } label: { + self.rowLabel(destination) + } + .buttonStyle(.plain) + } + } + + private func rowLabel(_ destination: RootTabs.SidebarDestination) -> some View { + HStack(alignment: .center, spacing: 12) { + ProIconBadge(systemName: destination.systemImage, color: self.color(for: destination)) + VStack(alignment: .leading, spacing: 3) { + Text(destination.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + Text(destination.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + } + .padding(.vertical, self.isCompactHeight ? 8 : 10) + .padding(.horizontal, 14) + .contentShape(Rectangle()) + } + + private var versionFooter: some View { + ProCard(radius: OpenClawProMetric.cardRadius) { + HStack { + Spacer() + Text("v\(DeviceInfoHelper.openClawVersionString())") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer() + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + + @ViewBuilder + private func detail(for destination: RootTabs.SidebarDestination) -> some View { + switch destination { + case .chat, .talk, .agents, .gateway: + EmptyView() + case .overview: + CommandCenterTab( + headerTitle: "Overview", + headerLeadingAction: self.phoneDetailBackAction, + showsHeaderMark: false, + openChat: { self.openRootDestination(.chat) }, + openSettings: { self.openRootDestination(.gateway) }) + case .activity: + IPadActivityScreen( + headerLeadingAction: self.phoneDetailBackAction, + openChat: { self.openRootDestination(.chat) }, + openSettings: { self.openRootDestination(.gateway) }) + case .workboard: + IPadWorkboardScreen( + headerLeadingAction: self.phoneDetailBackAction, + openChat: { self.openRootDestination(.chat) }, + openSettings: { self.openRootDestination(.gateway) }) + case .skillWorkshop: + IPadSkillWorkshopScreen( + headerLeadingAction: self.phoneDetailBackAction, + openSettings: { self.openRootDestination(.gateway) }) + case .instances: + AgentProTab( + directRoute: .instances, + headerLeadingAction: self.phoneDetailBackAction, + headerTitle: "Instances", + openSettings: { self.openRootDestination(.gateway) }) + case .sessions: + CommandSessionsScreen( + headerLeadingAction: self.phoneDetailBackAction, + openChat: { self.openRootDestination(.chat) }) + case .dreaming: + AgentProTab( + directRoute: .dreaming, + headerLeadingAction: self.phoneDetailBackAction, + headerTitle: "Dreaming", + openSettings: { self.openRootDestination(.gateway) }) + case .usage: + AgentProTab( + directRoute: .usage, + headerLeadingAction: self.phoneDetailBackAction, + headerTitle: "Usage", + openSettings: { self.openRootDestination(.gateway) }) + case .cron: + AgentProTab( + directRoute: .cron, + headerLeadingAction: self.phoneDetailBackAction, + headerTitle: "Cron Jobs", + openSettings: { self.openRootDestination(.gateway) }) + case .docs: + OpenClawDocsScreen( + headerLeadingAction: self.phoneDetailBackAction, + gatewayAction: { self.openRootDestination(.gateway) }) + case .settings: + EmptyView() + } + } + + private var phoneDetailBackAction: OpenClawSidebarHeaderAction { + OpenClawSidebarHeaderAction( + systemName: "chevron.left", + accessibilityLabel: "Back to Control", + accessibilityIdentifier: "OpenClawPhoneDetailBackButton", + action: { self.popPhoneDetail() }) + } + + private func popPhoneDetail() { + guard !self.navigationPath.isEmpty else { return } + self.navigationPath.removeLast() + } + + private func opensRootTab(_ destination: RootTabs.SidebarDestination) -> Bool { + RootTabs.shouldOpenRootTabFromPhoneHub(destination) + } + + private func applyInitialDestinationIfNeeded() { + guard !self.didApplyInitialDestination else { return } + self.didApplyInitialDestination = true + guard let initialDestination, initialDestination != .overview else { return } + if self.opensRootTab(initialDestination) { + self.openRootDestination(initialDestination) + } else { + self.navigationPath = [initialDestination] + } + } + + private var sidebarActiveAgentTitle: String { + let selectedID = self.normalized(self.appModel.selectedAgentId) ?? self.resolveDefaultAgentID() + if let agent = self.appModel.gatewayAgents.first(where: { $0.id == selectedID }) { + return self.agentTitle(for: agent) + } + return self.normalized(self.appModel.activeAgentName) ?? "Default Agent" + } + + private var gatewayDisplayLabel: String { + self.normalized(self.appModel.gatewayServerName) + ?? self.normalized(self.appModel.gatewayRemoteAddress) + ?? self.appModel.gatewayDisplayStatusText + } + + private var gatewayStateText: String { + switch GatewayStatusBuilder.build(appModel: self.appModel) { + case .connected: "Online" + case .connecting: "Connecting" + case .error: "Attention" + case .disconnected: "Offline" + } + } + + private var gatewayStateColor: Color { + switch GatewayStatusBuilder.build(appModel: self.appModel) { + case .connected: + OpenClawBrand.ok + case .connecting: + OpenClawBrand.accent + case .error: + OpenClawBrand.warn + case .disconnected: + .secondary + } + } + + private var gatewayActionTitle: String { + switch GatewayStatusBuilder.build(appModel: self.appModel) { + case .connected: + "Manage" + case .connecting: + "Details" + case .error: + "Fix" + case .disconnected: + "Connect" + } + } + + private var isCompactHeight: Bool { + self.verticalSizeClass == .compact + } + + private var bottomScrollInset: CGFloat { + Self.bottomScrollInset(verticalSizeClass: self.verticalSizeClass) + } + + static func bottomScrollInset(verticalSizeClass: UserInterfaceSizeClass?) -> CGFloat { + verticalSizeClass == .compact ? 72 : 112 + } + + private func color(for destination: RootTabs.SidebarDestination) -> Color { + switch destination { + case .chat, .talk, .overview, .gateway: + OpenClawBrand.accent + case .instances: + Color.secondary + case .activity, .usage, .docs: + OpenClawBrand.accentHot + case .agents, .workboard, .skillWorkshop, .sessions, .dreaming, .cron, .settings: + OpenClawBrand.ok + } + } + + private func resolveDefaultAgentID() -> String { + self.normalized(self.appModel.gatewayDefaultAgentId) ?? "" + } + + private func agentTitle(for agent: AgentSummary) -> String { + let name = self.normalized(agent.name) ?? agent.id + return name == agent.id ? name : "\(name) (\(agent.id))" + } + + private func normalized(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} + +#if DEBUG +#Preview("Phone control hub offline") { + RootTabsPhoneControlHub.preview(appModel: NodeAppModel()) +} + +#Preview("Phone control hub connected") { + let appModel = NodeAppModel() + appModel.enterAppleReviewDemoMode() + return RootTabsPhoneControlHub.preview(appModel: appModel) +} + +#Preview("Phone control hub connecting") { + let appModel = NodeAppModel() + appModel.gatewayStatusText = "Connecting..." + return RootTabsPhoneControlHub.preview(appModel: appModel) +} + +#Preview("Phone control hub gateway error") { + let appModel = NodeAppModel() + appModel.gatewayStatusText = "Gateway error: connection refused" + return RootTabsPhoneControlHub.preview(appModel: appModel) +} + +#Preview( + "Phone control hub landscape", + traits: .fixedLayout(width: 852, height: 393), + .landscapeLeft) +{ + RootTabsPhoneControlHub.preview(appModel: NodeAppModel()) + .environment(\.horizontalSizeClass, .regular) + .environment(\.verticalSizeClass, .compact) +} + +extension RootTabsPhoneControlHub { + fileprivate static func preview(appModel: NodeAppModel) -> some View { + RootTabsPhoneControlHub( + groups: RootTabs.phoneControlGroups, + initialDestination: nil, + openRootDestination: { _ in }) + .environment(appModel) + } +} +#endif diff --git a/apps/ios/Sources/Design/SettingsChannelsDestination.swift b/apps/ios/Sources/Design/SettingsChannelsDestination.swift new file mode 100644 index 000000000000..5d64e38260b0 --- /dev/null +++ b/apps/ios/Sources/Design/SettingsChannelsDestination.swift @@ -0,0 +1,667 @@ +import OpenClawKit +import OpenClawProtocol +import SwiftUI + +struct SettingsChannelsDestination: View { + @Environment(NodeAppModel.self) private var appModel + @Environment(\.scenePhase) private var scenePhase + let showsSummaryCard: Bool + @State private var snapshot: ChannelsStatusResult? + @State private var isLoading = false + @State private var errorText: String? + @State private var busyOperation: SettingsChannelOperation? + + init(showsSummaryCard: Bool = true) { + self.showsSummaryCard = showsSummaryCard + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + if self.showsSummaryCard { + self.summaryCard + } + self.channelsCard + } + .task(id: self.refreshID) { + await self.loadChannels(force: false) + } + .refreshable { + await self.loadChannels(force: true) + } + } + + private var summaryCard: some View { + ProCard(radius: SettingsLayout.cardRadius) { + HStack(spacing: 12) { + ProIconBadge(systemName: "point.3.connected.trianglepath.dotted", color: self.summaryColor) + VStack(alignment: .leading, spacing: 3) { + Text("Channels / Integrations") + .font(.headline) + Text(self.summaryDetail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + ProValuePill(value: self.summaryValue, color: self.summaryColor) + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + + private var channelsCard: some View { + ProCard(padding: 0, radius: SettingsLayout.cardRadius) { + VStack(spacing: 0) { + ProPanelHeader( + title: "Message Routing", + value: self.headerValue, + actionIcon: self.isLoading ? "hourglass" : "arrow.clockwise", + actionAccessibilityLabel: "Refresh Channels", + isActionDisabled: self.isLoading, + action: { + Task { await self.loadChannels(force: true) } + }) + + if let errorText { + ProStatusRow( + icon: "exclamationmark.triangle", + title: "Channel status unavailable", + detail: errorText, + value: "error", + color: OpenClawBrand.warn) + } else if !self.canRead { + ProStatusRow( + icon: "wifi.slash", + title: "Gateway offline", + detail: "Connect to the gateway to load installed channels, accounts, and routing status.", + value: "offline", + color: .secondary) + } else if self.isLoading, self.snapshot == nil { + ProStatusRow( + icon: "hourglass", + title: "Loading channels", + detail: "Fetching installed channels, accounts, and routing status from the gateway.", + value: "loading", + color: OpenClawBrand.accent) + } else if self.channelEntries.isEmpty { + ProStatusRow( + icon: "tray", + title: "No channel plugins reported", + detail: "Install or enable channel plugins on the gateway, then refresh.", + value: "empty", + color: .secondary) + } else { + ForEach(Array(self.channelEntries.enumerated()), id: \.element.id) { index, entry in + if index > 0 { + Divider().padding(.leading, 58) + } + SettingsChannelRow( + entry: entry, + canAdmin: self.canAdmin, + busyOperation: self.busyOperation, + start: { accountID in + Task { await self.run(.start, channelID: entry.id, accountID: accountID) } + }, + stop: { accountID in + Task { await self.run(.stop, channelID: entry.id, accountID: accountID) } + }, + logout: { accountID in + Task { await self.run(.logout, channelID: entry.id, accountID: accountID) } + }) + } + } + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + + private var refreshID: String { + [ + self.canRead ? "connected" : "offline", + self.scenePhase == .active ? "active" : "inactive", + ].joined(separator: ":") + } + + private var canRead: Bool { + self.appModel.isOperatorGatewayConnected + } + + private var canAdmin: Bool { + self.appModel.hasOperatorAdminScope + } + + static func shouldEnableChannelOperation(canRead: Bool, hasOperatorAdminScope: Bool) -> Bool { + canRead && hasOperatorAdminScope + } + + private var headerValue: String? { + if self.isLoading { return "Loading" } + guard self.canRead else { return "Offline" } + return "\(self.channelEntries.count)" + } + + private var summaryDetail: String { + guard self.canRead else { + return "Connect to load channel integrations." + } + if let errorText { + return errorText + } + return "Installed channel clients, account state, and message-routing readiness." + } + + private var summaryValue: String { + guard self.canRead else { return "offline" } + if self.isLoading { return "loading" } + if self.errorText != nil { return "error" } + let configured = self.channelEntries.count(where: { $0.configured }) + return "\(configured)/\(self.channelEntries.count)" + } + + private var summaryColor: Color { + guard self.canRead else { return .secondary } + if self.errorText != nil { return OpenClawBrand.warn } + return self.channelEntries.contains(where: { $0.running || $0.connected }) ? OpenClawBrand.ok : OpenClawBrand + .accent + } + + private var channelEntries: [SettingsChannelEntry] { + guard let snapshot else { return [] } + let ids = snapshot.channelorder.isEmpty ? Array(snapshot.channels.keys).sorted() : snapshot.channelorder + return ids.map { self.entry(channelID: $0, snapshot: snapshot) } + } + + private func entry(channelID: String, snapshot: ChannelsStatusResult) -> SettingsChannelEntry { + let summary = snapshot.channels[channelID]?.dictionaryValue ?? [:] + let accounts = self.accounts(channelID: channelID, snapshot: snapshot) + let configured = accounts.contains(where: \.configured) || summary["configured"]?.boolValue == true + let running = accounts.contains(where: \.running) + let connected = accounts.contains(where: \.connected) + let linked = accounts.contains(where: \.linked) + let label = snapshot.channellabels[channelID]?.stringValue ?? Self.fallbackLabel(channelID) + let detail = snapshot.channeldetaillabels?[channelID]?.stringValue ?? Self.fallbackDetail(channelID) + let systemImage = snapshot.channelsystemimages?[channelID]?.stringValue ?? Self.fallbackSystemImage(channelID) + let lastActivity = accounts.compactMap(\.lastActivityMs).max() + let lastError = accounts.compactMap(\.lastError).first ?? summary["lastError"]?.stringValue + return SettingsChannelEntry( + id: channelID, + label: label, + detail: detail, + systemImage: systemImage, + configured: configured, + running: running, + connected: connected, + linked: linked, + lastActivityText: lastActivity.map(Self.relativeTime), + lastError: lastError, + unavailableReason: configured ? nil : "Configure this channel on the gateway.", + accounts: accounts) + } + + private func accounts(channelID: String, snapshot: ChannelsStatusResult) -> [SettingsChannelAccount] { + let rawAccounts = snapshot.channelaccounts[channelID]?.arrayValue ?? [] + return rawAccounts.compactMap { raw in + guard let dict = raw.dictionaryValue else { return nil } + let accountID = dict["accountId"]?.stringValue ?? "default" + let name = dict["name"]?.stringValue + let lastActivity = [ + dict["lastInboundAt"]?.intValue, + dict["lastOutboundAt"]?.intValue, + dict["lastTransportActivityAt"]?.intValue, + ] + .compactMap(\.self) + .max() + return SettingsChannelAccount( + id: accountID, + name: name, + configured: dict["configured"]?.boolValue == true, + enabled: dict["enabled"]?.boolValue != false, + running: dict["running"]?.boolValue == true, + connected: dict["connected"]?.boolValue == true, + linked: dict["linked"]?.boolValue == true, + healthState: dict["healthState"]?.stringValue, + lastError: dict["lastError"]?.stringValue, + lastActivityMs: lastActivity) + } + } + + private func loadChannels(force: Bool) async { + guard self.scenePhase == .active else { return } + guard self.canRead else { + self.snapshot = nil + self.errorText = nil + return + } + if self.isLoading { return } + + self.isLoading = true + self.errorText = nil + defer { self.isLoading = false } + + do { + let params = ChannelsStatusParams(probe: false, timeoutms: 10000, channel: nil) + let data = try await self.request(method: "channels.status", params: params, timeoutSeconds: 12) + self.snapshot = try JSONDecoder().decode(ChannelsStatusResult.self, from: data) + } catch { + if force || self.snapshot == nil { + self.errorText = Self.message(for: error) + } + } + } + + private func run(_ kind: SettingsChannelOperation.Kind, channelID: String, accountID: String?) async { + guard Self.shouldEnableChannelOperation(canRead: self.canRead, hasOperatorAdminScope: self.canAdmin), + self.busyOperation == nil + else { + return + } + self.busyOperation = SettingsChannelOperation(kind: kind, channelID: channelID, accountID: accountID) + self.errorText = nil + defer { self.busyOperation = nil } + + do { + switch kind { + case .start: + let params = ChannelsStartParams(channel: channelID, accountid: accountID) + _ = try await self.request(method: "channels.start", params: params, timeoutSeconds: 20) + case .stop: + let params = ChannelsStopParams(channel: channelID, accountid: accountID) + _ = try await self.request(method: "channels.stop", params: params, timeoutSeconds: 20) + case .logout: + let params = ChannelsLogoutParams(channel: channelID, accountid: accountID) + _ = try await self.request(method: "channels.logout", params: params, timeoutSeconds: 20) + } + await self.loadChannels(force: true) + } catch { + self.errorText = Self.message(for: error) + } + } + + private func request(method: String, params: some Encodable, timeoutSeconds: Int) async throws -> Data { + let data = try JSONEncoder().encode(params) + guard let json = String(data: data, encoding: .utf8) else { + throw SettingsChannelError.invalidPayload + } + return try await self.appModel.operatorSession.request( + method: method, + paramsJSON: json, + timeoutSeconds: timeoutSeconds) + } + + static func fallbackLabel(_ id: String) -> String { + if let metadata = self.fallbackMetadata[id.lowercased()] { + return metadata.label + } + return id.replacingOccurrences(of: "-", with: " ") + .replacingOccurrences(of: "_", with: " ") + .split(separator: " ") + .map { $0.prefix(1).uppercased() + $0.dropFirst() } + .joined(separator: " ") + } + + static func fallbackDetail(_ id: String) -> String { + self.fallbackMetadata[id.lowercased()]?.detail ?? "Channel integration" + } + + static func fallbackSystemImage(_ id: String) -> String { + self.fallbackMetadata[id.lowercased()]?.systemImage ?? "bubble.left.and.text.bubble.right" + } + + private static let fallbackMetadata: [String: SettingsChannelFallbackMetadata] = [ + "clickclack": SettingsChannelFallbackMetadata( + label: "ClickClack", + detail: "Self-hosted chat bot routing.", + systemImage: "bubble.left.and.bubble.right"), + ] + + private static func relativeTime(_ milliseconds: Int) -> String { + let age = max(0, Int(Date().timeIntervalSince1970 * 1000) - milliseconds) + let minutes = age / 60000 + if minutes < 1 { return "now" } + if minutes < 60 { return "\(minutes)m ago" } + let hours = minutes / 60 + if hours < 24 { return "\(hours)h ago" } + return "\(hours / 24)d ago" + } + + private static func message(for error: Error) -> String { + if let channelError = error as? SettingsChannelError { + return channelError.message + } + return error.localizedDescription + } +} + +private struct SettingsChannelRow: View { + let entry: SettingsChannelEntry + let canAdmin: Bool + let busyOperation: SettingsChannelOperation? + let start: (String?) -> Void + let stop: (String?) -> Void + let logout: (String?) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 12) { + ProIconBadge(systemName: self.entry.systemImage, color: self.entry.color) + VStack(alignment: .leading, spacing: 4) { + Text(self.entry.label) + .font(.subheadline.weight(.semibold)) + Text(self.entry.detailText) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + if let lastError = self.entry.lastError { + Text(lastError) + .font(.caption2.weight(.medium)) + .foregroundStyle(OpenClawBrand.warn) + .lineLimit(2) + } + } + Spacer(minLength: 8) + ProValuePill(value: self.entry.statusValue, color: self.entry.color) + } + + if !self.entry.accounts.isEmpty { + VStack(spacing: 0) { + ForEach(Array(self.entry.accounts.enumerated()), id: \.element.id) { index, account in + if index > 0 { + Divider().padding(.leading, 38) + } + self.accountRow(account) + } + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + + private func accountRow(_ account: SettingsChannelAccount) -> some View { + HStack(spacing: 10) { + Image(systemName: account.running || account.connected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(account.color) + .frame(width: 28, height: 28) + VStack(alignment: .leading, spacing: 2) { + Text(account.displayName) + .font(.caption.weight(.semibold)) + Text(account.detailText) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 8) + Menu { + if account.running { + Button("Stop") { + self.stop(account.id) + } + } else { + Button("Start") { + self.start(account.id) + } + .disabled(!account.configured || !account.enabled) + } + if account.linked { + Button("Logout", role: .destructive) { + self.logout(account.id) + } + } + } label: { + Image(systemName: self.actionMenuIcon(account)) + } + .buttonStyle(.bordered) + .controlSize(.mini) + .disabled(!self.canAdmin || self.isBusy(account)) + } + .padding(.vertical, 8) + } + + private func actionMenuIcon(_ account: SettingsChannelAccount) -> String { + if self.isBusy(account) { + return "hourglass" + } + if !self.canAdmin { + return "lock.shield" + } + return "ellipsis.circle" + } + + private func isBusy(_ account: SettingsChannelAccount) -> Bool { + self.busyOperation?.channelID == self.entry.id && self.busyOperation?.accountID == account.id + } +} + +private struct SettingsChannelEntry: Identifiable { + let id: String + let label: String + let detail: String + let systemImage: String + let configured: Bool + let running: Bool + let connected: Bool + let linked: Bool + let lastActivityText: String? + let lastError: String? + let unavailableReason: String? + let accounts: [SettingsChannelAccount] + + var color: Color { + if self.connected || self.running { return OpenClawBrand.ok } + if self.lastError != nil { return OpenClawBrand.warn } + return self.configured ? OpenClawBrand.accent : .secondary + } + + var statusValue: String { + if self.connected { return "connected" } + if self.running { return "running" } + if self.linked { return "linked" } + if self.configured { return "configured" } + return "not set" + } + + var detailText: String { + if let lastActivityText { + return "\(self.detail) • active \(lastActivityText)" + } + if let unavailableReason { + return unavailableReason + } + return self.detail + } +} + +private struct SettingsChannelFallbackMetadata { + let label: String + let detail: String + let systemImage: String +} + +private struct SettingsChannelAccount: Identifiable { + let id: String + let name: String? + let configured: Bool + let enabled: Bool + let running: Bool + let connected: Bool + let linked: Bool + let healthState: String? + let lastError: String? + let lastActivityMs: Int? + + var displayName: String { + let trimmedName = self.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmedName.isEmpty ? self.id : "\(trimmedName) (\(self.id))" + } + + var detailText: String { + let state = if self.connected { + "connected" + } else if self.running { + "running" + } else if self.linked { + "linked" + } else if self.configured { + "configured" + } else { + "not configured" + } + let enabledText = self.enabled ? "enabled" : "disabled" + if let healthState, !healthState.isEmpty { + return "\(state), \(enabledText), \(healthState)" + } + if let lastError, !lastError.isEmpty { + return "\(state), \(enabledText), error" + } + return "\(state), \(enabledText)" + } + + var color: Color { + if self.connected || self.running { return OpenClawBrand.ok } + if self.lastError != nil { return OpenClawBrand.warn } + return self.configured ? OpenClawBrand.accent : .secondary + } +} + +private struct SettingsChannelOperation: Equatable { + enum Kind { + case start + case stop + case logout + } + + let kind: Kind + let channelID: String + let accountID: String? +} + +private enum SettingsChannelError: Error { + case invalidPayload + + var message: String { + switch self { + case .invalidPayload: + "Could not encode channel request." + } + } +} + +#if DEBUG +#Preview("Channels states") { + SettingsChannelsStatesPreview() +} + +private struct SettingsChannelsStatesPreview: View { + var body: some View { + ZStack { + OpenClawProBackground() + ScrollView { + VStack(alignment: .leading, spacing: 16) { + self.stateSection("Connected") { + SettingsChannelRow( + entry: Self.telegramEntry, + canAdmin: true, + busyOperation: nil, + start: { _ in }, + stop: { _ in }, + logout: { _ in }) + } + + self.stateSection("Loading") { + ProPanelHeader( + title: "Message Routing", + value: "Loading", + actionIcon: "hourglass", + actionAccessibilityLabel: "Refresh Channels", + isActionDisabled: true, + action: {}) + ProStatusRow( + icon: "hourglass", + title: "Loading channel status", + detail: "Checking installed channel clients and account state.", + value: "loading", + color: OpenClawBrand.accent) + } + + self.stateSection("Empty") { + ProPanelHeader( + title: "Message Routing", + value: "0", + actionIcon: "arrow.clockwise", + actionAccessibilityLabel: "Refresh Channels", + action: {}) + ProStatusRow( + icon: "tray", + title: "No channel plugins reported", + detail: "Install or enable channel plugins on the gateway, then refresh.", + value: "empty", + color: .secondary) + } + + self.stateSection("Error") { + ProStatusRow( + icon: "exclamationmark.triangle", + title: "Channel status unavailable", + detail: "Gateway returned an unexpected channel status response.", + value: "error", + color: OpenClawBrand.warn) + } + + self.stateSection("Offline") { + ProStatusRow( + icon: "wifi.slash", + title: "Gateway offline", + detail: "Connect to the gateway to load installed channels, accounts, and routing status.", + value: "offline", + color: .secondary) + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + .padding(.vertical, 18) + } + } + } + + private func stateSection( + _ title: String, + @ViewBuilder content: () -> some View) -> some View + { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + ProCard(padding: 0, radius: SettingsLayout.cardRadius) { + VStack(spacing: 0) { + content() + } + } + } + } + + private static let telegramEntry = SettingsChannelEntry( + id: "telegram", + label: "Telegram", + detail: "Message routing client", + systemImage: "paperplane", + configured: true, + running: true, + connected: true, + linked: true, + lastActivityText: "4m ago", + lastError: nil, + unavailableReason: nil, + accounts: [ + SettingsChannelAccount( + id: "main", + name: "OpenClaw Ops", + configured: true, + enabled: true, + running: true, + connected: true, + linked: true, + healthState: "healthy", + lastError: nil, + lastActivityMs: nil), + ]) +} +#endif diff --git a/apps/ios/Sources/Design/SettingsProTab.swift b/apps/ios/Sources/Design/SettingsProTab.swift index d7c3ba50b17f..632909f45534 100644 --- a/apps/ios/Sources/Design/SettingsProTab.swift +++ b/apps/ios/Sources/Design/SettingsProTab.swift @@ -1,7 +1,5 @@ import OpenClawKit import SwiftUI -import UIKit -import UserNotifications struct SettingsProTab: View { @Environment(NodeAppModel.self) var appModel @@ -59,14 +57,39 @@ struct SettingsProTab: View { @State var notificationActionText = "Request Access" @State var diagnosticsLastRunText = "Not run" @State var diagnosticsIssueCount: Int? - @State var bottomOverlayInset: CGFloat = 0 + @State var showTalkIssueDetails = false + @State private var navigationPath: [SettingsRoute] = [] + let initialRoute: SettingsRoute? + let directRoute: SettingsRoute? + let headerLeadingAction: OpenClawSidebarHeaderAction? - var bottomScrollMargin: CGFloat { - max(0, self.bottomOverlayInset - SettingsLayout.rowHeight - SettingsLayout.bottomContentPadding) + init( + initialRoute: SettingsRoute? = nil, + directRoute: SettingsRoute? = nil, + headerLeadingAction: OpenClawSidebarHeaderAction? = nil) + { + self.initialRoute = initialRoute + self.directRoute = directRoute + self.headerLeadingAction = headerLeadingAction } var body: some View { - NavigationStack { + self.settingsModalPresentation( + self.settingsLifecycle( + self.settingsContent)) + } + + @ViewBuilder + private var settingsContent: some View { + if let directRoute { + self.destination(for: directRoute) + } else { + self.settingsNavigationStack + } + } + + private var settingsNavigationStack: some View { + NavigationStack(path: self.$navigationPath) { ZStack { OpenClawProBackground() ScrollView { @@ -77,22 +100,24 @@ struct SettingsProTab: View { self.settingsListSection } .padding(.top, 18) - .padding(.bottom, SettingsLayout.bottomContentPadding) + .padding(.bottom, 18) } - .contentMargins(.bottom, self.bottomScrollMargin, for: .scrollContent) - SettingsBottomOverlayInsetReader(inset: self.$bottomOverlayInset) - .frame(width: 0, height: 0) - .allowsHitTesting(false) } .navigationBarHidden(true) .navigationDestination(for: SettingsRoute.self) { route in self.destination(for: route) } + } + } + + private func settingsLifecycle(_ content: some View) -> some View { + content .task { self.previousLocationModeRaw = self.locationModeRaw self.syncSettingsState() self.refreshNotificationSettings() self.applyPendingGatewaySetupLinkIfNeeded() + self.applyInitialRouteIfNeeded() } .onChange(of: self.scenePhase) { _, phase in if phase == .active { @@ -129,58 +154,76 @@ struct SettingsProTab: View { .onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in self.applyPendingGatewaySetupLinkIfNeeded() } - } - .sheet(isPresented: self.$showGatewayProblemDetails) { - if let gatewayProblem = self.appModel.lastGatewayProblem { - GatewayProblemDetailsSheet( - problem: gatewayProblem, - primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem), - onPrimaryAction: { - Task { await self.handleGatewayProblemPrimaryAction(gatewayProblem) } - }) + } + + private func settingsModalPresentation(_ content: some View) -> some View { + content + .sheet(isPresented: self.$showGatewayProblemDetails) { + if let gatewayProblem = self.appModel.lastGatewayProblem { + GatewayProblemDetailsSheet( + problem: gatewayProblem, + primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem), + onPrimaryAction: { + Task { await self.handleGatewayProblemPrimaryAction(gatewayProblem) } + }) + } } - } - .sheet(isPresented: self.$showQRScanner) { - NavigationStack { - QRScannerView( - onGatewayLink: { link in - self.handleScannedGatewayLink(link) - }, - onError: { error in - self.showQRScanner = false - self.setupStatusText = "Scanner error: \(error)" - self.scannerError = error - }, - onDismiss: { - self.showQRScanner = false - }) - .ignoresSafeArea() - .navigationTitle("Scan QR Code") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("Cancel") { self.showQRScanner = false } + .sheet(isPresented: self.$showTalkIssueDetails) { + if let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue { + TalkRuntimeIssueDetailsSheet(issue: issue) + } + } + .sheet(isPresented: self.$showQRScanner) { + NavigationStack { + QRScannerView( + onGatewayLink: { link in + self.handleScannedGatewayLink(link) + }, + onSetupCode: { code in + self.handleScannedSetupCode(code) + }, + onError: { error in + self.showQRScanner = false + self.setupStatusText = "Scanner error: \(error)" + self.scannerError = error + }, + onDismiss: { + self.showQRScanner = false + }) + .ignoresSafeArea() + .navigationTitle("Scan QR Code") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { self.showQRScanner = false } + } } - } + } } - } - .alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) { - Button("Reset", role: .destructive) { - self.resetOnboarding() + .alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) { + Button("Reset", role: .destructive) { + self.resetOnboarding() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This disconnects, clears saved gateway credentials, and reopens onboarding.") } - Button("Cancel", role: .cancel) {} - } message: { - Text("This disconnects, clears saved gateway credentials, and reopens onboarding.") - } - .alert( - "QR Scanner Unavailable", - isPresented: Binding( - get: { self.scannerError != nil }, - set: { if !$0 { self.scannerError = nil } })) - { - Button("OK", role: .cancel) {} - } message: { - Text(self.scannerError ?? "") - } + .alert( + "QR Scanner Unavailable", + isPresented: Binding( + get: { self.scannerError != nil }, + set: { if !$0 { self.scannerError = nil } })) + { + Button("OK", role: .cancel) {} + } message: { + Text(self.scannerError ?? "") + } + } + + private func applyInitialRouteIfNeeded() { + guard self.directRoute == nil else { return } + guard let initialRoute else { return } + guard self.navigationPath != [initialRoute] else { return } + self.navigationPath = [initialRoute] } } diff --git a/apps/ios/Sources/Design/SettingsProTabActions.swift b/apps/ios/Sources/Design/SettingsProTabActions.swift index fa897fdca5a7..8c8f63178877 100644 --- a/apps/ios/Sources/Design/SettingsProTabActions.swift +++ b/apps/ios/Sources/Design/SettingsProTabActions.swift @@ -42,9 +42,9 @@ extension SettingsProTab { self.diagnosticCheckRow( icon: "antenna.radiowaves.left.and.right", title: "Gateway Link", - detail: self.appModel.gatewayDisplayStatusText, - value: self.gatewayConnected ? "online" : "offline", - color: self.gatewayConnected ? OpenClawBrand.ok : .secondary) + detail: self.gatewayStatusDetail, + value: self.gatewayStatusValue, + color: self.gatewayStatusColor) Divider().padding(.leading, 60) self.diagnosticCheckRow( icon: "dot.radiowaves.left.and.right", @@ -56,9 +56,9 @@ extension SettingsProTab { self.diagnosticCheckRow( icon: "waveform", title: "Talk Config", - detail: self.appModel.talkMode.gatewayTalkTransportLabel, - value: self.appModel.talkMode.gatewayTalkConfigLoaded ? "loaded" : "missing", - color: self.appModel.talkMode.gatewayTalkConfigLoaded ? OpenClawBrand.ok : .secondary) + detail: self.gatewayTalkConfigDetail, + value: self.gatewayTalkConfigValue, + color: self.gatewayTalkConfigColor) Divider().padding(.leading, 60) self.diagnosticCheckRow( icon: "bell", @@ -132,37 +132,31 @@ extension SettingsProTab { } func reconnectGateway() async { + guard !self.appModel.isAppleReviewDemoModeEnabled else { return } guard !self.isReconnectingGateway else { return } self.isReconnectingGateway = true defer { self.isReconnectingGateway = false } await self.gatewayController.connectLastKnown() } - func refreshGateway() async { - guard !self.isRefreshingGateway else { return } - self.isRefreshingGateway = true - defer { self.isRefreshingGateway = false } - self.gatewayController.refreshActiveGatewayRegistrationFromSettings() - self.gatewayController.restartDiscovery() - await self.appModel.refreshGatewayOverviewIfConnected() - } - @MainActor func runDiagnostics() async { guard !self.isRefreshingGateway else { return } self.isRefreshingGateway = true defer { self.isRefreshingGateway = false } - self.gatewayController.refreshActiveGatewayRegistrationFromSettings() - self.gatewayController.restartDiscovery() - await self.appModel.refreshGatewayOverviewIfConnected() + if !self.appModel.isAppleReviewDemoModeEnabled { + self.gatewayController.refreshActiveGatewayRegistrationFromSettings() + self.gatewayController.restartDiscovery() + await self.appModel.refreshGatewayOverviewIfConnected() + } let notificationSettings = await UNUserNotificationCenter.current().notificationSettings() self.applyNotificationStatus(notificationSettings.authorizationStatus) let issueCount = SettingsDiagnostics.issueCount( - gatewayConnected: self.gatewayConnected, + gatewayConnected: self.gatewayDiagnosticConnected, discoveredGatewayCount: self.gatewayController.gateways.count, - talkConfigLoaded: self.appModel.talkMode.gatewayTalkConfigLoaded, + talkConfigLoaded: self.gatewayDiagnosticTalkConfigLoaded, notificationStatusText: self.notificationStatusText) self.diagnosticsIssueCount = issueCount self.diagnosticsLastRunText = SettingsDiagnostics.timestamp(Date()) @@ -197,7 +191,7 @@ extension SettingsProTab { self.setupStatusText = "Failed: invalid port" return } - guard await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS) else { return } + guard await self.preflightGateway(host: host, port: port) else { return } self.setupStatusText = "Setup code applied. Connecting..." await self.connectManual() } @@ -220,6 +214,14 @@ extension SettingsProTab { return false } + if AppleReviewDemoMode.isSetupCode(raw) { + self.stagedGatewaySetupLink = nil + self.setupCode = "" + self.setupStatusText = "Apple Review demo mode enabled." + self.appModel.enterAppleReviewDemoMode() + return false + } + guard let link = raw.isEmpty ? stagedLink : GatewayConnectDeepLink.fromSetupInput(raw) else { self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL." return false @@ -272,13 +274,22 @@ extension SettingsProTab { Task { await self.connectAfterScannedGatewayLink() } } + func handleScannedSetupCode(_ code: String) { + guard AppleReviewDemoMode.isSetupCode(code) else { return } + self.showQRScanner = false + self.setupCode = "" + self.stagedGatewaySetupLink = nil + self.setupStatusText = "Apple Review demo mode enabled." + self.appModel.enterAppleReviewDemoMode() + } + func connectAfterScannedGatewayLink() async { let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) guard let port = self.resolvedManualPort(host: host) else { self.setupStatusText = "Failed: invalid port" return } - guard await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS) else { return } + guard await self.preflightGateway(host: host, port: port) else { return } await self.connectManual() } @@ -307,7 +318,7 @@ extension SettingsProTab { authOverride: authOverride) } - func preflightGateway(host: String, port: Int, useTLS: Bool) async -> Bool { + func preflightGateway(host: String, port: Int) async -> Bool { let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return false } if Self.isTailnetHostOrIP(trimmed), !Self.hasTailnetIPv4() { @@ -473,7 +484,9 @@ extension SettingsProTab { func title(for route: SettingsRoute) -> String { switch route { case .gateway: "Gateway" + case .approvals: "Approvals" case .permissions: "Permissions" + case .channels: "Channels" case .voice: "Voice & Talk" case .diagnostics: "Diagnostics" case .privacy: "Privacy" @@ -482,6 +495,20 @@ extension SettingsProTab { } } + func subtitle(for route: SettingsRoute) -> String { + switch route { + case .gateway: "Pairing, diagnostics, and Tailscale checks." + case .approvals: "Review pending agent actions." + case .permissions: "Control device capabilities." + case .channels: "Message routing and external clients." + case .voice: "Talk mode and wake phrase settings." + case .diagnostics: "Run local health checks." + case .privacy: "Data and device privacy controls." + case .notifications: "Alert permissions and delivery." + case .about: "Version and support details." + } + } + var manualPortBinding: Binding { Binding( get: { self.manualGatewayPortText }, @@ -589,6 +616,21 @@ extension SettingsProTab { return self.appModel.talkMode.gatewayTalkApiKeyConfigured ? "Configured" : "Not configured" } + var gatewayTalkActiveVoiceDetail: String { + let title = self.appModel.talkMode.gatewayTalkActiveModeTitle.trimmingCharacters(in: .whitespacesAndNewlines) + let subtitle = (self.appModel.talkMode.gatewayTalkActiveModeSubtitle ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + if title.isEmpty { return "Not active" } + if subtitle.isEmpty { return title } + return "\(title) • \(subtitle)" + } + + var gatewayTalkLastIssueDetail: String? { + let detail = (self.appModel.talkMode.gatewayTalkLastIssueText ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + return detail.isEmpty ? nil : detail + } + func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] { var lines: [String] = [] if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") } @@ -602,7 +644,53 @@ extension SettingsProTab { } var gatewayConnected: Bool { - GatewayStatusBuilder.build(appModel: self.appModel) == .connected + !self.appModel.isAppleReviewDemoModeEnabled && + GatewayStatusBuilder.build(appModel: self.appModel) == .connected + } + + var gatewayStatusDetail: String { + if self.appModel.isAppleReviewDemoModeEnabled { return "Apple Review demo mode" } + return self.gatewayConnected ? "Connected" : self.appModel.gatewayDisplayStatusText + } + + var gatewayStatusValue: String { + if self.appModel.isAppleReviewDemoModeEnabled { return "demo" } + return self.gatewayConnected ? "online" : "offline" + } + + var gatewayStatusColor: Color { + if self.appModel.isAppleReviewDemoModeEnabled { return OpenClawBrand.accent } + return self.gatewayConnected ? OpenClawBrand.ok : .secondary + } + + var gatewayDiagnosticConnected: Bool { + self.appModel.isAppleReviewDemoModeEnabled || self.gatewayConnected + } + + var gatewayDiagnosticTalkConfigLoaded: Bool { + self.appModel.isAppleReviewDemoModeEnabled || self.appModel.talkMode.gatewayTalkConfigLoaded + } + + var approvalEmptyDetail: String { + if self.appModel.isAppleReviewDemoModeEnabled { + return "Live gateway requests are disabled in demo mode." + } + return self.gatewayConnected ? "Gateway requests will appear here." : "Connect to the gateway." + } + + var gatewayTalkConfigDetail: String { + if self.appModel.isAppleReviewDemoModeEnabled { return "Demo mode only" } + return self.appModel.talkMode.gatewayTalkTransportLabel + } + + var gatewayTalkConfigValue: String { + if self.appModel.isAppleReviewDemoModeEnabled { return "demo" } + return self.appModel.talkMode.gatewayTalkConfigLoaded ? "loaded" : "missing" + } + + var gatewayTalkConfigColor: Color { + if self.appModel.isAppleReviewDemoModeEnabled { return .secondary } + return self.appModel.talkMode.gatewayTalkConfigLoaded ? OpenClawBrand.ok : .secondary } var gatewayAddress: String { @@ -621,6 +709,34 @@ extension SettingsProTab { return "\(enabled) enabled" } + var pendingApproval: NodeAppModel.ExecApprovalPrompt? { + self.appModel.pendingExecApprovalPrompt + } + + var approvalsDetail: String { + self.pendingApproval == nil ? "No approvals waiting" : "1 request waiting" + } + + var approvalItems: [SettingsApprovalItem] { + guard let pendingApproval else { return [] } + return [ + SettingsApprovalItem( + id: "pending-real", + icon: "terminal.fill", + title: pendingApproval.commandPreview ?? "Review gateway action", + detail: "Agent: \(self.appModel.activeAgentName)", + priority: self.appModel.pendingExecApprovalPromptResolving ? "Resolving" : "High", + color: OpenClawBrand.danger), + SettingsApprovalItem( + id: "pending-context", + icon: "doc.text.fill", + title: pendingApproval.allowsAllowAlways ? "Permission can be saved" : "One-time approval", + detail: "Gateway request", + priority: pendingApproval.allowsAllowAlways ? "Medium" : "Review", + color: OpenClawBrand.warn), + ] + } + var voiceDetail: String { if self.talkEnabled, self.voiceWakeEnabled { return "Talk + Wake" } if self.talkEnabled { return "Talk on" } @@ -633,6 +749,7 @@ extension SettingsProTab { } var diagnosticsHealthValue: String { + if self.appModel.isAppleReviewDemoModeEnabled { return "demo" } if self.gatewayConnected { return "ready" } if self.gatewayController.gateways.isEmpty { return "check" } return "partial" diff --git a/apps/ios/Sources/Design/SettingsProTabSections.swift b/apps/ios/Sources/Design/SettingsProTabSections.swift index c8613acd6332..76365de8d55a 100644 --- a/apps/ios/Sources/Design/SettingsProTabSections.swift +++ b/apps/ios/Sources/Design/SettingsProTabSections.swift @@ -3,10 +3,20 @@ import SwiftUI extension SettingsProTab { var settingsHeader: some View { - Text("Settings") - .font(.system(size: 28, weight: .bold)) - .padding(.horizontal, OpenClawProMetric.pagePadding) - .padding(.top, 6) + OpenClawAdaptiveHeaderRow( + title: "Settings", + subtitle: "Gateway, permissions, voice, and device controls.", + titleFont: .title3.weight(.semibold), + subtitleFont: .callout) + { + if let headerLeadingAction { + OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction) + } + } accessory: { + EmptyView() + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + .padding(.top, 6) } var appearanceSection: some View { @@ -60,14 +70,14 @@ extension SettingsProTab { HStack(spacing: 12) { ProIconBadge( systemName: "antenna.radiowaves.left.and.right", - color: self.gatewayConnected ? OpenClawBrand.ok : .secondary) + color: self.gatewayStatusColor) VStack(alignment: .leading, spacing: 3) { Text("Connection") .font(.subheadline.weight(.semibold)) - Text(self.gatewayConnected ? "Connected" : self.appModel.gatewayDisplayStatusText) + Text(self.gatewayStatusDetail) .font(.caption) - .foregroundStyle(self.gatewayConnected ? OpenClawBrand.ok : .secondary) + .foregroundStyle(self.gatewayStatusColor) } Spacer(minLength: 8) @@ -100,7 +110,8 @@ extension SettingsProTab { title: "Reconnect", icon: "arrow.triangle.2.circlepath", color: OpenClawBrand.warn, - isBusy: self.isReconnectingGateway) + isBusy: self.isReconnectingGateway, + isDisabled: self.appModel.isAppleReviewDemoModeEnabled) { Task { await self.reconnectGateway() } } @@ -118,11 +129,23 @@ extension SettingsProTab { var settingsListSection: some View { VStack(spacing: 10) { + self.settingsListRow( + icon: "checkmark.shield.fill", + title: "Approvals", + detail: self.approvalsDetail, + route: .approvals, + color: self.pendingApproval == nil ? .secondary : OpenClawBrand.warn, + badgeValue: self.pendingApproval == nil ? nil : "1") self.settingsListRow( icon: "person.2", title: "Permissions", detail: self.permissionsDetail, route: .permissions) + self.settingsListRow( + icon: "point.3.connected.trianglepath.dotted", + title: "Channels / Integrations", + detail: "Message routing and external channel clients.", + route: .channels) self.settingsListRow( icon: "waveform", title: "Voice & Talk", @@ -156,11 +179,13 @@ extension SettingsProTab { icon: String, title: String, detail: String, - route: SettingsRoute) -> some View + route: SettingsRoute, + color: Color = .secondary, + badgeValue: String? = nil) -> some View { NavigationLink(value: route) { HStack(spacing: 12) { - ProIconBadge(systemName: icon, color: .secondary) + ProIconBadge(systemName: icon, color: color) VStack(alignment: .leading, spacing: 2) { Text(title) .font(.subheadline.weight(.semibold)) @@ -170,6 +195,9 @@ extension SettingsProTab { .lineLimit(1) } Spacer(minLength: 8) + if let badgeValue { + ProValuePill(value: badgeValue, color: color) + } Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) @@ -186,11 +214,18 @@ extension SettingsProTab { OpenClawProBackground() ScrollView { VStack(alignment: .leading, spacing: 14) { + if self.headerLeadingAction != nil { + self.routeHeader(for: route) + } switch route { case .gateway: self.gatewayDestination + case .approvals: + self.approvalsDestination case .permissions: self.permissionsDestination + case .channels: + SettingsChannelsDestination() case .voice: self.voiceDestination case .diagnostics: @@ -204,15 +239,29 @@ extension SettingsProTab { } } .padding(.top, 18) - .padding(.bottom, SettingsLayout.bottomContentPadding) + .padding(.bottom, OpenClawProMetric.bottomScrollInset) } - .contentMargins(.bottom, self.bottomScrollMargin, for: .scrollContent) - SettingsBottomOverlayInsetReader(inset: self.$bottomOverlayInset) - .frame(width: 0, height: 0) - .allowsHitTesting(false) } .navigationTitle(self.title(for: route)) .navigationBarTitleDisplayMode(.inline) + .toolbar(self.headerLeadingAction == nil ? .visible : .hidden, for: .navigationBar) + } + + func routeHeader(for route: SettingsRoute) -> some View { + OpenClawAdaptiveHeaderRow( + title: self.title(for: route), + subtitle: self.subtitle(for: route), + titleFont: .title3.weight(.semibold), + subtitleFont: .callout) + { + if let headerLeadingAction { + OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction) + } + } accessory: { + EmptyView() + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + .padding(.top, 6) } var gatewayDestination: some View { @@ -224,9 +273,9 @@ extension SettingsProTab { self.detailStatusCard( icon: "antenna.radiowaves.left.and.right", title: "Gateway", - detail: self.gatewayConnected ? "Connected" : self.appModel.gatewayDisplayStatusText, - value: self.gatewayConnected ? "online" : "offline", - color: self.gatewayConnected ? OpenClawBrand.ok : .secondary) + detail: self.gatewayStatusDetail, + value: self.gatewayStatusValue, + color: self.gatewayStatusColor) self.detailListCard { self.detailRow("Address", value: self.gatewayAddress) @@ -235,7 +284,7 @@ extension SettingsProTab { Divider() self.detailRow("Discovered", value: "\(self.gatewayController.gateways.count)") Divider() - self.detailRow("Active Agent", value: self.appModel.activeAgentName) + self.detailRow("Default Agent", value: self.appModel.activeAgentName) Divider() self.detailRow("Agents", value: "\(self.appModel.gatewayAgents.count)") } @@ -254,6 +303,89 @@ extension SettingsProTab { } } + var approvalsDestination: some View { + VStack(alignment: .leading, spacing: 14) { + self.detailStatusCard( + icon: "checkmark.shield.fill", + title: "Approvals", + detail: self.pendingApproval == nil ? "No gateway actions are waiting for review." : + "Review the pending gateway action.", + value: self.pendingApproval == nil ? "clear" : "1 waiting", + color: self.pendingApproval == nil ? OpenClawBrand.ok : OpenClawBrand.warn) + + self.approvalsReviewCard + } + } + + var approvalsReviewCard: some View { + ProCard(radius: SettingsLayout.cardRadius) { + VStack(alignment: .leading, spacing: 12) { + if let pendingApproval { + VStack(spacing: 0) { + ForEach(Array(self.approvalItems.enumerated()), id: \.element.id) { index, item in + SettingsApprovalRow(item: item) + if index < self.approvalItems.count - 1 { + Divider().padding(.leading, 46) + } + } + } + + if let errorText = self.appModel.pendingExecApprovalPromptErrorText { + Text(errorText) + .font(.caption2.weight(.medium)) + .foregroundStyle(OpenClawBrand.danger) + } + + HStack(spacing: 8) { + Button { + Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once") } + } label: { + Label("Allow", systemImage: "checkmark") + } + .buttonStyle(.borderedProminent) + .disabled(self.appModel.pendingExecApprovalPromptResolving) + + if pendingApproval.allowsAllowAlways { + Button { + Task { + await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-always") + } + } label: { + Label("Always", systemImage: "checkmark.shield") + } + .buttonStyle(.bordered) + .disabled(self.appModel.pendingExecApprovalPromptResolving) + } + + Button(role: .destructive) { + Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "deny") } + } label: { + Label("Deny", systemImage: "xmark") + } + .buttonStyle(.bordered) + .disabled(self.appModel.pendingExecApprovalPromptResolving) + + Spacer(minLength: 0) + } + .controlSize(.small) + } else { + HStack(spacing: 12) { + ProIconBadge(systemName: "checkmark.shield.fill", color: OpenClawBrand.ok) + VStack(alignment: .leading, spacing: 3) { + Text("No approvals waiting") + .font(.subheadline.weight(.semibold)) + Text(self.approvalEmptyDetail) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + } + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + } + var permissionsDestination: some View { VStack(alignment: .leading, spacing: 14) { self.toggleCard( @@ -296,7 +428,7 @@ extension SettingsProTab { title: "Health Check", detail: "Run app, permission, and gateway-adjacent checks without editing setup.", value: self.diagnosticsHealthValue, - color: self.gatewayConnected ? OpenClawBrand.ok : OpenClawBrand.warn) + color: self.gatewayDiagnosticConnected ? OpenClawBrand.ok : OpenClawBrand.warn) ProCard(radius: SettingsLayout.cardRadius) { self.gatewayActionButton( @@ -410,6 +542,7 @@ extension SettingsProTab { icon: String, color: Color, isBusy: Bool, + isDisabled: Bool = false, action: @escaping () -> Void) -> some View { Button(action: action) { @@ -431,7 +564,7 @@ extension SettingsProTab { } } .buttonStyle(.plain) - .disabled(isBusy) + .disabled(isBusy || isDisabled) } func toggleCard( @@ -503,7 +636,7 @@ extension SettingsProTab { var agentSelectionCard: some View { ProCard(radius: SettingsLayout.cardRadius) { VStack(alignment: .leading, spacing: 10) { - Text("Active Agent") + Text("Default Agent") .font(.subheadline.weight(.semibold)) Picker("Agent", selection: self.$selectedAgentPickerId) { Text("Default").tag("") @@ -514,7 +647,7 @@ extension SettingsProTab { Text(name.isEmpty ? agent.id : name).tag(agent.id) } } - Text("Controls which agent Chat and Talk use.") + Text("Used for new Chat and Talk sessions.") .font(.caption) .foregroundStyle(.secondary) } @@ -616,7 +749,7 @@ extension SettingsProTab { var manualGatewayCard: some View { ProCard(radius: SettingsLayout.cardRadius) { VStack(alignment: .leading, spacing: 12) { - Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled) + self.settingsButtonToggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled) TextField("Host", text: self.$manualGatewayHost) .textInputAutocapitalization(.never) .autocorrectionDisabled() @@ -624,7 +757,7 @@ extension SettingsProTab { TextField("Port", text: self.manualPortBinding) .keyboardType(.numberPad) .textFieldStyle(.roundedBorder) - Toggle("Use TLS", isOn: self.$manualGatewayTLS) + self.settingsButtonToggle("Use TLS", isOn: self.$manualGatewayTLS) self.gatewayActionButton( title: "Connect Manual", icon: "network", @@ -643,16 +776,21 @@ extension SettingsProTab { var gatewayAdvancedCard: some View { ProCard(radius: SettingsLayout.cardRadius) { VStack(alignment: .leading, spacing: 12) { - Toggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect) + self.settingsButtonToggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect) SecureField("Gateway Auth Token", text: self.$gatewayToken) .textInputAutocapitalization(.never) .autocorrectionDisabled() .textFieldStyle(.roundedBorder) SecureField("Gateway Password", text: self.$gatewayPassword) .textFieldStyle(.roundedBorder) - Button("Reset Onboarding", role: .destructive) { + Button(role: .destructive) { self.showResetOnboardingAlert = true + } label: { + Label("Reset Onboarding", systemImage: "arrow.counterclockwise") + .frame(maxWidth: .infinity) } + .buttonStyle(.bordered) + .controlSize(.small) } } .padding(.horizontal, OpenClawProMetric.pagePadding) @@ -665,8 +803,13 @@ extension SettingsProTab { self.appModel.setVoiceWakeEnabled(enabled) } self.settingsToggle("Talk Mode", isOn: self.$talkEnabled) { enabled in + guard !self.appModel.isAppleReviewDemoModeEnabled else { + self.talkEnabled = false + return + } self.appModel.setTalkEnabled(enabled) } + .disabled(self.appModel.isAppleReviewDemoModeEnabled) Picker("Speech Language", selection: self.$talkSpeechLocale) { ForEach(TalkSpeechLocale.supportedOptions()) { option in Text(option.label).tag(option.id) @@ -687,26 +830,44 @@ extension SettingsProTab { } var talkVoiceSettingsCard: some View { - ProCard(radius: SettingsLayout.cardRadius) { - VStack(alignment: .leading, spacing: 12) { - Picker("Provider", selection: self.talkProviderSelectionBinding) { - ForEach(TalkModeProviderSelection.allCases) { option in - Text(option.label).tag(option.rawValue) - } - } - if self.shouldShowRealtimeVoicePicker { - Picker("Realtime Voice", selection: self.talkRealtimeVoiceSelectionBinding) { - Text("Gateway Default").tag("") - ForEach(TalkModeRealtimeVoiceSelection.voices, id: \.self) { voice in - Text(TalkModeRealtimeVoiceSelection.label(for: voice)).tag(voice) + VStack(alignment: .leading, spacing: 10) { + if self.gatewayConnected, + let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue + { + TalkRuntimeIssueBanner( + issue: issue, + onOpenSettings: nil, + onShowDetails: { + self.showTalkIssueDetails = true + }) + } + ProCard(radius: SettingsLayout.cardRadius) { + VStack(alignment: .leading, spacing: 12) { + Picker("Provider", selection: self.talkProviderSelectionBinding) { + ForEach(TalkModeProviderSelection.allCases) { option in + Text(option.label).tag(option.rawValue) } } + if self.shouldShowRealtimeVoicePicker { + Picker("Realtime Voice", selection: self.talkRealtimeVoiceSelectionBinding) { + Text("Gateway Default").tag("") + ForEach(TalkModeRealtimeVoiceSelection.voices, id: \.self) { voice in + Text(TalkModeRealtimeVoiceSelection.label(for: voice)).tag(voice) + } + } + } + self.detailRow("Voice Mode", value: self.appModel.talkMode.gatewayTalkVoiceModeTitle) + Divider() + self.detailRow("Active Voice", value: self.gatewayTalkActiveVoiceDetail) + if let issue = self.gatewayTalkLastIssueDetail { + Divider() + self.detailRow("Last Voice Issue", value: issue) + } + Divider() + self.detailRow("Transport", value: self.appModel.talkMode.gatewayTalkTransportLabel) + Divider() + self.detailRow("API Key", value: self.talkApiKeyStatus) } - self.detailRow("Voice Mode", value: self.appModel.talkMode.gatewayTalkVoiceModeTitle) - Divider() - self.detailRow("Transport", value: self.appModel.talkMode.gatewayTalkTransportLabel) - Divider() - self.detailRow("API Key", value: self.talkApiKeyStatus) } } .padding(.horizontal, OpenClawProMetric.pagePadding) @@ -744,11 +905,10 @@ extension SettingsProTab { var diagnosticsAdvancedCard: some View { ProCard(radius: SettingsLayout.cardRadius) { VStack(alignment: .leading, spacing: 12) { - Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled) - .onChange(of: self.discoveryDebugLogsEnabled) { _, enabled in - self.gatewayController.setDiscoveryDebugLoggingEnabled(enabled) - } - Toggle("Debug Screen Status", isOn: self.$canvasDebugStatusEnabled) + self.settingsButtonToggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled) { enabled in + self.gatewayController.setDiscoveryDebugLoggingEnabled(enabled) + } + self.settingsButtonToggle("Debug Screen Status", isOn: self.$canvasDebugStatusEnabled) NavigationLink { GatewayDiscoveryDebugLogView() } label: { @@ -796,6 +956,44 @@ extension SettingsProTab { } } + func settingsButtonToggle( + _ title: String, + isOn: Binding, + onChange: ((Bool) -> Void)? = nil) -> some View + { + // Settings switch rows need full-width taps; wrapping Toggle crashes this NavigationStack on iOS 26. + Button { + isOn.wrappedValue.toggle() + } label: { + HStack { + Text(title) + Spacer(minLength: 8) + self.settingsSwitchIndicator(isOn: isOn.wrappedValue) + } + .font(.subheadline) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(title) + .accessibilityValue(isOn.wrappedValue ? "On" : "Off") + .onChange(of: isOn.wrappedValue) { _, enabled in + onChange?(enabled) + } + } + + func settingsSwitchIndicator(isOn: Bool) -> some View { + Capsule() + .fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35)) + .frame(width: 52, height: 32) + .overlay(alignment: isOn ? .trailing : .leading) { + Circle() + .fill(Color.white) + .frame(width: 28, height: 28) + .padding(2) + .shadow(color: Color.black.opacity(0.14), radius: 1, x: 0, y: 1) + } + } + func simpleSettingsRow(title: String, value: String) -> some View { HStack { Text(title) diff --git a/apps/ios/Sources/Design/SettingsProTabSupport.swift b/apps/ios/Sources/Design/SettingsProTabSupport.swift index cf24feef801e..256731cc8798 100644 --- a/apps/ios/Sources/Design/SettingsProTabSupport.swift +++ b/apps/ios/Sources/Design/SettingsProTabSupport.swift @@ -1,10 +1,12 @@ import Darwin +import OpenClawKit import SwiftUI -import UIKit enum SettingsRoute: Hashable { case gateway + case approvals case permissions + case channels case voice case diagnostics case privacy @@ -15,109 +17,51 @@ enum SettingsRoute: Hashable { enum SettingsLayout { static let cardRadius: CGFloat = 12 static let rowHeight: CGFloat = 58 - static let bottomContentPadding: CGFloat = 12 } -struct SettingsBottomOverlayInsetReader: UIViewRepresentable { - @Binding var inset: CGFloat - - func makeCoordinator() -> Coordinator { - Coordinator(inset: self.$inset) - } - - func makeUIView(context: Context) -> SettingsBottomOverlayInsetProbeView { - let view = SettingsBottomOverlayInsetProbeView() - view.onInsetChange = { value in - context.coordinator.updateInset(value) - } - return view - } - - func updateUIView(_ uiView: SettingsBottomOverlayInsetProbeView, context: Context) { - context.coordinator.inset = self.$inset - uiView.onInsetChange = { value in - context.coordinator.updateInset(value) - } - uiView.updateInset() - } - - final class Coordinator { - var inset: Binding - - init(inset: Binding) { - self.inset = inset - } - - func updateInset(_ value: CGFloat) { - let rounded = max(0, ceil(value)) - guard abs(self.inset.wrappedValue - rounded) > 0.5 else { return } - self.inset.wrappedValue = rounded - } - } +struct SettingsApprovalItem: Identifiable { + let id: String + let icon: String + let title: String + let detail: String + let priority: String + let color: Color } -final class SettingsBottomOverlayInsetProbeView: UIView { - var onInsetChange: ((CGFloat) -> Void)? +struct SettingsApprovalRow: View { + let item: SettingsApprovalItem - override func didMoveToWindow() { - super.didMoveToWindow() - self.updateInset() - } - - override func layoutSubviews() { - super.layoutSubviews() - self.updateInset() - } - - func updateInset() { - let value = self.visibleTabBarHeight() - DispatchQueue.main.async { [weak self] in - self?.onInsetChange?(value) - } - } - - private func visibleTabBarHeight() -> CGFloat { - let tabBarController = self.nearestViewController()?.tabBarController - ?? self.findTabBarController(in: self.window?.rootViewController) - guard let tabBar = tabBarController?.tabBar, - !tabBar.isHidden, - tabBar.alpha > 0.01, - tabBar.window != nil, - self.window != nil - else { - return 0 - } - - let tabFrame = tabBar.convert(tabBar.bounds, to: nil) - guard tabFrame.height.isFinite else { return 0 } - return max(0, tabFrame.height) - } - - private func nearestViewController() -> UIViewController? { - var responder: UIResponder? = self - while let current = responder { - if let viewController = current as? UIViewController { - return viewController + var body: some View { + HStack(spacing: 10) { + Image(systemName: self.item.icon) + .font(.caption.weight(.bold)) + .foregroundStyle(.white) + .frame(width: 30, height: 30) + .background { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(self.item.color) + } + VStack(alignment: .leading, spacing: 2) { + Text(self.item.title) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + Text(self.item.detail) + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + .lineLimit(1) } - responder = current.next + Spacer(minLength: 8) + Text(self.item.priority) + .font(.caption.weight(.bold)) + .foregroundStyle(self.item.color) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background { + Capsule() + .fill(self.item.color.opacity(0.10)) + } } - return nil - } - - private func findTabBarController(in viewController: UIViewController?) -> UITabBarController? { - guard let viewController else { return nil } - if let tabBarController = viewController as? UITabBarController { - return tabBarController - } - if let tabBarController = self.findTabBarController(in: viewController.presentedViewController) { - return tabBarController - } - for child in viewController.children { - if let tabBarController = self.findTabBarController(in: child) { - return tabBarController - } - } - return nil + .padding(.vertical, 7) } } @@ -208,3 +152,176 @@ extension SettingsProTab { return a == 100 && b >= 64 && b <= 127 } } + +#if DEBUG +#Preview("Gateway settings states") { + SettingsGatewayStatesPreview() +} + +private struct SettingsGatewayStatesPreview: View { + var body: some View { + ZStack { + OpenClawProBackground() + ScrollView { + VStack(alignment: .leading, spacing: 16) { + self.stateSection("Connected") { + self.gatewayStatusCard( + title: "Gateway online", + detail: "Connected to openclaw-gateway.tailnet.ts.net.", + value: "online", + color: OpenClawBrand.ok) + self.gatewayFactsCard( + address: "100.88.41.20:18789", + server: "openclaw-gateway", + discovered: "3", + agent: "Aiden") + } + + self.stateSection("Loading") { + self.gatewayStatusCard( + title: "Checking gateway", + detail: "Refreshing connection, discovery, and device trust state.", + value: "loading", + color: OpenClawBrand.accent) + self.gatewayActionsCard(isBusy: true) + } + + self.stateSection("Empty") { + self.gatewayStatusCard( + title: "No gateway configured", + detail: "Scan a setup QR code, paste a setup code, or choose a discovered gateway.", + value: "setup", + color: .secondary) + self.setupActionsCard + } + + self.stateSection("Error") { + GatewayProblemBanner( + problem: Self.pairingProblem, + primaryActionTitle: "Retry", + onPrimaryAction: {}, + onShowDetails: {}) + self.gatewayStatusCard( + title: "Tailscale warning", + detail: "Tailscale is off on this device. Turn it on, then try again.", + value: "network", + color: OpenClawBrand.warn) + } + } + .padding(.horizontal, OpenClawProMetric.pagePadding) + .padding(.vertical, 18) + } + } + } + + private func stateSection( + _ title: String, + @ViewBuilder content: () -> some View) -> some View + { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + content() + } + } + + private func gatewayStatusCard( + title: String, + detail: String, + value: String, + color: Color) -> some View + { + ProCard(padding: 0, radius: SettingsLayout.cardRadius) { + ProStatusRow( + icon: value == "online" ? "antenna.radiowaves.left.and.right" : "wifi.slash", + title: title, + detail: detail, + value: value, + color: color, + actionTitle: value == "setup" ? "Scan QR" : nil, + action: value == "setup" ? {} : nil) + } + } + + private func gatewayFactsCard( + address: String, + server: String, + discovered: String, + agent: String) -> some View + { + ProCard(radius: SettingsLayout.cardRadius) { + VStack(spacing: 0) { + self.factRow("Address", value: address) + Divider() + self.factRow("Server", value: server) + Divider() + self.factRow("Discovered", value: discovered) + Divider() + self.factRow("Default Agent", value: agent) + } + } + } + + private func factRow(_ label: String, value: String) -> some View { + HStack { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + Spacer(minLength: 8) + Text(value) + .font(.caption.weight(.medium)) + .lineLimit(1) + .truncationMode(.middle) + } + .frame(height: SettingsLayout.rowHeight) + } + + private func gatewayActionsCard(isBusy: Bool) -> some View { + ProCard(radius: SettingsLayout.cardRadius) { + HStack(spacing: 10) { + self.previewButton("Reconnect", systemImage: "arrow.triangle.2.circlepath", isBusy: isBusy) + self.previewButton("Diagnose", systemImage: "cross.case", isBusy: isBusy) + } + } + } + + private var setupActionsCard: some View { + ProCard(radius: SettingsLayout.cardRadius) { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + self.previewButton("Scan QR", systemImage: "qrcode.viewfinder", isBusy: false) + self.previewButton("Connect", systemImage: "link", isBusy: false) + } + Text("Discovered gateways and manual setup live here when the gateway has not connected yet.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + private func previewButton( + _ title: String, + systemImage: String, + isBusy: Bool) -> some View + { + Button {} label: { + Label(title, systemImage: systemImage) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(isBusy) + } + + private static let pairingProblem = GatewayConnectionProblem( + kind: .pairingRequired, + owner: .gateway, + title: "Pairing required", + message: "Run /pair approve in your OpenClaw chat before this iPad can connect.", + actionCommand: "/pair approve req-ipad-preview", + requestId: "req-ipad-preview", + retryable: false, + pauseReconnect: true) +} +#endif diff --git a/apps/ios/Sources/Design/TalkProTab.swift b/apps/ios/Sources/Design/TalkProTab.swift index 2e1614155e15..0ed1c3fb00c6 100644 --- a/apps/ios/Sources/Design/TalkProTab.swift +++ b/apps/ios/Sources/Design/TalkProTab.swift @@ -8,11 +8,22 @@ struct TalkProTab: View { TalkDefaults.speakerphoneEnabledByDefault @AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false @State private var showPermissionPrompt = false + @State private var showTalkIssueDetails = false + let headerLeadingAction: OpenClawSidebarHeaderAction? var openSettings: () -> Void + init( + headerLeadingAction: OpenClawSidebarHeaderAction? = nil, + openSettings: @escaping () -> Void) + { + self.headerLeadingAction = headerLeadingAction + self.openSettings = openSettings + } + private var state: TalkProState { TalkProState( gatewayConnected: self.gatewayConnected, + isDemoMode: self.appModel.isAppleReviewDemoModeEnabled, isEnabled: self.appModel.talkMode.isEnabled || self.talkEnabled, statusText: self.appModel.talkMode.statusText, isConfigLoaded: self.appModel.talkMode.gatewayTalkConfigLoaded, @@ -29,6 +40,15 @@ struct TalkProTab: View { ScrollView { VStack(alignment: .leading, spacing: 10) { self.header + if let fallbackIssue = self.fallbackIssue { + TalkRuntimeIssueBanner( + issue: fallbackIssue, + onOpenSettings: self.openSettings, + onShowDetails: { + self.showTalkIssueDetails = true + }) + .padding(.horizontal, OpenClawProMetric.pagePadding) + } self.voiceHeroCard self.conversationCard self.voiceModeCard @@ -37,7 +57,6 @@ struct TalkProTab: View { .padding(.top, 16) .padding(.bottom, 18) } - .safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset) } .navigationBarHidden(true) } @@ -62,11 +81,22 @@ struct TalkProTab: View { .presentationDetents([.medium, .large]) .openClawSheetChrome() } + .sheet(isPresented: self.$showTalkIssueDetails) { + if let fallbackIssue = self.fallbackIssue { + TalkRuntimeIssueDetailsSheet( + issue: fallbackIssue, + onOpenSettings: self.openSettings) + .openClawSheetChrome() + } + } .onAppear { self.alignPersistedTalkState() } } private var header: some View { HStack(alignment: .center, spacing: 11) { + if let headerLeadingAction { + OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction) + } OpenClawProMark(size: 31, shadowRadius: 9) VStack(alignment: .leading, spacing: 2) { Text("Talk") @@ -149,7 +179,7 @@ struct TalkProTab: View { .padding(.horizontal, 12) .padding(.top, 11) .padding(.bottom, 3) - self.infoRow(icon: "person.crop.circle.fill", title: "Agent", value: self.appModel.activeAgentName) + self.infoRow(icon: "person.crop.circle.fill", title: "Agent", value: self.appModel.chatAgentName) Divider().padding(.leading, 54) self.infoRow( icon: "bubble.left.and.text.bubble.right.fill", @@ -173,9 +203,21 @@ struct TalkProTab: View { .padding(.horizontal, 12) .padding(.top, 11) .padding(.bottom, 3) - self.infoRow(icon: "waveform", title: "Mode", value: self.appModel.talkMode.gatewayTalkVoiceModeTitle) + self.infoRow( + icon: "waveform", + title: "Configured", + value: self.appModel.talkMode.gatewayTalkVoiceModeTitle) + Divider().padding(.leading, 54) + self.infoRow( + icon: "waveform", + title: "Active now", + value: self.activeModeText) Divider().padding(.leading, 54) self.infoRow(icon: "antenna.radiowaves.left.and.right", title: "Transport", value: self.transportText) + if let issueText = self.talkIssueText { + Divider().padding(.leading, 54) + self.infoRow(icon: "exclamationmark.triangle.fill", title: "Last issue", value: issueText) + } Divider().padding(.leading, 54) self.infoRow(icon: "key.fill", title: "Permission", value: self.permissionText) Divider().padding(.leading, 54) @@ -192,13 +234,9 @@ struct TalkProTab: View { .padding(.horizontal, 12) .padding(.top, 11) .padding(.bottom, 3) - Toggle("Speakerphone", isOn: self.$talkSpeakerphoneEnabled) - .padding(.horizontal, 14) - .padding(.vertical, 10) + self.controlToggleRow("Speakerphone", isOn: self.talkSpeakerphoneBinding) Divider().padding(.leading, 14) - Toggle("Background listening", isOn: self.$talkBackgroundEnabled) - .padding(.horizontal, 14) - .padding(.vertical, 10) + self.controlToggleRow("Background listening", isOn: self.$talkBackgroundEnabled) Divider().padding(.leading, 14) Button(action: self.openSettings) { HStack { @@ -218,6 +256,25 @@ struct TalkProTab: View { .padding(.horizontal, OpenClawProMetric.pagePadding) } + private func controlToggleRow(_ title: String, isOn: Binding) -> some View { + Toggle(title, isOn: isOn) + .contentShape(Rectangle()) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .overlay { + // Keep Toggle semantics for accessibility while making the full visual row tappable. + Button { + isOn.wrappedValue.toggle() + } label: { + Rectangle() + .fill(.clear) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityHidden(true) + } + } + private func cardHeader( title: String, value: String?, @@ -268,12 +325,18 @@ struct TalkProTab: View { } private var gatewayConnected: Bool { - GatewayStatusBuilder.build(appModel: self.appModel) == .connected + !self.appModel.isAppleReviewDemoModeEnabled && + GatewayStatusBuilder.build(appModel: self.appModel) == .connected + } + + private var fallbackIssue: TalkRuntimeIssue? { + guard self.gatewayConnected else { return nil } + return self.appModel.talkMode.gatewayTalkCurrentFallbackIssue } private var headerSubtitle: String { let mode = self.appModel.talkMode.gatewayTalkVoiceModeTitle.trimmingCharacters(in: .whitespacesAndNewlines) - let agent = self.appModel.activeAgentName.trimmingCharacters(in: .whitespacesAndNewlines) + let agent = self.appModel.chatAgentName.trimmingCharacters(in: .whitespacesAndNewlines) if mode.isEmpty || mode == "Not loaded" { return agent.isEmpty ? "Realtime voice" : agent } if agent.isEmpty { return mode } return "\(agent) • \(mode)" @@ -282,6 +345,7 @@ struct TalkProTab: View { private var heroSubtitle: String { if self.state .prefersPermissionCopy { return "Gateway approval is required before this phone can capture voice." } + if self.appModel.isAppleReviewDemoModeEnabled { return "Voice is disabled in Apple Review demo mode." } if !self.gatewayConnected { return "Connect to your gateway to start a voice conversation." } if !self.appModel.talkMode.gatewayTalkConfigLoaded { return "Open Voice settings after the gateway loads Talk configuration." @@ -289,7 +353,7 @@ struct TalkProTab: View { let subtitle = (self.appModel.talkMode.gatewayTalkVoiceModeSubtitle ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) if !subtitle.isEmpty { return subtitle } - return "Routes voice to \(self.appModel.activeAgentName)." + return "Routes voice to \(self.appModel.chatAgentName)." } private var transportText: String { @@ -300,6 +364,21 @@ struct TalkProTab: View { return "\(provider) • \(transport)" } + private var activeModeText: String { + let title = self.appModel.talkMode.gatewayTalkActiveModeTitle.trimmingCharacters(in: .whitespacesAndNewlines) + let subtitle = (self.appModel.talkMode.gatewayTalkActiveModeSubtitle ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + if title.isEmpty { return "Not active" } + if subtitle.isEmpty { return title } + return "\(title) • \(subtitle)" + } + + private var talkIssueText: String? { + let text = (self.appModel.talkMode.gatewayTalkLastIssueText ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? nil : text + } + private var permissionText: String { if let failure = self.appModel.talkMode.gatewayTalkPermissionState.failureMessage { return failure @@ -313,15 +392,28 @@ struct TalkProTab: View { } private func alignPersistedTalkState() { - if self.appModel.talkMode.gatewayTalkPermissionState.requiresTalkPermissionAction, + if self.appModel.isAppleReviewDemoModeEnabled, self.talkEnabled || self.appModel.talkMode.isEnabled { self.stopTalk() + } else if self.appModel.talkMode.gatewayTalkPermissionState.requiresTalkPermissionAction, + self.talkEnabled || self.appModel.talkMode.isEnabled + { + self.stopTalk() } else if self.talkEnabled != self.appModel.talkMode.isEnabled { self.appModel.setTalkEnabled(self.talkEnabled) } } + private var talkSpeakerphoneBinding: Binding { + Binding( + get: { self.talkSpeakerphoneEnabled }, + set: { enabled in + self.talkSpeakerphoneEnabled = enabled + self.appModel.setTalkSpeakerphoneEnabled(enabled) + }) + } + private func handlePrimaryAction() { switch self.state.primaryAction { case .start: @@ -339,7 +431,9 @@ struct TalkProTab: View { } private func startTalk() { + guard !self.appModel.isAppleReviewDemoModeEnabled else { return } self.talkEnabled = true + self.appModel.talkMode.updateMainSessionKey(self.appModel.chatSessionKey) self.appModel.setTalkEnabled(true) } @@ -367,6 +461,7 @@ enum TalkProWaveformMode: Equatable { struct TalkProState: Equatable { let gatewayConnected: Bool + let isDemoMode: Bool let isEnabled: Bool let statusText: String let isConfigLoaded: Bool @@ -380,6 +475,7 @@ struct TalkProState: Equatable { } var title: String { + if self.isDemoMode { return "Demo mode only" } if !self.gatewayConnected { return "Gateway offline" } switch self.permissionState { case .missingScope, .requestFailed: @@ -405,6 +501,7 @@ struct TalkProState: Equatable { } var chipText: String { + if self.isDemoMode { return "Demo" } if !self.gatewayConnected { return "Offline" } switch self.permissionState { case .missingScope, .requestFailed: @@ -426,6 +523,7 @@ struct TalkProState: Equatable { } var icon: String { + if self.isDemoMode { return "waveform.slash" } if !self.gatewayConnected { return "wifi.slash" } switch self.permissionState { case .missingScope, .requestFailed: @@ -448,6 +546,7 @@ struct TalkProState: Equatable { } var color: Color { + if self.isDemoMode { return .secondary } if !self.gatewayConnected { return .secondary } switch self.permissionState { case .requestFailed, .loadFailed: @@ -461,6 +560,7 @@ struct TalkProState: Equatable { } var primaryAction: TalkProPrimaryAction { + if self.isDemoMode { return .waiting } if !self.gatewayConnected { return .openSettings } switch self.permissionState { case .missingScope, .requestFailed: @@ -480,7 +580,7 @@ struct TalkProState: Equatable { case .stop: "Stop Talk" case .enablePermission: "Enable Talk" case .openSettings: self.gatewayConnected ? "Open Voice Settings" : "Open Gateway Settings" - case .waiting: "Waiting for Approval" + case .waiting: self.isDemoMode ? "Demo Mode Only" : "Waiting for Approval" } } @@ -490,7 +590,7 @@ struct TalkProState: Equatable { case .stop: "stop.fill" case .enablePermission: "key.fill" case .openSettings: "gearshape.fill" - case .waiting: "hourglass" + case .waiting: self.isDemoMode ? "lock.fill" : "hourglass" } } @@ -518,6 +618,7 @@ struct TalkProState: Equatable { } func waveformMode(micLevel: Double) -> TalkProWaveformMode { + if self.isDemoMode { return .still } if !self.gatewayConnected { return .still } switch self.permissionState { case .requestingUpgrade, .upgradeRequested: diff --git a/apps/ios/Sources/Design/TalkRuntimeIssueBanner.swift b/apps/ios/Sources/Design/TalkRuntimeIssueBanner.swift new file mode 100644 index 000000000000..1f425abcf6bc --- /dev/null +++ b/apps/ios/Sources/Design/TalkRuntimeIssueBanner.swift @@ -0,0 +1,142 @@ +import SwiftUI +import UIKit + +struct TalkRuntimeIssueBanner: View { + @Environment(\.colorScheme) private var colorScheme + + let issue: TalkRuntimeIssue + var onOpenSettings: (() -> Void)? + var onShowDetails: (() -> Void)? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 10) { + Image(systemName: self.iconName) + .font(.headline.weight(.semibold)) + .foregroundStyle(self.tint) + .frame(width: 20) + .padding(.top, 2) + + VStack(alignment: .leading, spacing: 5) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(self.issue.fallbackBannerTitle) + .font(.subheadline.weight(.semibold)) + .multilineTextAlignment(.leading) + Spacer(minLength: 0) + Text(self.issue.fallbackBannerOwnerLabel) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + + Text(self.issue.fallbackBannerMessage) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Text(self.issue.displayMessage) + .font(.caption.weight(.medium)) + .foregroundStyle(self.tint) + .fixedSize(horizontal: false, vertical: true) + } + } + + HStack(spacing: 10) { + if let onOpenSettings { + Button("Open Settings", action: onOpenSettings) + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + if let onShowDetails { + Button("Details", action: onShowDetails) + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(13) + .background { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.ultraThickMaterial) + .overlay { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(Color.primary.opacity(self.colorScheme == .dark ? 0.12 : 0.07), lineWidth: 1) + } + .shadow(color: .black.opacity(self.colorScheme == .dark ? 0.16 : 0.07), radius: 16, y: 7) + } + } + + private var iconName: String { + "exclamationmark.triangle.fill" + } + + private var tint: Color { + .orange + } +} + +struct TalkRuntimeIssueDetailsSheet: View { + @Environment(\.dismiss) private var dismiss + + let issue: TalkRuntimeIssue + var onOpenSettings: (() -> Void)? + + @State private var copyFeedback: String? + + var body: some View { + NavigationStack { + List { + Section { + VStack(alignment: .leading, spacing: 10) { + Text(self.issue.fallbackBannerTitle) + .font(.title3.weight(.semibold)) + Text(self.issue.fallbackBannerMessage) + .font(.body) + .foregroundStyle(.secondary) + Text(self.issue.displayMessage) + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + } + + Section("Technical details") { + Text(verbatim: self.issue.technicalDetails) + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + Button("Copy diagnostics") { + UIPasteboard.general.string = self.issue.technicalDetails + self.copyFeedback = "Copied diagnostics" + } + } + + if let copyFeedback { + Section { + Text(copyFeedback) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + .navigationTitle("Talk fallback") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + if let onOpenSettings { + Button("Open Settings") { + self.dismiss() + onOpenSettings() + } + } + } + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + self.dismiss() + } + } + } + } + } +} diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index a415b522ebfb..a723af26b3c2 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -3,7 +3,6 @@ import Contacts import CoreLocation import CoreMotion import CryptoKit -import Darwin import EventKit import Foundation import Network @@ -169,11 +168,6 @@ final class GatewayConnectionController { } } - func allowAutoConnectAgain() { - self.didAutoConnect = false - self.maybeAutoConnect() - } - func restartDiscovery() { self.discovery.stop() self.didAutoConnect = false @@ -522,8 +516,7 @@ final class GatewayConnectionController { let stableID = self.manualStableID(host: manualHost, port: resolvedPort) let tlsParams = self.resolveManualTLSParams( stableID: stableID, - tlsEnabled: resolvedUseTLS, - allowTOFUReset: self.shouldRequireTLS(host: manualHost)) + tlsEnabled: resolvedUseTLS) guard let url = self.buildGatewayURL( host: manualHost, @@ -719,8 +712,7 @@ final class GatewayConnectionController { } private func resolveDiscoveredTLSParams( - gateway: GatewayDiscoveryModel.DiscoveredGateway, - allowTOFU: Bool) -> GatewayTLSParams? + gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams? { let stableID = gateway.stableID let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) @@ -747,8 +739,7 @@ final class GatewayConnectionController { private func resolveManualTLSParams( stableID: String, - tlsEnabled: Bool, - allowTOFUReset: Bool = false) -> GatewayTLSParams? + tlsEnabled: Bool) -> GatewayTLSParams? { let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) if tlsEnabled || stored != nil { @@ -785,126 +776,6 @@ final class GatewayConnectionController { resolver.start() } } - - private func resolveHostPortFromBonjourEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? { - switch endpoint { - case let .hostPort(host, port): - (host: host.debugDescription, port: Int(port.rawValue)) - case let .service(name, type, domain, _): - await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain) - default: - nil - } - } - - private static func resolveBonjourServiceToHostPort( - name: String, - type: String, - domain: String, - timeoutSeconds: TimeInterval = 3.0) async -> (host: String, port: Int)? - { - // NetService callbacks are delivered via a run loop. If we resolve from a thread without one, - // we can end up never receiving callbacks, which in turn leaks the continuation and leaves - // the UI stuck "connecting". Keep the whole lifecycle on the main run loop and always - // resume the continuation exactly once (timeout/cancel safe). - @MainActor - final class Resolver: NSObject, @preconcurrency NetServiceDelegate { - private var cont: CheckedContinuation<(host: String, port: Int)?, Never>? - private let service: NetService - private var timeoutTask: Task? - private var finished = false - - init(cont: CheckedContinuation<(host: String, port: Int)?, Never>, service: NetService) { - self.cont = cont - self.service = service - super.init() - } - - func start(timeoutSeconds: TimeInterval) { - self.service.delegate = self - self.service.schedule(in: .main, forMode: .default) - - // NetService has its own timeout, but we keep a manual one as a backstop in case - // callbacks never arrive (e.g. local network permission issues). - self.timeoutTask = Task { @MainActor [weak self] in - guard let self else { return } - let ns = UInt64(max(0.1, timeoutSeconds) * 1_000_000_000) - try? await Task.sleep(nanoseconds: ns) - self.finish(nil) - } - - self.service.resolve(withTimeout: timeoutSeconds) - } - - func netServiceDidResolveAddress(_ sender: NetService) { - self.finish(Self.extractHostPort(sender)) - } - - func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) { - _ = errorDict // currently best-effort; callers surface a generic failure - self.finish(nil) - } - - private func finish(_ result: (host: String, port: Int)?) { - guard !self.finished else { return } - self.finished = true - - self.timeoutTask?.cancel() - self.timeoutTask = nil - - self.service.stop() - self.service.remove(from: .main, forMode: .default) - - let c = self.cont - self.cont = nil - c?.resume(returning: result) - } - - private static func extractHostPort(_ svc: NetService) -> (host: String, port: Int)? { - let port = svc.port - - if let host = svc.hostName?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty { - return (host: host, port: port) - } - - guard let addrs = svc.addresses else { return nil } - for addrData in addrs { - let host = addrData.withUnsafeBytes { ptr -> String? in - guard let base = ptr.baseAddress, !ptr.isEmpty else { return nil } - var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) - - let rc = getnameinfo( - base.assumingMemoryBound(to: sockaddr.self), - socklen_t(ptr.count), - &buffer, - socklen_t(buffer.count), - nil, - 0, - NI_NUMERICHOST) - guard rc == 0 else { return nil } - let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } - return String(bytes: bytes, encoding: .utf8) - } - - if let host, !host.isEmpty { - return (host: host, port: port) - } - } - - return nil - } - } - - return await withCheckedContinuation { cont in - Task { @MainActor in - let service = NetService(domain: domain, type: type, name: name) - let resolver = Resolver(cont: cont, service: service) - // Keep the resolver alive for the lifetime of the NetService resolve. - objc_setAssociatedObject(service, "resolver", resolver, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - resolver.start(timeoutSeconds: timeoutSeconds) - } - } - } } extension GatewayConnectionController { @@ -1162,30 +1033,10 @@ extension GatewayConnectionController { self.currentCommands() } - func _test_currentPermissions() async -> [String: Bool] { - await self.currentPermissions() - } - static func _test_isLocationAvailable(servicesEnabled: Bool, status: CLAuthorizationStatus) -> Bool { self.isLocationAvailable(servicesEnabled: servicesEnabled, status: status) } - func _test_platformString() -> String { - DeviceInfoHelper.platformString() - } - - func _test_deviceFamily() -> String { - DeviceInfoHelper.deviceFamily() - } - - func _test_modelIdentifier() -> String { - DeviceInfoHelper.modelIdentifier() - } - - func _test_appVersion() -> String { - DeviceInfoHelper.appVersion() - } - func _test_setGateways(_ gateways: [GatewayDiscoveryModel.DiscoveredGateway]) { self.gateways = gateways } @@ -1199,10 +1050,9 @@ extension GatewayConnectionController { } func _test_resolveDiscoveredTLSParams( - gateway: GatewayDiscoveryModel.DiscoveredGateway, - allowTOFU: Bool) -> GatewayTLSParams? + gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams? { - self.resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: allowTOFU) + self.resolveDiscoveredTLSParams(gateway: gateway) } func _test_resolveManualUseTLS(host: String, useTLS: Bool) -> Bool { diff --git a/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift b/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift index 66a10d51e1c4..b974029304c0 100644 --- a/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift +++ b/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift @@ -95,7 +95,7 @@ struct GatewayQuickSetupSheet: View { .buttonStyle(.bordered) .disabled(self.connecting) - Toggle("Don’t show this again", isOn: self.$quickSetupDismissed) + self.fullRowToggle("Don’t show this again", isOn: self.$quickSetupDismissed) .padding(.top, 4) } else { Text("No gateways found yet. Make sure your gateway is running and Bonjour discovery is enabled.") @@ -135,6 +135,23 @@ struct GatewayQuickSetupSheet: View { self.gatewayController.gateways.first } + private func fullRowToggle(_ title: String, isOn: Binding) -> some View { + Toggle(title, isOn: isOn) + .contentShape(Rectangle()) + .overlay { + // Keep Toggle semantics for accessibility while making the full visual row tappable. + Button { + isOn.wrappedValue.toggle() + } label: { + Rectangle() + .fill(.clear) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityHidden(true) + } + } + private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String { problem.canTrustRotatedCertificate ? "Trust certificate" : "Connect" } diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift index e4376557ff66..fa6c3a662719 100644 --- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift +++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift @@ -9,11 +9,6 @@ enum GatewaySettingsStore { private static let instanceIdDefaultsKey = "node.instanceId" private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID" private static let lastDiscoveredGatewayStableIDDefaultsKey = "gateway.lastDiscoveredStableID" - private static let manualEnabledDefaultsKey = "gateway.manual.enabled" - private static let manualHostDefaultsKey = "gateway.manual.host" - private static let manualPortDefaultsKey = "gateway.manual.port" - private static let manualTlsDefaultsKey = "gateway.manual.tls" - private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs" private static let lastGatewayKindDefaultsKey = "gateway.last.kind" private static let lastGatewayHostDefaultsKey = "gateway.last.host" private static let lastGatewayPortDefaultsKey = "gateway.last.port" @@ -184,24 +179,6 @@ enum GatewaySettingsStore { enum LastGatewayConnection: Equatable { case manual(host: String, port: Int, useTLS: Bool, stableID: String) case discovered(stableID: String, useTLS: Bool) - - var stableID: String { - switch self { - case let .manual(_, _, _, stableID): - stableID - case let .discovered(stableID, _): - stableID - } - } - - var useTLS: Bool { - switch self { - case let .manual(_, _, useTLS, _): - useTLS - case let .discovered(_, useTLS): - useTLS - } - } } private enum LastGatewayKind: String, Codable { @@ -229,17 +206,6 @@ enum GatewaySettingsStore { return nil } - static func saveTalkProviderApiKey(_ apiKey: String?, provider: String) { - guard let providerId = self.normalizedTalkProviderID(provider) else { return } - let account = self.talkProviderApiKeyAccount(providerId: providerId) - let trimmed = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if trimmed.isEmpty { - _ = KeychainStore.delete(service: self.talkService, account: account) - return - } - _ = KeychainStore.saveString(trimmed, service: self.talkService, account: account) - } - static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) { let payload = LastGatewayConnectionData( kind: .manual, stableID: stableID, useTLS: useTLS, host: host, port: port) @@ -580,11 +546,4 @@ enum GatewayDiagnostics { } } } - - static func reset() { - guard let url = fileURL else { return } - self.queue.async { - try? FileManager.default.removeItem(at: url) - } - } } diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index 97a954d7e7d7..5e45dcad40f3 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -107,12 +107,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - diff --git a/apps/ios/Sources/LiveActivity/LiveActivityManager.swift b/apps/ios/Sources/LiveActivity/LiveActivityManager.swift index d459c6a76c49..2a6cbe792093 100644 --- a/apps/ios/Sources/LiveActivity/LiveActivityManager.swift +++ b/apps/ios/Sources/LiveActivity/LiveActivityManager.swift @@ -17,15 +17,6 @@ final class LiveActivityManager { self.hydrateCurrentAndPruneDuplicates() } - var isActive: Bool { - guard let activity = self.currentActivity else { return false } - guard activity.activityState == .active else { - self.currentActivity = nil - return false - } - return true - } - func showConnecting(statusText: String = "Connecting...", agentName: String, sessionKey: String) { self.hydrateCurrentAndPruneDuplicates() @@ -96,10 +87,6 @@ final class LiveActivityManager { self.endActivity(reason: "connected") } - func handleDisconnect() { - self.endActivity(reason: "disconnected") - } - func endActivity(reason: String) { guard let activity = self.currentActivity else { return } self.currentActivity = nil @@ -183,15 +170,6 @@ final class LiveActivityManager { startedAt: self.activityStartDate) } - private func idleState() -> OpenClawActivityAttributes.ContentState { - OpenClawActivityAttributes.ContentState( - statusText: "Idle", - isIdle: true, - isDisconnected: false, - isConnecting: false, - startedAt: self.activityStartDate) - } - private func disconnectedState() -> OpenClawActivityAttributes.ContentState { OpenClawActivityAttributes.ContentState( statusText: "Disconnected", diff --git a/apps/ios/Sources/LiveActivity/OpenClawActivityAttributes.swift b/apps/ios/Sources/LiveActivity/OpenClawActivityAttributes.swift index 81d03f7b377a..c7c0143adcce 100644 --- a/apps/ios/Sources/LiveActivity/OpenClawActivityAttributes.swift +++ b/apps/ios/Sources/LiveActivity/OpenClawActivityAttributes.swift @@ -14,39 +14,3 @@ struct OpenClawActivityAttributes: ActivityAttributes { var startedAt: Date } } - -#if DEBUG -extension OpenClawActivityAttributes { - static let preview = OpenClawActivityAttributes(agentName: "main", sessionKey: "main") -} - -extension OpenClawActivityAttributes.ContentState { - static let connecting = OpenClawActivityAttributes.ContentState( - statusText: "Connecting...", - isIdle: false, - isDisconnected: false, - isConnecting: true, - startedAt: .now) - - static let idle = OpenClawActivityAttributes.ContentState( - statusText: "Idle", - isIdle: true, - isDisconnected: false, - isConnecting: false, - startedAt: .now) - - static let disconnected = OpenClawActivityAttributes.ContentState( - statusText: "Disconnected", - isIdle: false, - isDisconnected: true, - isConnecting: false, - startedAt: .now) - - static let attention = OpenClawActivityAttributes.ContentState( - statusText: "Approval needed", - isIdle: false, - isDisconnected: false, - isConnecting: false, - startedAt: .now) -} -#endif diff --git a/apps/ios/Sources/Location/LocationService.swift b/apps/ios/Sources/Location/LocationService.swift index b04fe037d5a8..fbb87d3de9c5 100644 --- a/apps/ios/Sources/Location/LocationService.swift +++ b/apps/ios/Sources/Location/LocationService.swift @@ -12,8 +12,6 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic private let manager = CLLocationManager() private var authContinuation: CheckedContinuation? private var locationContinuation: CheckedContinuation? - private var updatesContinuation: AsyncStream.Continuation? - private var isStreaming = false private var significantLocationCallback: (@Sendable (CLLocation) -> Void)? private var isMonitoringSignificantChanges = false @@ -84,42 +82,6 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: { Error.timeout }, operation: operation) } - func startLocationUpdates( - desiredAccuracy: OpenClawLocationAccuracy, - significantChangesOnly: Bool) -> AsyncStream - { - self.stopLocationUpdates() - - self.manager.desiredAccuracy = LocationCurrentRequest.accuracyValue(desiredAccuracy) - self.manager.pausesLocationUpdatesAutomatically = true - self.manager.allowsBackgroundLocationUpdates = true - - self.isStreaming = true - if significantChangesOnly { - self.manager.startMonitoringSignificantLocationChanges() - } else { - self.manager.startUpdatingLocation() - } - - return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in - self.updatesContinuation = continuation - continuation.onTermination = { @Sendable _ in - Task { @MainActor in - self.stopLocationUpdates() - } - } - } - } - - func stopLocationUpdates() { - guard self.isStreaming else { return } - self.isStreaming = false - self.manager.stopUpdatingLocation() - self.manager.stopMonitoringSignificantLocationChanges() - self.updatesContinuation?.finish() - self.updatesContinuation = nil - } - func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void) { self.significantLocationCallback = onUpdate guard !self.isMonitoringSignificantChanges else { return } @@ -127,13 +89,6 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic self.manager.startMonitoringSignificantLocationChanges() } - func stopMonitoringSignificantLocationChanges() { - guard self.isMonitoringSignificantChanges else { return } - self.isMonitoringSignificantChanges = false - self.significantLocationCallback = nil - self.manager.stopMonitoringSignificantLocationChanges() - } - nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { let status = manager.authorizationStatus Task { @MainActor in @@ -161,9 +116,6 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic if let callback = self.significantLocationCallback, let latest = locs.last { callback(latest) } - if let latest = locs.last, let updates = self.updatesContinuation { - updates.yield(latest) - } } } diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 4cb8f0624728..d6ab4ee1f441 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -111,10 +111,13 @@ final class NodeAppModel { var gatewayStatusText: String = "Offline" var nodeStatusText: String = "Offline" var operatorStatusText: String = "Offline" + private(set) var isAppleReviewDemoModeEnabled: Bool = false var isOperatorGatewayConnected: Bool { self.operatorConnected } + private(set) var hasOperatorAdminScope: Bool = false + var gatewayServerName: String? var gatewayRemoteAddress: String? var connectedGatewayID: String? @@ -125,11 +128,11 @@ final class NodeAppModel { var gatewayPairingPaused: Bool = false var gatewayPairingRequestId: String? private(set) var lastGatewayProblem: GatewayConnectionProblem? + private var operatorGatewayProblem: GatewayConnectionProblem? var gatewayDisplayStatusText: String { self.lastGatewayProblem?.statusText ?? self.gatewayStatusText } - var seamColorHex: String? private var mainSessionBaseKey: String = "main" private var focusedChatSessionKey: String? var selectedAgentId: String? @@ -184,6 +187,7 @@ final class NodeAppModel { @ObservationIgnored private var backgroundGraceTaskTimer: Task? private var backgroundReconnectSuppressed = false private var backgroundReconnectLeaseUntil: Date? + @ObservationIgnored private var foregroundGatewayResumeCheckInFlight = false private var lastSignificantLocationWakeAt: Date? @ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator() private var watchExecApprovalPromptsByID: [String: ExecApprovalPrompt] = [:] @@ -197,9 +201,6 @@ final class NodeAppModel { private var apnsDeviceTokenHex: String? private var apnsLastRegisteredTokenHex: String? @ObservationIgnored private let pushRegistrationManager = PushRegistrationManager() - var gatewaySession: GatewayNodeSession { - self.nodeGateway - } var operatorSession: GatewayNodeSession { self.operatorGateway @@ -210,6 +211,7 @@ final class NodeAppModel { private static let watchExecApprovalBridgeStateKey = "watch.execApproval.bridge.state.v1" private static let backgroundAliveLastSuccessAtMsKey = "gateway.backgroundAlive.lastSuccessAtMs" private static let backgroundAliveLastTriggerKey = "gateway.backgroundAlive.lastTrigger" + private static let foregroundResumeHealthTimeoutSeconds = 1 var cameraHUDText: String? var cameraHUDKind: CameraHUDKind? @@ -295,6 +297,7 @@ final class NodeAppModel { let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled") self.voiceWake.setEnabled(enabled) self.talkMode.attachGateway(self.operatorGateway) + self.refreshOperatorAdminScopeFromStore() self.refreshLastShareEventFromRelay() let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled") self.setTalkEnabled(talkEnabled) @@ -412,9 +415,7 @@ final class NodeAppModel { self.isBackgrounded = false self.endBackgroundConnectionGracePeriod(reason: "scene_foreground") self.clearBackgroundReconnectSuppression(reason: "scene_foreground") - if self.operatorConnected { - self.startGatewayHealthMonitor() - } + var shouldStartGatewayHealthMonitor = self.operatorConnected if phase == .active { self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.backgroundVoiceWakeSuspended) self.backgroundVoiceWakeSuspended = false @@ -439,6 +440,8 @@ final class NodeAppModel { // iOS may suspend network sockets in background without a clean close. // On foreground, force a fresh handshake to avoid "connected but dead" states. if backgroundedFor >= 3.0 { + shouldStartGatewayHealthMonitor = false + self.foregroundGatewayResumeCheckInFlight = true Task { [weak self] in guard let self else { return } let operatorWasConnected = await MainActor.run { self.operatorConnected } @@ -447,30 +450,26 @@ final class NodeAppModel { let healthy = await (try? self.operatorGateway.request( method: "health", paramsJSON: nil, - timeoutSeconds: 2)) != nil + timeoutSeconds: Self.foregroundResumeHealthTimeoutSeconds)) != nil if healthy { - await MainActor.run { self.startGatewayHealthMonitor() } + await MainActor.run { + self.foregroundGatewayResumeCheckInFlight = false + self.startGatewayHealthMonitor() + } return } } - await self.operatorGateway.disconnect() - await self.nodeGateway.disconnect() await MainActor.run { - self.setOperatorConnected(false) - self.gatewayConnected = false - // Foreground recovery must actively restart the saved gateway config. - // Disconnecting stale sockets alone can leave us idle if the old - // reconnect tasks were suppressed or otherwise got stuck in background. - self.gatewayStatusText = "Reconnecting…" - self.talkMode.updateGatewayConnected(false) - if let cfg = self.activeGatewayConnectConfig { - self.applyGatewayConnectConfig(cfg) - } + self.foregroundGatewayResumeCheckInFlight = false } + await self.restartGatewaySessionsAfterForegroundStaleConnection() } } } + if shouldStartGatewayHealthMonitor { + self.startGatewayHealthMonitor() + } @unknown default: self.isBackgrounded = false self.endBackgroundConnectionGracePeriod(reason: "scene_unknown") @@ -548,6 +547,7 @@ final class NodeAppModel { await self.operatorGateway.disconnect() await self.nodeGateway.disconnect() await MainActor.run { + guard !self.isAppleReviewDemoModeEnabled else { return } self.setOperatorConnected(false) self.gatewayConnected = false self.talkMode.updateGatewayConnected(false) @@ -585,6 +585,12 @@ final class NodeAppModel { } func setTalkEnabled(_ enabled: Bool) { + if self.isAppleReviewDemoModeEnabled { + UserDefaults.standard.set(false, forKey: "talk.enabled") + self.talkMode.setEnabled(false) + self.talkMode.statusText = "Demo mode only" + return + } UserDefaults.standard.set(enabled, forKey: "talk.enabled") if enabled { // Voice wake holds the microphone continuously; talk mode needs exclusive access for STT. @@ -630,6 +636,7 @@ final class NodeAppModel { self.gatewayPairingPaused = false self.gatewayPairingRequestId = nil self.lastGatewayProblem = nil + self.operatorGatewayProblem = nil self.operatorGatewayTask?.cancel() self.operatorGatewayTask = nil let sessionBox = config.tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) } @@ -713,11 +720,6 @@ final class NodeAppModel { } } - var seamColor: Color { - Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor - } - - private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0) private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex" private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key" private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey() @@ -727,12 +729,9 @@ final class NodeAppModel { let res = try await self.operatorGateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8) guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return } guard let config = json["config"] as? [String: Any] else { return } - let ui = config["ui"] as? [String: Any] - let raw = (ui?["seamColor"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let session = config["session"] as? [String: Any] let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String) await MainActor.run { - self.seamColorHex = raw.isEmpty ? nil : raw self.mainSessionBaseKey = mainKey self.talkMode.updateMainSessionKey(self.mainSessionKey) self.homeCanvasRevision &+= 1 @@ -760,6 +759,7 @@ final class NodeAppModel { let selected = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if !selected.isEmpty, !decoded.agents.contains(where: { $0.id == selected }) { self.selectedAgentId = nil + self.focusedChatSessionKey = nil } self.talkMode.updateMainSessionKey(self.mainSessionKey) self.homeCanvasRevision &+= 1 @@ -771,19 +771,31 @@ final class NodeAppModel { func refreshGatewayOverviewIfConnected() async { guard await self.isOperatorConnected() else { return } + if self.foregroundGatewayResumeCheckInFlight { + GatewayDiagnostics.log("gateway overview refresh deferred reason=foreground_resume_check") + try? await Task.sleep( + nanoseconds: UInt64(Self.foregroundResumeHealthTimeoutSeconds) * 1_000_000_000) + guard await self.isOperatorConnected(), !self.foregroundGatewayResumeCheckInFlight else { return } + } await self.refreshBrandingFromGateway() await self.refreshAgentsFromGateway() } func setSelectedAgentId(_ agentId: String?) { let trimmed = (agentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let nextSelectedAgentId = trimmed.isEmpty ? nil : trimmed + let currentSelectedAgentId = self.selectedAgentId?.trimmingCharacters(in: .whitespacesAndNewlines) + let selectedAgentChanged = currentSelectedAgentId != nextSelectedAgentId let stableID = (self.connectedGatewayID ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if stableID.isEmpty { - self.selectedAgentId = trimmed.isEmpty ? nil : trimmed + self.selectedAgentId = nextSelectedAgentId } else { - self.selectedAgentId = trimmed.isEmpty ? nil : trimmed + self.selectedAgentId = nextSelectedAgentId GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: stableID, agentId: self.selectedAgentId) } + if selectedAgentChanged { + self.focusedChatSessionKey = nil + } self.talkMode.updateMainSessionKey(self.mainSessionKey) self.homeCanvasRevision &+= 1 if let relay = ShareGatewayRelaySettings.loadConfig() { @@ -902,6 +914,7 @@ final class NodeAppModel { await self.operatorGateway.disconnect() await self.nodeGateway.disconnect() await MainActor.run { + guard !self.isAppleReviewDemoModeEnabled else { return } self.setOperatorConnected(false) self.gatewayConnected = false self.gatewayStatusText = "Reconnecting…" @@ -1835,9 +1848,13 @@ extension NodeAppModel { { return focused } + return self.defaultChatSessionKey + } + + var defaultChatSessionKey: String { // Keep chat aligned with the gateway's resolved main session key. // A hardcoded "ios" base creates synthetic placeholder sessions in the chat UI. - return self.mainSessionKey + self.mainSessionKey } func openChat(sessionKey: String?) { @@ -1851,11 +1868,30 @@ extension NodeAppModel { self.talkMode.updateMainSessionKey(self.chatSessionKey) } + var chatAgentId: String { + if let sessionAgentId = SessionKey.agentId(from: self.chatSessionKey) { + return sessionAgentId + } + return self.selectedOrDefaultAgentId + } + + var chatAgentName: String { + self.agentDisplayName(for: self.chatAgentId, fallback: "Main") + } + var activeAgentName: String { + self.agentDisplayName(for: self.selectedOrDefaultAgentId, fallback: "Main") + } + + private var selectedOrDefaultAgentId: String { let agentId = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedId = agentId.isEmpty ? defaultId : agentId - if resolvedId.isEmpty { return "Main" } + return agentId.isEmpty ? defaultId : agentId + } + + private func agentDisplayName(for agentId: String, fallback: String) -> String { + let resolvedId = agentId.trimmingCharacters(in: .whitespacesAndNewlines) + if resolvedId.isEmpty { return fallback } if let match = self.gatewayAgents.first(where: { $0.id == resolvedId }) { let name = (match.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) return name.isEmpty ? match.id : name @@ -1900,7 +1936,7 @@ extension NodeAppModel { } self.activeGatewayConnectConfig = nextConfig - self.prepareForGatewayConnect(url: url, stableID: effectiveStableID) + self.prepareForGatewayConnect(stableID: effectiveStableID) if operatorLoopRequired { self.startOperatorGatewayLoop( url: url, @@ -1926,6 +1962,7 @@ extension NodeAppModel { /// Preferred entry-point: apply a single config object and start both sessions. func applyGatewayConnectConfig(_ cfg: GatewayConnectConfig, forceReconnect: Bool = false) { + self.isAppleReviewDemoModeEnabled = false self.connectToGateway( url: cfg.url, // Preserve the caller-provided stableID (may be empty) and let connectToGateway @@ -1940,19 +1977,42 @@ extension NodeAppModel { } func resetGatewaySessionsForForcedReconnect() async { - self.nodeGatewayTask?.cancel() + let nodeGatewayTask = self.nodeGatewayTask + let operatorGatewayTask = self.operatorGatewayTask + nodeGatewayTask?.cancel() self.nodeGatewayTask = nil - self.operatorGatewayTask?.cancel() + operatorGatewayTask?.cancel() self.operatorGatewayTask = nil await self.operatorGateway.disconnect() await self.nodeGateway.disconnect() + // Foreground recovery reuses the same config immediately after reset. + // Wait for canceled loops so their shutdown cleanup cannot clobber the new reconnect state. + if let operatorGatewayTask { + await operatorGatewayTask.value + } + if let nodeGatewayTask { + await nodeGatewayTask.value + } + } + + private func restartGatewaySessionsAfterForegroundStaleConnection() async { + await self.resetGatewaySessionsForForcedReconnect() + guard !self.isAppleReviewDemoModeEnabled else { return } + self.setOperatorConnected(false) + self.gatewayConnected = false + self.gatewayStatusText = "Reconnecting…" + self.talkMode.updateGatewayConnected(false) + guard let cfg = self.activeGatewayConnectConfig else { return } + self.applyGatewayConnectConfig(cfg, forceReconnect: true) } func disconnectGateway() { + self.isAppleReviewDemoModeEnabled = false self.gatewayAutoReconnectEnabled = false self.gatewayPairingPaused = false self.gatewayPairingRequestId = nil self.lastGatewayProblem = nil + self.operatorGatewayProblem = nil self.nodeGatewayTask?.cancel() self.nodeGatewayTask = nil self.operatorGatewayTask?.cancel() @@ -1973,7 +2033,6 @@ extension NodeAppModel { self.gatewayConnected = false self.setOperatorConnected(false) self.talkMode.updateGatewayConnected(false) - self.seamColorHex = nil self.mainSessionBaseKey = "main" self.talkMode.updateMainSessionKey(self.mainSessionKey) ShareGatewayRelaySettings.clearConfig() @@ -1982,11 +2041,13 @@ extension NodeAppModel { } extension NodeAppModel { - private func prepareForGatewayConnect(url: URL, stableID: String) { + private func prepareForGatewayConnect(stableID: String) { + self.isAppleReviewDemoModeEnabled = false self.gatewayAutoReconnectEnabled = true self.gatewayPairingPaused = false self.gatewayPairingRequestId = nil self.lastGatewayProblem = nil + self.operatorGatewayProblem = nil self.nodeGatewayTask?.cancel() self.operatorGatewayTask?.cancel() self.gatewayHealthMonitor.stop() @@ -2001,17 +2062,30 @@ extension NodeAppModel { self.gatewayDefaultAgentId = nil self.gatewayAgents = [] self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID) + self.focusedChatSessionKey = nil self.homeCanvasRevision &+= 1 self.apnsLastRegisteredTokenHex = nil } private func clearGatewayConnectionProblem() { + if let operatorGatewayProblem { + self.lastGatewayProblem = operatorGatewayProblem + if operatorGatewayProblem.needsPairingApproval { + self.gatewayPairingPaused = true + self.gatewayPairingRequestId = operatorGatewayProblem.requestId + } else { + self.gatewayPairingPaused = false + self.gatewayPairingRequestId = nil + } + return + } self.lastGatewayProblem = nil self.gatewayPairingPaused = false self.gatewayPairingRequestId = nil } private func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) { + guard !self.isAppleReviewDemoModeEnabled else { return } self.lastGatewayProblem = problem self.gatewayStatusText = problem.statusText self.gatewayServerName = nil @@ -2036,6 +2110,38 @@ extension NodeAppModel { } } + private func applyOperatorGatewayConnectionProblem(_ problem: GatewayConnectionProblem) { + guard !self.isAppleReviewDemoModeEnabled else { return } + self.operatorGatewayProblem = problem + self.lastGatewayProblem = problem + self.gatewayStatusText = problem.statusText + if problem.needsPairingApproval { + self.gatewayPairingPaused = true + self.gatewayPairingRequestId = problem.requestId + } + if problem.needsPairingApproval || problem.pauseReconnect { + LiveActivityManager.shared.showAttention( + statusText: problem.needsPairingApproval ? "Approval needed" : "Action required", + agentName: self.activeAgentName, + sessionKey: self.mainSessionKey) + } + } + + private func clearOperatorGatewayConnectionProblemIfCurrent() { + guard let operatorGatewayProblem else { return } + self.operatorGatewayProblem = nil + guard self.lastGatewayProblem == operatorGatewayProblem else { return } + self.lastGatewayProblem = nil + self.gatewayPairingPaused = false + self.gatewayPairingRequestId = nil + if self.gatewayServerName != nil { + self.gatewayStatusText = "Connected" + } + if self.gatewayConnected { + LiveActivityManager.shared.handleReconnect() + } + } + private func shouldKeepGatewayProblemStatus(forDisconnectReason reason: String) -> Bool { guard let lastGatewayProblem else { return false } return GatewayConnectionProblemMapper.shouldPreserve( @@ -2213,13 +2319,19 @@ extension NodeAppModel { fallbackPassword: password) let effectiveClientId = GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID) ?? nodeOptions.clientId + let talkPermissionUpgradeRequest = self.forceOperatorTalkPermissionUpgradeRequest let operatorOptions = self.makeOperatorConnectOptions( clientId: effectiveClientId, displayName: nodeOptions.clientDisplayName, + includeAdminScope: self.shouldRequestOperatorAdminScope( + token: reconnectAuth.token, + password: reconnectAuth.password, + forceTalkPermissionUpgradeRequest: talkPermissionUpgradeRequest), includeApprovalScope: self.shouldRequestOperatorApprovalScope( token: reconnectAuth.token, - password: reconnectAuth.password), - forceExplicitScopes: self.forceOperatorTalkPermissionUpgradeRequest) + password: reconnectAuth.password, + forceTalkPermissionUpgradeRequest: talkPermissionUpgradeRequest), + forceExplicitScopes: talkPermissionUpgradeRequest) do { try await self.operatorGateway.connect( @@ -2231,11 +2343,15 @@ extension NodeAppModel { sessionBox: sessionBox, onConnected: { [weak self] in guard let self else { return } - await MainActor.run { + let shouldUseConnection = await MainActor.run { + guard !self.isAppleReviewDemoModeEnabled else { return false } self.setOperatorConnected(true) + self.clearOperatorGatewayConnectionProblemIfCurrent() self.forceOperatorTalkPermissionUpgradeRequest = false self.talkMode.updateGatewayConnected(true) + return true } + guard shouldUseConnection else { return } GatewayDiagnostics.log( "operator gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")") await self.talkMode.reloadConfig() @@ -2250,6 +2366,7 @@ extension NodeAppModel { onDisconnected: { [weak self] reason in guard let self else { return } await MainActor.run { + guard !self.isAppleReviewDemoModeEnabled else { return } self.setOperatorConnected(false) self.talkMode.updateGatewayConnected(false) LiveActivityManager.shared.endActivity(reason: "operator_disconnected") @@ -2272,12 +2389,14 @@ extension NodeAppModel { } catch { attempt += 1 GatewayDiagnostics.log("operator gateway connect error: \(error.localizedDescription)") - let problem = await MainActor.run { + let problem: GatewayConnectionProblem? = await MainActor.run { let nextProblem = GatewayConnectionProblemMapper.map(error: error) + guard !self.isAppleReviewDemoModeEnabled else { return nil } if let nextProblem { - if nextProblem.kind == .pairingScopeUpgradeRequired { - self.gatewayPairingPaused = true - self.gatewayPairingRequestId = nextProblem.requestId + if nextProblem.needsPairingApproval || nextProblem.pauseReconnect { + self.applyOperatorGatewayConnectionProblem(nextProblem) + } + if talkPermissionUpgradeRequest, nextProblem.kind == .pairingScopeUpgradeRequired { self.talkMode.markTalkPermissionUpgradeRequested(requestId: nextProblem.requestId) } } @@ -2338,6 +2457,7 @@ extension NodeAppModel { continue } await MainActor.run { + guard !self.isAppleReviewDemoModeEnabled else { return } self.gatewayStatusText = (attempt == 0) ? "Connecting…" : "Reconnecting…" self.gatewayServerName = nil self.gatewayRemoteAddress = nil @@ -2364,7 +2484,8 @@ extension NodeAppModel { sessionBox: sessionBox, onConnected: { [weak self] in guard let self else { return } - await MainActor.run { + let shouldUseConnection = await MainActor.run { + guard !self.isAppleReviewDemoModeEnabled else { return false } self.clearGatewayConnectionProblem() self.gatewayStatusText = "Connected" self.gatewayServerName = url.host ?? "gateway" @@ -2372,7 +2493,9 @@ extension NodeAppModel { self.screen.errorText = nil UserDefaults.standard.set(true, forKey: "gateway.autoconnect") LiveActivityManager.shared.handleReconnect() + return true } + guard shouldUseConnection else { return } let usedBootstrapToken = reconnectAuth.token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false && reconnectAuth.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -2421,6 +2544,7 @@ extension NodeAppModel { onDisconnected: { [weak self] reason in guard let self else { return } await MainActor.run { + guard !self.isAppleReviewDemoModeEnabled else { return } if self.shouldKeepGatewayProblemStatus(forDisconnectReason: reason), let lastGatewayProblem = self.lastGatewayProblem { @@ -2466,10 +2590,11 @@ extension NodeAppModel { } attempt += 1 - let problem = await MainActor.run { + let problem: GatewayConnectionProblem? = await MainActor.run { let nextProblem = GatewayConnectionProblemMapper.map( error: error, preserving: self.lastGatewayProblem) + guard !self.isAppleReviewDemoModeEnabled else { return nil } if let nextProblem { self.applyGatewayConnectionProblem(nextProblem) } else { @@ -2510,6 +2635,7 @@ extension NodeAppModel { } await MainActor.run { + guard !self.isAppleReviewDemoModeEnabled else { return } self.lastGatewayProblem = nil self.gatewayStatusText = "Offline" LiveActivityManager.shared.endActivity(reason: "gateway_loop_stopped") @@ -2519,7 +2645,6 @@ extension NodeAppModel { self.gatewayConnected = false self.setOperatorConnected(false) self.talkMode.updateGatewayConnected(false) - self.seamColorHex = nil self.mainSessionBaseKey = "main" self.talkMode.updateMainSessionKey(self.mainSessionKey) self.showLocalCanvasOnDisconnect() @@ -2527,7 +2652,11 @@ extension NodeAppModel { } } - private func shouldRequestOperatorApprovalScope(token: String?, password: String?) -> Bool { + private func shouldRequestOperatorApprovalScope( + token: String?, + password: String?, + forceTalkPermissionUpgradeRequest: Bool = false) -> Bool + { let identity = DeviceIdentityStore.loadOrCreate() let storedOperatorScopes = DeviceAuthStore .loadToken(deviceId: identity.deviceId, role: "operator")? @@ -2535,14 +2664,19 @@ extension NodeAppModel { return Self.shouldRequestOperatorApprovalScope( token: token, password: password, - storedOperatorScopes: storedOperatorScopes) + storedOperatorScopes: storedOperatorScopes, + forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest) } fileprivate nonisolated static func shouldRequestOperatorApprovalScope( token: String?, password: String?, - storedOperatorScopes: [String]) -> Bool + storedOperatorScopes: [String], + forceTalkPermissionUpgradeRequest: Bool = false) -> Bool { + if forceTalkPermissionUpgradeRequest { + return storedOperatorScopes.contains("operator.approvals") + } let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !trimmedToken.isEmpty { return true @@ -2554,13 +2688,53 @@ extension NodeAppModel { return storedOperatorScopes.contains("operator.approvals") } + private func shouldRequestOperatorAdminScope( + token: String?, + password: String?, + forceTalkPermissionUpgradeRequest: Bool = false) -> Bool + { + let identity = DeviceIdentityStore.loadOrCreate() + let storedOperatorScopes = DeviceAuthStore + .loadToken(deviceId: identity.deviceId, role: "operator")? + .scopes ?? [] + return Self.shouldRequestOperatorAdminScope( + token: token, + password: password, + storedOperatorScopes: storedOperatorScopes, + forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest) + } + + fileprivate nonisolated static func shouldRequestOperatorAdminScope( + token: String?, + password: String?, + storedOperatorScopes: [String], + forceTalkPermissionUpgradeRequest: Bool = false) -> Bool + { + if forceTalkPermissionUpgradeRequest { + return false + } + let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedToken.isEmpty { + return true + } + let trimmedPassword = password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedPassword.isEmpty { + return true + } + return storedOperatorScopes.contains("operator.admin") + } + private func makeOperatorConnectOptions( clientId: String, displayName: String?, + includeAdminScope: Bool = false, includeApprovalScope: Bool, forceExplicitScopes: Bool = false) -> GatewayConnectOptions { var scopes = ["operator.read", "operator.write", "operator.talk.secrets"] + if includeAdminScope { + scopes.append("operator.admin") + } // Preserve reconnect compatibility for older paired operator tokens that were // approved before iOS requested operator.approvals by default. if includeApprovalScope { @@ -2596,6 +2770,60 @@ extension NodeAppModel { private func setOperatorConnected(_ connected: Bool) { self.operatorConnected = connected self.operatorStatusText = connected ? "Connected" : "Offline" + self.refreshOperatorAdminScopeFromStore() + } + + private func refreshOperatorAdminScopeFromStore() { + let identity = DeviceIdentityStore.loadOrCreate() + self.hasOperatorAdminScope = DeviceAuthStore + .loadToken(deviceId: identity.deviceId, role: "operator")? + .scopes + .contains("operator.admin") == true + } +} + +extension NodeAppModel { + func enterAppleReviewDemoMode() { + self.isAppleReviewDemoModeEnabled = true + self.gatewayAutoReconnectEnabled = false + self.gatewayPairingPaused = false + self.gatewayPairingRequestId = nil + self.lastGatewayProblem = nil + self.operatorGatewayProblem = nil + self.nodeGatewayTask?.cancel() + self.nodeGatewayTask = nil + self.operatorGatewayTask?.cancel() + self.operatorGatewayTask = nil + self.voiceWakeSyncTask?.cancel() + self.voiceWakeSyncTask = nil + self.gatewayHealthMonitor.stop() + LiveActivityManager.shared.endActivity(reason: "apple_review_demo") + + Task { + await self.operatorGateway.disconnect() + await self.nodeGateway.disconnect() + } + + self.gatewayStatusText = "Connected" + self.nodeStatusText = "Connected" + self.gatewayServerName = AppleReviewDemoMode.gatewayName + self.gatewayRemoteAddress = AppleReviewDemoMode.gatewayAddress + self.connectedGatewayID = AppleReviewDemoMode.gatewayID + self.activeGatewayConnectConfig = nil + self.gatewayConnected = true + self.setOperatorConnected(false) + UserDefaults.standard.set(false, forKey: "talk.enabled") + UserDefaults.standard.set(false, forKey: "talk.background.enabled") + self.talkMode.updateGatewayConnected(false) + self.talkMode.setEnabled(false) + self.talkMode.statusText = "Demo mode only" + self.mainSessionBaseKey = "main" + self.selectedAgentId = nil + self.gatewayDefaultAgentId = "main" + self.gatewayAgents = AppleReviewDemoMode.agents + self.focusedChatSessionKey = nil + self.talkMode.updateMainSessionKey(self.mainSessionKey) + self.homeCanvasRevision &+= 1 } } @@ -2703,14 +2931,6 @@ extension NodeAppModel { self.refreshLastShareEventFromRelay() } - func reloadTalkConfig() { - Task { [weak self] in - guard let self else { return } - await self.talkMode.reloadConfig() - await self.talkMode.prefetchRealtimeSessionIfReady(reason: "config_reload") - } - } - /// Back-compat hook retained for older gateway-connect flows. func onNodeGatewayConnected() async { await self.registerAPNsTokenIfNeeded() @@ -3671,32 +3891,6 @@ extension NodeAppModel { } } - func handleExecApprovalNotificationDecision( - approvalId: String, - decision: String) async - { - let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedApprovalID.isEmpty else { return } - - if self.pendingExecApprovalPrompt?.id == normalizedApprovalID { - self.pendingExecApprovalPromptResolving = true - self.pendingExecApprovalPromptErrorText = nil - } - - let outcome = await self.resolveExecApprovalNotificationDecision( - approvalId: normalizedApprovalID, - decision: decision) - switch outcome { - case .resolved, .stale, .unavailable: - break - case let .failed(message): - if self.pendingExecApprovalPrompt?.id == normalizedApprovalID { - self.pendingExecApprovalPromptResolving = false - self.pendingExecApprovalPromptErrorText = message - } - } - } - private func resolveExecApprovalNotificationDecision( approvalId: String, decision: String, @@ -4233,17 +4427,6 @@ extension NodeAppModel { self.talkMode.updateMainSessionKey(self.mainSessionKey) } - private static func color(fromHex raw: String?) -> Color? { - let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed - guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil } - let r = Double((value >> 16) & 0xFF) / 255.0 - let g = Double((value >> 8) & 0xFF) / 255.0 - let b = Double(value & 0xFF) / 255.0 - return Color(red: r, green: g, blue: b) - } - func approvePendingAgentDeepLinkPrompt() async { guard let prompt = self.pendingAgentDeepLinkPrompt else { return } self.pendingAgentDeepLinkPrompt = nil @@ -4393,30 +4576,10 @@ extension NodeAppModel { try self.encodePayload(obj) } - func _test_isCameraEnabled() -> Bool { - self.isCameraEnabled() - } - - func _test_triggerCameraFlash() { - self.triggerCameraFlash() - } - - func _test_showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) { - self.showCameraHUD(text: text, kind: kind, autoHideSeconds: autoHideSeconds) - } - func _test_handleCanvasA2UIAction(body: [String: Any]) async { await self.handleCanvasA2UIAction(body: body) } - func _test_showLocalCanvasOnDisconnect() { - self.showLocalCanvasOnDisconnect() - } - - func _test_applyTalkModeSync(enabled: Bool, phase: String? = nil) { - self.applyTalkModeSync(enabled: enabled, phase: phase) - } - func _test_queuedWatchReplyCount() -> Int { self.watchReplyCoordinator.queuedCount } @@ -4425,6 +4588,14 @@ extension NodeAppModel { self.gatewayConnected = connected } + func _test_isGatewayConnected() -> Bool { + self.gatewayConnected + } + + func _test_refreshOperatorAdminScopeFromStore() { + self.refreshOperatorAdminScopeFromStore() + } + func _test_applyPendingForegroundNodeActions( _ actions: [(id: String, command: String, paramsJSON: String?)]) async { @@ -4441,12 +4612,14 @@ extension NodeAppModel { func _test_makeOperatorConnectOptions( clientId: String, displayName: String?, + includeAdminScope: Bool = false, includeApprovalScope: Bool, forceExplicitScopes: Bool = false) -> GatewayConnectOptions { self.makeOperatorConnectOptions( clientId: clientId, displayName: displayName, + includeAdminScope: includeAdminScope, includeApprovalScope: includeApprovalScope, forceExplicitScopes: forceExplicitScopes) } @@ -4459,6 +4632,18 @@ extension NodeAppModel { self.dismissPendingExecApprovalPrompt() } + func _test_applyOperatorGatewayConnectionProblem(_ problem: GatewayConnectionProblem) { + self.applyOperatorGatewayConnectionProblem(problem) + } + + func _test_clearOperatorGatewayConnectionProblemIfCurrent() { + self.clearOperatorGatewayConnectionProblemIfCurrent() + } + + func _test_clearGatewayConnectionProblem() { + self.clearGatewayConnectionProblem() + } + func _test_pendingExecApprovalPrompt() -> ExecApprovalPrompt? { self.pendingExecApprovalPrompt } @@ -4552,12 +4737,27 @@ extension NodeAppModel { nonisolated static func _test_shouldRequestOperatorApprovalScope( token: String?, password: String?, - storedOperatorScopes: [String]) -> Bool + storedOperatorScopes: [String], + forceTalkPermissionUpgradeRequest: Bool = false) -> Bool { self.shouldRequestOperatorApprovalScope( token: token, password: password, - storedOperatorScopes: storedOperatorScopes) + storedOperatorScopes: storedOperatorScopes, + forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest) + } + + nonisolated static func _test_shouldRequestOperatorAdminScope( + token: String?, + password: String?, + storedOperatorScopes: [String], + forceTalkPermissionUpgradeRequest: Bool = false) -> Bool + { + self.shouldRequestOperatorAdminScope( + token: token, + password: password, + storedOperatorScopes: storedOperatorScopes, + forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest) } nonisolated static func _test_clearingBootstrapToken( @@ -4570,6 +4770,10 @@ extension NodeAppModel { (self.nodeGatewayTask != nil, self.operatorGatewayTask != nil) } + func _test_restartGatewaySessionsAfterForegroundStaleConnection() async { + await self.restartGatewaySessionsAfterForegroundStaleConnection() + } + func _test_handleSuccessfulBootstrapGatewayOnboarding() async { await self.handleSuccessfulBootstrapGatewayOnboarding( url: URL(string: "wss://gateway.example")!, diff --git a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift deleted file mode 100644 index 1b1016f848fd..000000000000 --- a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift +++ /dev/null @@ -1,358 +0,0 @@ -import Foundation -import OpenClawKit -import SwiftUI - -struct GatewayOnboardingView: View { - var body: some View { - NavigationStack { - List { - Section { - Text("Connect to your gateway to get started.") - .foregroundStyle(.secondary) - } - - Section { - NavigationLink("Auto detect") { - AutoDetectStep() - } - NavigationLink("Manual entry") { - ManualEntryStep() - } - } - } - .navigationTitle("Connect Gateway") - } - .gatewayTrustPromptAlert() - } -} - -private struct AutoDetectStep: View { - @Environment(NodeAppModel.self) private var appModel: NodeAppModel - @Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController - @AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = "" - @AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = "" - - @State private var connectingGatewayID: String? - @State private var connectStatusText: String? - - var body: some View { - Form { - Section { - Text("We’ll scan for gateways on your network and connect automatically when we find one.") - .foregroundStyle(.secondary) - } - - gatewayConnectionStatusSection( - appModel: self.appModel, - gatewayController: self.gatewayController, - secondaryLine: self.connectStatusText) - - Section { - Button("Retry") { - resetGatewayConnectionState( - appModel: self.appModel, - connectStatusText: &self.connectStatusText, - connectingGatewayID: &self.connectingGatewayID) - self.triggerAutoConnect() - } - .disabled(self.connectingGatewayID != nil) - } - } - .navigationTitle("Auto detect") - .onAppear { self.triggerAutoConnect() } - .onChange(of: self.gatewayController.gateways) { _, _ in - self.triggerAutoConnect() - } - } - - private func triggerAutoConnect() { - guard self.appModel.gatewayServerName == nil else { return } - guard self.connectingGatewayID == nil else { return } - guard let candidate = self.autoCandidate() else { return } - - self.connectingGatewayID = candidate.id - Task { - defer { self.connectingGatewayID = nil } - await self.gatewayController.connect(candidate) - } - } - - private func autoCandidate() -> GatewayDiscoveryModel.DiscoveredGateway? { - let preferred = self.preferredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) - let lastDiscovered = self.lastDiscoveredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) - - if !preferred.isEmpty, - let match = self.gatewayController.gateways.first(where: { $0.stableID == preferred }) - { - return match - } - if !lastDiscovered.isEmpty, - let match = self.gatewayController.gateways.first(where: { $0.stableID == lastDiscovered }) - { - return match - } - if self.gatewayController.gateways.count == 1 { - return self.gatewayController.gateways.first - } - return nil - } -} - -private struct ManualEntryStep: View { - @Environment(NodeAppModel.self) private var appModel: NodeAppModel - @Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController - - @State private var setupCode: String = "" - @State private var setupStatusText: String? - @State private var manualHost: String = "" - @State private var manualPortText: String = "" - @State private var manualUseTLS: Bool = true - @State private var manualToken: String = "" - @State private var manualPassword: String = "" - @State private var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride? - - @State private var connectingGatewayID: String? - @State private var connectStatusText: String? - - var body: some View { - Form { - Section("Setup code") { - Text("Use /pair in your bot to get a setup code.") - .font(.footnote) - .foregroundStyle(.secondary) - - TextField("Paste setup code", text: self.$setupCode) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - - Button("Apply setup code") { - self.applySetupCode() - } - .disabled(self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - - if let setupStatusText, !setupStatusText.isEmpty { - Text(setupStatusText) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - - Section { - TextField("Host", text: self.$manualHost) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - - TextField("Port", text: self.$manualPortText) - .keyboardType(.numberPad) - - Toggle("Use TLS", isOn: self.$manualUseTLS) - - TextField("Gateway token", text: self.$manualToken) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - - SecureField("Gateway password", text: self.$manualPassword) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - } - - gatewayConnectionStatusSection( - appModel: self.appModel, - gatewayController: self.gatewayController, - secondaryLine: self.connectStatusText) - - Section { - Button { - Task { await self.connectManual() } - } label: { - if self.connectingGatewayID == "manual" { - HStack(spacing: 8) { - ProgressView() - .progressViewStyle(.circular) - Text("Connecting…") - } - } else { - Text("Connect") - } - } - .disabled(self.connectingGatewayID != nil) - - Button("Retry") { - resetGatewayConnectionState( - appModel: self.appModel, - connectStatusText: &self.connectStatusText, - connectingGatewayID: &self.connectingGatewayID) - self.resetManualForm() - } - .disabled(self.connectingGatewayID != nil) - } - } - .navigationTitle("Manual entry") - } - - private func connectManual() async { - let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines) - guard !host.isEmpty else { - self.connectStatusText = "Failed: host required" - return - } - - if let port = self.manualPortValue(), !(1...65535).contains(port) { - self.connectStatusText = "Failed: invalid port" - return - } - - let defaults = UserDefaults.standard - defaults.set(true, forKey: "gateway.manual.enabled") - defaults.set(host, forKey: "gateway.manual.host") - defaults.set(self.manualPortValue() ?? 0, forKey: "gateway.manual.port") - defaults.set(self.manualUseTLS, forKey: "gateway.manual.tls") - - let instanceId = GatewaySettingsStore.currentInstanceID() - if !instanceId.isEmpty { - let trimmedToken = self.manualToken.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedPassword = self.manualPassword.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmedToken.isEmpty { - GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: instanceId) - } - GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: instanceId) - } - - self.connectingGatewayID = "manual" - defer { self.connectingGatewayID = nil } - let authOverride = GatewayConnectionController.ManualAuthOverride.currentManualInput( - token: self.manualToken, - pendingOverride: self.pendingManualAuthOverride, - password: self.manualPassword) - self.pendingManualAuthOverride = nil - await self.gatewayController.connectManual( - host: host, - port: self.manualPortValue() ?? 0, - useTLS: self.manualUseTLS, - authOverride: authOverride) - } - - private func manualPortValue() -> Int? { - let trimmed = self.manualPortText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return Int(trimmed.filter(\.isNumber)) - } - - private func resetManualForm() { - self.setupCode = "" - self.setupStatusText = nil - self.manualHost = "" - self.manualPortText = "" - self.manualUseTLS = true - self.manualToken = "" - self.manualPassword = "" - } - - private func applySetupCode() { - let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines) - guard !raw.isEmpty else { - self.setupStatusText = "Paste a setup code to continue." - return - } - - guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else { - self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL." - return - } - - self.manualHost = link.host - self.manualPortText = String(link.port) - self.manualUseTLS = link.tls - - let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link) - if setupAuth.shouldApplyTokenField { - self.manualToken = setupAuth.token - } - if setupAuth.shouldApplyPasswordField { - self.manualPassword = setupAuth.password - } - - let trimmedInstanceId = GatewaySettingsStore.currentInstanceID() - if !trimmedInstanceId.isEmpty { - if setupAuth.hasBootstrapToken { - GatewayOnboardingReset.prepareForBootstrapPairing( - appModel: self.appModel, - instanceId: trimmedInstanceId) - } - GatewaySettingsStore.saveGatewayBootstrapToken(setupAuth.bootstrapToken, instanceId: trimmedInstanceId) - } - self.pendingManualAuthOverride = setupAuth.manualAuthOverride - - self.setupStatusText = "Setup code applied." - } -} - -@MainActor -private func gatewayConnectionStatusLines( - appModel: NodeAppModel, - gatewayController: GatewayConnectionController) -> [String] -{ - ConnectionStatusBox.defaultLines(appModel: appModel, gatewayController: gatewayController) -} - -@MainActor -private func resetGatewayConnectionState( - appModel: NodeAppModel, - connectStatusText: inout String?, - connectingGatewayID: inout String?) -{ - appModel.disconnectGateway() - connectStatusText = nil - connectingGatewayID = nil -} - -@MainActor -private func gatewayConnectionStatusSection( - appModel: NodeAppModel, - gatewayController: GatewayConnectionController, - secondaryLine: String?) -> some View -{ - Section("Connection status") { - ConnectionStatusBox( - statusLines: gatewayConnectionStatusLines( - appModel: appModel, - gatewayController: gatewayController), - secondaryLine: secondaryLine) - } -} - -private struct ConnectionStatusBox: View { - let statusLines: [String] - let secondaryLine: String? - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - ForEach(self.statusLines, id: \.self) { line in - Text(line) - .font(.system(size: 12, weight: .regular, design: .monospaced)) - .foregroundStyle(.secondary) - } - if let secondaryLine, !secondaryLine.isEmpty { - Text(secondaryLine) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(10) - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - } - - static func defaultLines( - appModel: NodeAppModel, - gatewayController: GatewayConnectionController) -> [String] - { - var lines: [String] = [ - "gateway: \(appModel.gatewayDisplayStatusText)", - "discovery: \(gatewayController.discoveryStatusText)", - ] - lines.append("server: \(appModel.gatewayServerName ?? "—")") - lines.append("address: \(appModel.gatewayRemoteAddress ?? "—")") - return lines - } -} diff --git a/apps/ios/Sources/Onboarding/OnboardingStateStore.swift b/apps/ios/Sources/Onboarding/OnboardingStateStore.swift index edd2b30df0d6..db91689d5fbf 100644 --- a/apps/ios/Sources/Onboarding/OnboardingStateStore.swift +++ b/apps/ios/Sources/Onboarding/OnboardingStateStore.swift @@ -53,10 +53,6 @@ enum OnboardingStateStore { defaults.set(true, forKey: self.firstRunIntroSeenDefaultsKey) } - static func markIncomplete(defaults: UserDefaults = .standard) { - defaults.set(false, forKey: self.completedDefaultsKey) - } - static func reset(defaults: UserDefaults = .standard) { defaults.set(false, forKey: self.completedDefaultsKey) defaults.set(false, forKey: self.firstRunIntroSeenDefaultsKey) diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift b/apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift index 2803b44f5733..9687c1161878 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift @@ -7,9 +7,7 @@ struct OnboardingIntroStep: View { VStack(spacing: 0) { Spacer() - Image(systemName: UIDevice.current.userInterfaceIdiom == .pad ? "ipad" : "iphone.gen3") - .font(.system(size: 60, weight: .semibold)) - .foregroundStyle(.tint) + OpenClawProMark(size: 64, shadowRadius: 14) .padding(.bottom, 18) Text("Welcome to OpenClaw") @@ -181,6 +179,7 @@ struct OnboardingModeRow: View { Image(systemName: self.selected ? "checkmark.circle.fill" : "circle") .foregroundStyle(self.selected ? Color.accentColor : Color.secondary) } + .contentShape(Rectangle()) } .buttonStyle(.plain) } diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index 6cc71f1729a1..d5e7ad792749 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -17,10 +17,6 @@ private enum OnboardingStep: Int, CaseIterable { Self(rawValue: self.rawValue - 1) } - var next: Self? { - Self(rawValue: self.rawValue + 1) - } - /// Progress label for the manual setup flow (mode → connect → auth → success). var manualProgressTitle: String { let manualSteps: [OnboardingStep] = [.mode, .connect, .auth, .success] @@ -72,6 +68,8 @@ struct OnboardingWizardView: View { @State private var showGatewayProblemDetails: Bool = false @State private var lastPairingAutoResumeAttemptAt: Date? @State private var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride? + @State private var setupCode: String = "" + @State private var setupCodeStatus: String? private static let pairingAutoResumeTicker = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect() let allowSkip: Bool @@ -172,6 +170,9 @@ struct OnboardingWizardView: View { onGatewayLink: { link in self.handleScannedLink(link) }, + onSetupCode: { code in + self.handleScannedSetupCode(code) + }, onError: { error in self.showQRScanner = false self.statusLine = "Scanner error: \(error)" @@ -208,6 +209,10 @@ struct OnboardingWizardView: View { self.handleScannedLink(link) return } + if AppleReviewDemoMode.isSetupCode(message) { + self.handleScannedSetupCode(message) + return + } } self.showQRScanner = false self.scannerError = "No valid QR code found in the selected image." @@ -272,7 +277,7 @@ struct OnboardingWizardView: View { OnboardingStateStore.markCompleted(mode: selectedMode) self.didMarkCompleted = true } - self.onClose() + self.step = .success } .onChange(of: self.scenePhase) { _, newValue in guard newValue == .active else { return } @@ -301,6 +306,8 @@ struct OnboardingWizardView: View { @ViewBuilder private var modeStep: some View { + self.setupCodeSection + Section("Connection Mode") { OnboardingModeRow( title: OnboardingConnectionMode.homeNetwork.title, @@ -318,16 +325,7 @@ struct OnboardingWizardView: View { self.selectMode(.remoteDomain) } - Toggle( - "Developer mode", - isOn: Binding( - get: { self.developerModeEnabled }, - set: { newValue in - self.developerModeEnabled = newValue - if !newValue, self.selectedMode == .developerLocal { - self.selectedMode = nil - } - })) + self.developerModeToggleRow if self.developerModeEnabled { OnboardingModeRow( @@ -348,6 +346,49 @@ struct OnboardingWizardView: View { } } + private var developerModeToggleRow: some View { + self.onboardingButtonToggle( + "Developer mode", + isOn: Binding( + get: { self.developerModeEnabled }, + set: { enabled in + self.developerModeEnabled = enabled + if !enabled, self.selectedMode == .developerLocal { + self.selectedMode = nil + } + })) + } + + private func onboardingButtonToggle(_ title: String, isOn: Binding) -> some View { + // Onboarding Form switch rows need full-width taps; native Toggle only hits the switch edge on iOS 26. + Button { + isOn.wrappedValue.toggle() + } label: { + HStack { + Text(title) + Spacer(minLength: 8) + self.onboardingSwitchIndicator(isOn: isOn.wrappedValue) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(title) + .accessibilityValue(isOn.wrappedValue ? "On" : "Off") + } + + private func onboardingSwitchIndicator(isOn: Bool) -> some View { + Capsule() + .fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35)) + .frame(width: 52, height: 32) + .overlay(alignment: isOn ? .trailing : .leading) { + Circle() + .fill(Color.white) + .frame(width: 28, height: 28) + .padding(2) + .shadow(color: Color.black.opacity(0.14), radius: 1, x: 0, y: 1) + } + } + @ViewBuilder private var connectStep: some View { if let selectedMode { @@ -440,7 +481,7 @@ struct OnboardingWizardView: View { .autocorrectionDisabled() TextField("Port", text: self.$manualPortText) .keyboardType(.numberPad) - Toggle("Use TLS", isOn: self.$manualTLS) + self.onboardingButtonToggle("Use TLS", isOn: self.$manualTLS) self.manualConnectButton } header: { Text("Developer Local") @@ -570,6 +611,44 @@ struct OnboardingWizardView: View { } extension OnboardingWizardView { + private var setupCodeSection: some View { + Section { + TextField("Paste setup code", text: self.$setupCode) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .onSubmit { + Task { await self.applySetupCodeAndConnect() } + } + + Button { + Task { await self.applySetupCodeAndConnect() } + } label: { + if self.connectingGatewayID == "setup-code" { + HStack(spacing: 8) { + ProgressView() + .progressViewStyle(.circular) + Text("Applying...") + } + } else { + Text("Apply Setup Code") + } + } + .disabled( + self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || self.connectingGatewayID != nil) + + if let setupCodeStatus, !setupCodeStatus.isEmpty { + Text(setupCodeStatus) + .font(.footnote) + .foregroundStyle(.secondary) + } + } header: { + Text("Setup Code") + } footer: { + Text("Use this if you received a setup code instead of a QR code.") + } + } + private func manualConnectionFieldsSection(title: String) -> some View { Section(title) { TextField("Host", text: self.$manualHost) @@ -577,7 +656,7 @@ extension OnboardingWizardView { .autocorrectionDisabled() TextField("Port", text: self.$manualPortText) .keyboardType(.numberPad) - Toggle("Use TLS", isOn: self.$manualTLS) + self.onboardingButtonToggle("Use TLS", isOn: self.$manualTLS) TextField("Discovery Domain (optional)", text: self.$discoveryDomain) .textInputAutocapitalization(.never) .autocorrectionDisabled() @@ -608,9 +687,49 @@ extension OnboardingWizardView { .disabled(!self.canConnectManual || self.connectingGatewayID != nil) } + private func applySetupCodeAndConnect() async { + self.setupCodeStatus = nil + let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines) + guard !raw.isEmpty else { + self.setupCodeStatus = "Paste a setup code to continue." + return + } + + if AppleReviewDemoMode.isSetupCode(raw) { + self.setupCode = "" + self.setupCodeStatus = "Apple Review demo mode enabled." + self.handleScannedSetupCode(raw) + return + } + + guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else { + self.setupCodeStatus = "Setup code not recognized or uses an insecure ws:// gateway URL." + return + } + + self.connectingGatewayID = "setup-code" + self.applyGatewayLink(link) + self.setupCode = "" + self.setupCodeStatus = "Setup code applied. Connecting..." + self.connectMessage = "Connecting via setup code..." + self.statusLine = "Setup code loaded. Connecting to \(link.host):\(link.port)..." + self.step = .connect + await self.connectManual() + } + private func handleScannedLink(_ link: GatewayConnectDeepLink) { + self.applyGatewayLink(link) + self.setupCodeStatus = nil + self.showQRScanner = false + self.connectMessage = "Connecting via QR code..." + self.statusLine = "QR loaded. Connecting to \(link.host):\(link.port)..." + Task { await self.connectManual() } + } + + private func applyGatewayLink(_ link: GatewayConnectDeepLink) { self.manualHost = link.host self.manualPort = link.port + self.manualPortText = String(link.port) self.manualTLS = link.tls let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link) if setupAuth.hasBootstrapToken { @@ -627,13 +746,19 @@ extension OnboardingWizardView { } self.pendingManualAuthOverride = setupAuth.manualAuthOverride self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword) - self.showQRScanner = false - self.connectMessage = "Connecting via QR code…" - self.statusLine = "QR loaded. Connecting to \(link.host):\(link.port)…" if self.selectedMode == nil { self.selectedMode = link.tls ? .remoteDomain : .homeNetwork } - Task { await self.connectManual() } + } + + private func handleScannedSetupCode(_ code: String) { + guard AppleReviewDemoMode.isSetupCode(code) else { return } + self.showQRScanner = false + self.connectingGatewayID = nil + self.connectMessage = "Apple Review demo mode enabled." + self.statusLine = "Apple Review demo mode enabled." + self.selectedMode = .homeNetwork + self.appModel.enterAppleReviewDemoMode() } private func openQRScannerFromOnboarding() { diff --git a/apps/ios/Sources/Onboarding/QRScannerView.swift b/apps/ios/Sources/Onboarding/QRScannerView.swift index 97cc22b8e3c8..249a5cf1c701 100644 --- a/apps/ios/Sources/Onboarding/QRScannerView.swift +++ b/apps/ios/Sources/Onboarding/QRScannerView.swift @@ -4,6 +4,7 @@ import VisionKit struct QRScannerView: UIViewControllerRepresentable { let onGatewayLink: (GatewayConnectDeepLink) -> Void + let onSetupCode: (String) -> Void let onError: (String) -> Void let onDismiss: () -> Void @@ -72,6 +73,13 @@ struct QRScannerView: UIViewControllerRepresentable { } return } + if AppleReviewDemoMode.isSetupCode(payload) { + self.handled = true + Task { @MainActor in + self.parent.onSetupCode(payload) + } + return + } } } diff --git a/apps/ios/Sources/Push/PushBuildConfig.swift b/apps/ios/Sources/Push/PushBuildConfig.swift index d1665921552e..2affa3dd2e23 100644 --- a/apps/ios/Sources/Push/PushBuildConfig.swift +++ b/apps/ios/Sources/Push/PushBuildConfig.swift @@ -39,10 +39,6 @@ struct PushBuildConfig { self.relayBaseURL = Self.readURL(bundle: bundle, key: "OpenClawPushRelayBaseURL") } - var usesRelay: Bool { - self.transport == .relay - } - private static func readURL(bundle: Bundle, key: String) -> URL? { guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/ios/Sources/Push/PushRelayKeychainStore.swift b/apps/ios/Sources/Push/PushRelayKeychainStore.swift index 4d7df09cd14d..a9fc9a36dc88 100644 --- a/apps/ios/Sources/Push/PushRelayKeychainStore.swift +++ b/apps/ios/Sources/Push/PushRelayKeychainStore.swift @@ -71,11 +71,6 @@ enum PushRelayRegistrationStore { return KeychainStore.saveString(raw, service: self.service, account: self.registrationStateAccount) } - @discardableResult - static func clearRegistrationState() -> Bool { - KeychainStore.delete(service: self.service, account: self.registrationStateAccount) - } - static func loadAppAttestKeyID() -> String? { let value = KeychainStore.loadString(service: self.service, account: self.appAttestKeyIDAccount)? .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index 6e9f9e3dfa7e..c16c26e31457 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -8,6 +8,8 @@ struct RootTabs: View { @Environment(VoiceWakeManager.self) private var voiceWake @Environment(GatewayConnectionController.self) private var gatewayController @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.rootTabsUserInterfaceIdiomOverride) private var userInterfaceIdiomOverride @Environment(\.scenePhase) private var scenePhase @AppStorage("screen.preventSleep") private var preventSleep: Bool = true @AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0 @@ -21,10 +23,14 @@ struct RootTabs: View { @AppStorage(AppAppearancePreference.storageKey) private var appearancePreferenceRaw: String = AppAppearancePreference.system.rawValue @State private var selectedTab: AppTab = Self.initialTab + @State private var selectedSidebarDestination: SidebarDestination = Self.initialSidebarDestination + @State private var isSidebarVisible: Bool = Self.initialSidebarVisibility ?? false + @State private var sidebarVisibilityUserOverridden: Bool = Self.initialSidebarVisibility != nil + @State private var isSidebarDrawerLayout: Bool = false + @State private var didResolveSidebarLayout: Bool = false @State private var voiceWakeToastText: String? @State private var toastDismissTask: Task? @State private var presentedSheet: PresentedSheet? - @State private var showGatewayActions: Bool = false @State private var showGatewayProblemDetails: Bool = false @State private var showOnboarding: Bool = false @State private var onboardingAllowSkip: Bool = true @@ -34,14 +40,6 @@ struct RootTabs: View { @State private var didApplyInitialChatSession: Bool = false @State private var handledGatewaySetupRequestID: Int = 0 - private enum AppTab: Hashable { - case control - case chat - case talk - case agent - case settings - } - private static var initialTab: AppTab { let arguments = ProcessInfo.processInfo.arguments guard let flagIndex = arguments.firstIndex(of: "--openclaw-initial-tab") else { @@ -66,6 +64,28 @@ struct RootTabs: View { } } + private static var initialSidebarDestination: SidebarDestination { + if let requested = requestedInitialSidebarDestination { + return requested + } + return Self.defaultSidebarDestination(for: initialTab) + } + + private static var requestedInitialSidebarDestination: SidebarDestination? { + let arguments = ProcessInfo.processInfo.arguments + guard let flagIndex = arguments.firstIndex(of: "--openclaw-initial-destination") else { + return nil + } + let valueIndex = arguments.index(after: flagIndex) + guard arguments.indices.contains(valueIndex) else { return nil } + let requested = arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return SidebarDestination.allCases.first { $0.rawValue.lowercased() == requested } + } + + private static var initialSidebarVisibility: Bool? { + requestedInitialSidebarVisibility(arguments: ProcessInfo.processInfo.arguments) + } + private static var initialChatSessionKey: String? { let arguments = ProcessInfo.processInfo.arguments guard let flagIndex = arguments.firstIndex(of: "--openclaw-chat-session") else { @@ -87,45 +107,11 @@ struct RootTabs: View { } } - enum StartupPresentationRoute: Equatable { - case none - case onboarding - case settings - } - - static func startupPresentationRoute( - gatewayConnected: Bool, - hasConnectedOnce: Bool, - onboardingComplete: Bool, - hasExistingGatewayConfig: Bool, - shouldPresentOnLaunch: Bool) -> StartupPresentationRoute + static func shouldUseSidebarTabs( + idiom: UIUserInterfaceIdiom, + horizontalSizeClass _: UserInterfaceSizeClass?) -> Bool { - if gatewayConnected { - return .none - } - if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete { - return .onboarding - } - if !hasExistingGatewayConfig { - return .settings - } - return .none - } - - static func shouldPresentQuickSetup( - quickSetupDismissed: Bool, - showOnboarding: Bool, - hasPresentedSheet: Bool, - gatewayConnected: Bool, - hasExistingGatewayConfig: Bool, - discoveredGatewayCount: Int) -> Bool - { - guard !quickSetupDismissed else { return false } - guard !showOnboarding else { return false } - guard !hasPresentedSheet else { return false } - guard !gatewayConnected else { return false } - guard !hasExistingGatewayConfig else { return false } - return discoveredGatewayCount > 0 + idiom == .pad } var body: some View { @@ -136,20 +122,30 @@ struct RootTabs: View { .tint(OpenClawBrand.accent)))) } + @ViewBuilder private var tabContent: some View { + if self.usesSidebarTabs { + self.sidebarSplitContent + } else { + self.phoneTabContent + } + } + + private var phoneTabContent: some View { TabView(selection: self.$selectedTab) { - CommandCenterTab( - openChat: { self.selectedTab = .chat }, - openSettings: { self.selectedTab = .settings }) - .tabItem { Label("Command", systemImage: "target") } + RootTabsPhoneControlHub( + groups: Self.phoneControlGroups, + initialDestination: Self.requestedInitialSidebarDestination, + openRootDestination: { self.selectSidebarDestination($0) }) + .tabItem { Label("Control", systemImage: "square.grid.2x2") } .badge(self.appModel.pendingExecApprovalPrompt == nil ? 0 : 1) .tag(AppTab.control) - ChatProTab() + ChatProTab(openSettings: { self.selectSidebarDestination(.gateway) }) .tabItem { Label("Chat", systemImage: "bubble.left.fill") } .tag(AppTab.chat) - TalkProTab(openSettings: { self.selectedTab = .settings }) + TalkProTab(openSettings: { self.selectSidebarDestination(.gateway) }) .tabItem { Label( "Talk", @@ -157,16 +153,394 @@ struct RootTabs: View { } .tag(AppTab.talk) - AgentProTab() - .tabItem { Label("Agent", systemImage: "person.2.fill") } - .tag(AppTab.agent) + NavigationStack { + AgentProTab( + directRoute: .agents, + openSettings: { self.selectSidebarDestination(.gateway) }) + } + .tabItem { Label("Agent", systemImage: "person.2.fill") } + .tag(AppTab.agent) - SettingsProTab() + SettingsProTab(initialRoute: self.selectedSidebarDestination.settingsRoute) + .id(self.selectedSidebarDestination.settingsRoute.map { "\($0)" } ?? "settings") .tabItem { Label("Settings", systemImage: "gearshape.fill") } .tag(AppTab.settings) } } + private var sidebarSplitContent: some View { + GeometryReader { proxy in + let isDrawerLayout = self.shouldUseSidebarDrawer(containerSize: proxy.size) + let sidebarWidth = self.sidebarWidth(containerWidth: proxy.size.width, isDrawerLayout: isDrawerLayout) + Group { + if isDrawerLayout { + self.sidebarDrawerContent(sidebarWidth: sidebarWidth) + } else { + self.sidebarNavigationSplitContent(sidebarWidth: sidebarWidth) + } + } + .animation(.easeInOut(duration: 0.22), value: self.isSidebarVisible) + .onAppear { + self.updateSidebarLayout(containerSize: proxy.size, force: false) + } + .onChange(of: proxy.size) { _, size in + self.updateSidebarLayout(containerSize: size, force: false) + } + } + } + + private func sidebarNavigationSplitContent(sidebarWidth: CGFloat) -> some View { + HStack(spacing: 0) { + if self.isSidebarVisible { + self.sidebarColumn + .frame(width: sidebarWidth, alignment: .topLeading) + .frame(maxHeight: .infinity, alignment: .topLeading) + .overlay(alignment: .trailing) { + self.sidebarVerticalSeparator + } + .transition(.move(edge: .leading).combined(with: .opacity)) + } + + self.sidebarDetailNavigationShell + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + .background(OpenClawProBackground()) + } + + private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View { + ZStack(alignment: .topLeading) { + self.sidebarDetailNavigationShell + .frame(maxWidth: .infinity, maxHeight: .infinity) + + if self.isSidebarVisible { + Color.black.opacity(0.28) + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { + self.hideSidebar() + } + .transition(.opacity) + + self.sidebarColumn + .frame(width: sidebarWidth, alignment: .topLeading) + .frame(maxHeight: .infinity, alignment: .topLeading) + .overlay(alignment: .trailing) { + self.sidebarVerticalSeparator + } + .shadow(color: .black.opacity(0.26), radius: 18, x: 8, y: 0) + .transition(.move(edge: .leading).combined(with: .opacity)) + } + } + } + + private var sidebarDetailShell: some View { + self.sidebarDetail + .id(self.selectedSidebarDestination.id) + } + + private var sidebarColumn: some View { + VStack(spacing: 0) { + self.sidebarIdentityHeader + self.sidebarList + self.sidebarFooter + } + .safeAreaPadding(.top, 8) + .safeAreaPadding(.bottom, 8) + .background(Color(uiColor: .systemBackground)) + } + + private var sidebarIdentityHeader: some View { + HStack(spacing: 10) { + OpenClawProMark(size: 30, shadowRadius: 3) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text("OpenClaw") + .font(.headline.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + + HStack(spacing: 4) { + Image(systemName: "circle.fill") + .font(.system(size: 7, weight: .bold)) + .foregroundStyle(self.sidebarGatewayStatusColor) + Text(self.sidebarGatewayStatusTitle) + .lineLimit(1) + } + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 8) + + if self.isSidebarDrawerLayout { + self.sidebarHideButton + } + } + .padding(.horizontal, 18) + .padding(.vertical, 12) + .background(Color(uiColor: .systemBackground)) + .overlay(alignment: .bottom) { + self.sidebarHorizontalSeparator + } + .accessibilityElement(children: .combine) + .accessibilityLabel("OpenClaw \(self.sidebarGatewayStatusTitle)") + } + + private var sidebarGatewayStatusTitle: String { + switch self.gatewayStatus { + case .connected: + "Online" + case .connecting: + "Connecting" + case .error: + "Needs attention" + case .disconnected: + "Offline" + } + } + + private var sidebarList: some View { + List { + ForEach(Self.sidebarGroups) { group in + Section(group.title.capitalized) { + ForEach(group.destinations) { destination in + self.sidebarDestinationButton(destination) + } + } + .listSectionSeparator(.hidden, edges: .all) + } + } + .listStyle(.sidebar) + .tint(OpenClawBrand.accent) + .scrollContentBackground(.hidden) + .background(Color(uiColor: .systemBackground)) + } + + private var sidebarFooter: some View { + VStack(spacing: 0) { + self.sidebarHorizontalSeparator + HStack(spacing: 10) { + Text("VERSION") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer(minLength: 8) + Text("v\(DeviceInfoHelper.openClawVersionString())") + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.72) + ProStatusDot(color: self.sidebarGatewayStatusColor) + } + .padding(.horizontal, 18) + .padding(.vertical, 12) + } + } + + private var sidebarHorizontalSeparator: some View { + Rectangle() + .fill(Color(uiColor: .separator)) + .frame(height: 1 / UIScreen.main.scale) + } + + private var sidebarVerticalSeparator: some View { + Rectangle() + .fill(Color(uiColor: .separator)) + .frame(width: 1 / UIScreen.main.scale) + } + + private var sidebarGatewayStatusColor: Color { + switch self.gatewayStatus { + case .connected: + OpenClawBrand.ok + case .connecting: + OpenClawBrand.accent + case .error: + OpenClawBrand.warn + case .disconnected: + .secondary + } + } + + private func sidebarDestinationButton( + _ destination: SidebarDestination, + title: String? = nil) -> some View + { + Button { + self.selectSidebarDestination(destination) + } label: { + Label(title ?? destination.sidebarTitle, systemImage: destination.systemImage) + .lineLimit(1) + .minimumScaleFactor(0.82) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .foregroundStyle(destination == self.selectedSidebarDestination ? OpenClawBrand.accent : .primary) + .listRowBackground( + destination == self.selectedSidebarDestination + ? OpenClawBrand.accent.opacity(0.12) + : Color.clear) + .listRowSeparator(.hidden, edges: .all) + } + + @ViewBuilder + private var sidebarDetail: some View { + switch self.selectedSidebarDestination { + case .chat: + ChatProTab( + headerLeadingAction: self.sidebarHeaderLeadingAction, + headerTitle: "Chat", + headerSubtitle: "Agent conversation", + showsAgentBadge: false, + openSettings: { self.selectSidebarDestination(.gateway) }) + case .talk: + TalkProTab( + headerLeadingAction: self.sidebarHeaderLeadingAction, + openSettings: { self.selectSidebarDestination(.gateway) }) + case .overview: + CommandCenterTab( + headerTitle: "Overview", + headerLeadingAction: self.sidebarHeaderLeadingAction, + showsHeaderMark: false, + openChat: { self.selectSidebarDestination(.chat) }, + openSettings: { self.selectSidebarDestination(.gateway) }) + case .activity: + IPadActivityScreen( + headerLeadingAction: self.sidebarHeaderLeadingAction, + openChat: { self.selectSidebarDestination(.chat) }, + openSettings: { self.selectSidebarDestination(.gateway) }) + case .workboard: + IPadWorkboardScreen( + headerLeadingAction: self.sidebarHeaderLeadingAction, + openChat: { self.selectSidebarDestination(.chat) }, + openSettings: { self.selectSidebarDestination(.gateway) }) + case .skillWorkshop: + IPadSkillWorkshopScreen( + headerLeadingAction: self.sidebarHeaderLeadingAction, + openSettings: { self.selectSidebarDestination(.gateway) }) + case .agents: + AgentProTab( + directRoute: .agents, + headerLeadingAction: self.sidebarHeaderLeadingAction, + headerTitle: "Agents", + openSettings: { self.selectSidebarDestination(.gateway) }) + .id(self.selectedSidebarDestination.id) + case .instances: + AgentProTab( + directRoute: .instances, + headerLeadingAction: self.sidebarHeaderLeadingAction, + headerTitle: "Instances", + openSettings: { self.selectSidebarDestination(.gateway) }) + .id(self.selectedSidebarDestination.id) + case .sessions: + CommandSessionsScreen( + headerLeadingAction: self.sidebarHeaderLeadingAction, + openChat: { self.selectSidebarDestination(.chat) }) + case .dreaming: + AgentProTab( + directRoute: .dreaming, + headerLeadingAction: self.sidebarHeaderLeadingAction, + headerTitle: "Dreaming", + openSettings: { self.selectSidebarDestination(.gateway) }) + .id(self.selectedSidebarDestination.id) + case .usage: + AgentProTab( + directRoute: .usage, + headerLeadingAction: self.sidebarHeaderLeadingAction, + headerTitle: "Usage", + openSettings: { self.selectSidebarDestination(.gateway) }) + .id(self.selectedSidebarDestination.id) + case .cron: + AgentProTab( + directRoute: .cron, + headerLeadingAction: self.sidebarHeaderLeadingAction, + headerTitle: "Cron Jobs", + openSettings: { self.selectSidebarDestination(.gateway) }) + .id(self.selectedSidebarDestination.id) + case .docs: + OpenClawDocsScreen( + headerLeadingAction: self.sidebarHeaderLeadingAction, + gatewayAction: { self.selectSidebarDestination(.gateway) }) + case .settings: + SettingsProTab(headerLeadingAction: self.sidebarHeaderLeadingAction) + case .gateway: + SettingsProTab( + directRoute: self.selectedSidebarDestination.settingsRoute ?? .gateway, + headerLeadingAction: self.sidebarHeaderLeadingAction) + } + } + + private var sidebarDetailNavigationShell: some View { + NavigationStack { + self.sidebarDetailShell + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .clipped() + } + + private var usesSidebarTabs: Bool { + Self.shouldUseSidebarTabs( + idiom: self.userInterfaceIdiom, + horizontalSizeClass: self.horizontalSizeClass) + } + + private var userInterfaceIdiom: UIUserInterfaceIdiom { + if let userInterfaceIdiomOverride { + return userInterfaceIdiomOverride + } + return UIDevice.current.userInterfaceIdiom + } + + private var shouldCollapseSidebarAfterSelection: Bool { + Self.shouldCollapseSidebarAfterSelection( + layoutMode: self.isSidebarDrawerLayout ? .drawer : .split) + } + + private var sidebarHeaderLeadingAction: OpenClawSidebarHeaderAction? { + guard Self.shouldShowSidebarRevealInDestinationHeader( + isSidebarVisible: self.isSidebarVisible, + layoutMode: self.isSidebarDrawerLayout ? .drawer : .split) + else { + return nil + } + if self.isSidebarVisible { + return OpenClawSidebarHeaderAction( + systemName: "sidebar.left", + accessibilityLabel: "Hide Sidebar", + accessibilityIdentifier: Self.sidebarHideButtonAccessibilityIdentifier, + action: { self.hideSidebar() }) + } + return OpenClawSidebarHeaderAction( + systemName: "sidebar.left", + accessibilityLabel: "Show Sidebar", + accessibilityIdentifier: Self.sidebarShowButtonAccessibilityIdentifier, + action: { self.showSidebar() }) + } + + private var sidebarHideButton: some View { + Button { + self.hideSidebar() + } label: { + Image(systemName: self.isSidebarDrawerLayout ? "xmark" : "sidebar.left") + .font(.system(size: 15, weight: .semibold)) + } + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + .buttonStyle(.plain) + .foregroundStyle(OpenClawBrand.accent) + .accessibilityLabel("Hide Sidebar") + .accessibilityIdentifier(Self.sidebarHideButtonAccessibilityIdentifier) + } + + private func shouldUseSidebarDrawer(containerSize: CGSize) -> Bool { + Self.sidebarLayoutMode(containerSize: containerSize) == .drawer + } + + private func sidebarWidth(containerWidth: CGFloat, isDrawerLayout: Bool) -> CGFloat { + Self.sidebarWidth(containerWidth: containerWidth, isDrawerLayout: isDrawerLayout) + } + private func rootOverlays(_ content: some View) -> some View { content .overlay(alignment: .top) { @@ -195,6 +569,7 @@ struct RootTabs: View { .transition(.move(edge: .top).combined(with: .opacity)) } } + .overlay { if self.appModel.cameraFlashNonce != 0 { RootCameraFlashOverlay(nonce: self.appModel.cameraFlashNonce) @@ -298,7 +673,6 @@ struct RootTabs: View { .onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() } .onChange(of: self.appModel.gatewayServerName) { _, newValue in if newValue != nil { - self.showOnboarding = false self.onboardingComplete = true self.hasConnectedOnce = true OnboardingStateStore.markCompleted(mode: nil) @@ -326,7 +700,7 @@ struct RootTabs: View { self.evaluateOnboardingPresentation(force: true) } .onChange(of: self.appModel.openChatRequestID) { _, _ in - self.selectedTab = .chat + self.selectSidebarDestination(.chat) } .onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in self.maybeOpenSettingsForGatewaySetup() @@ -335,10 +709,6 @@ struct RootTabs: View { private func rootPresentation(_ content: some View) -> some View { content - .gatewayActionsDialog( - isPresented: self.$showGatewayActions, - onDisconnect: { self.appModel.disconnectGateway() }, - onOpenSettings: { self.selectedTab = .settings }) .sheet(isPresented: self.$showGatewayProblemDetails) { if let gatewayProblem = self.appModel.lastGatewayProblem { GatewayProblemDetailsSheet( @@ -505,6 +875,50 @@ struct RootTabs: View { self.normalized(agent.name) ?? agent.id } + private func selectSidebarDestination(_ destination: SidebarDestination) { + self.selectedSidebarDestination = destination + self.selectedTab = destination.appTab + guard self.usesSidebarTabs, self.shouldCollapseSidebarAfterSelection else { return } + withAnimation(.easeInOut(duration: 0.22)) { + self.setSidebarVisible(false) + } + } + + private func showSidebar() { + self.sidebarVisibilityUserOverridden = true + withAnimation(.easeInOut(duration: 0.22)) { + self.setSidebarVisible(true) + } + } + + private func hideSidebar() { + self.sidebarVisibilityUserOverridden = true + withAnimation(.easeInOut(duration: 0.22)) { + self.setSidebarVisible(false) + } + } + + private func updateSidebarLayout(containerSize: CGSize, force: Bool) { + let layoutMode = Self.sidebarLayoutMode(containerSize: containerSize) + let previousLayoutMode: SidebarLayoutMode = self.isSidebarDrawerLayout ? .drawer : .split + let didResolvePreviousLayout = self.didResolveSidebarLayout + let layoutModeDidChange = layoutMode != previousLayoutMode + self.didResolveSidebarLayout = true + self.isSidebarDrawerLayout = layoutMode == .drawer + if layoutModeDidChange && didResolvePreviousLayout { + self.sidebarVisibilityUserOverridden = false + } + guard force || !self.sidebarVisibilityUserOverridden else { return } + + let preferredVisibility = Self.preferredSidebarVisibility(layoutMode: layoutMode) + guard self.isSidebarVisible != preferredVisibility else { return } + self.setSidebarVisible(preferredVisibility) + } + + private func setSidebarVisible(_ isVisible: Bool) { + self.isSidebarVisible = isVisible + } + private func homeCanvasBadge(for agent: AgentSummary) -> String { if let identity = agent.identity, let emoji = identity["emoji"]?.value as? String, @@ -539,7 +953,7 @@ struct RootTabs: View { } else if problem.retryable { Task { await self.gatewayController.connectLastKnown() } } else { - self.selectedTab = .settings + self.selectSidebarDestination(.gateway) } } @@ -566,7 +980,7 @@ struct RootTabs: View { self.showOnboarding = true case .settings: self.didAutoOpenSettings = true - self.selectedTab = .settings + self.selectSidebarDestination(.gateway) } } @@ -592,7 +1006,7 @@ struct RootTabs: View { shouldPresentOnLaunch: false) guard route == .settings else { return } self.didAutoOpenSettings = true - self.selectedTab = .settings + self.selectSidebarDestination(.gateway) } private func maybeOpenSettingsForGatewaySetup() { @@ -602,7 +1016,7 @@ struct RootTabs: View { self.showOnboarding = false self.presentedSheet = nil self.didAutoOpenSettings = true - self.selectedTab = .settings + self.selectSidebarDestination(.gateway) } private func applyInitialChatSessionIfNeeded() { @@ -682,3 +1096,120 @@ private struct RootCameraFlashOverlay: View { } } } + +extension EnvironmentValues { + @Entry var rootTabsUserInterfaceIdiomOverride: UIUserInterfaceIdiom? +} + +#if DEBUG +#Preview( + "Shell iPhone portrait", + traits: .fixedLayout(width: 393, height: 852), + .portrait) +{ + RootTabsPreviewHost(idiom: .phone) +} + +#Preview( + "Shell iPhone connected", + traits: .fixedLayout(width: 393, height: 852), + .portrait) +{ + RootTabsPreviewHost(idiom: .phone, gatewayState: .connected) +} + +#Preview( + "Shell iPhone gateway error", + traits: .fixedLayout(width: 393, height: 852), + .portrait) +{ + RootTabsPreviewHost(idiom: .phone, gatewayState: .error) +} + +#Preview( + "Shell iPhone landscape", + traits: .fixedLayout(width: 852, height: 393), + .landscapeLeft) +{ + RootTabsPreviewHost(idiom: .phone) + .environment(\.horizontalSizeClass, .regular) + .environment(\.verticalSizeClass, .compact) +} + +#Preview( + "Shell iPad portrait drawer", + traits: .fixedLayout(width: 1024, height: 1366), + .portrait) +{ + RootTabsPreviewHost(idiom: .pad) +} + +#Preview( + "Shell iPad landscape split", + traits: .fixedLayout(width: 1366, height: 1024), + .landscapeLeft) +{ + RootTabsPreviewHost(idiom: .pad, gatewayState: .connected) +} + +#Preview( + "Shell iPad connecting", + traits: .fixedLayout(width: 1366, height: 1024), + .landscapeLeft) +{ + RootTabsPreviewHost(idiom: .pad, gatewayState: .connecting) +} + +#Preview( + "Shell iPad gateway error", + traits: .fixedLayout(width: 1366, height: 1024), + .landscapeLeft) +{ + RootTabsPreviewHost(idiom: .pad, gatewayState: .error) +} + +private struct RootTabsPreviewHost: View { + @State private var appModel: NodeAppModel + @State private var gatewayController: GatewayConnectionController + private let idiom: UIUserInterfaceIdiom + + init(idiom: UIUserInterfaceIdiom, gatewayState: RootTabsPreviewGatewayState = .offline) { + let appModel = NodeAppModel() + gatewayState.apply(to: appModel) + self.idiom = idiom + _appModel = State(initialValue: appModel) + _gatewayController = State( + initialValue: GatewayConnectionController(appModel: appModel, startDiscovery: false)) + } + + var body: some View { + RootTabs() + .environment(self.appModel) + .environment(self.appModel.voiceWake) + .environment(self.gatewayController) + .environment(\.rootTabsUserInterfaceIdiomOverride, self.idiom) + } +} + +private enum RootTabsPreviewGatewayState { + case offline + case connecting + case connected + case error + + @MainActor + func apply(to appModel: NodeAppModel) { + switch self { + case .offline: + break + case .connecting: + appModel.gatewayStatusText = "Connecting..." + case .connected: + appModel.enterAppleReviewDemoMode() + case .error: + appModel.gatewayStatusText = "Gateway error: connection refused" + } + } +} + +#endif diff --git a/apps/ios/Sources/RootTabsNavigation.swift b/apps/ios/Sources/RootTabsNavigation.swift new file mode 100644 index 000000000000..3686a9e24602 --- /dev/null +++ b/apps/ios/Sources/RootTabsNavigation.swift @@ -0,0 +1,307 @@ +import CoreGraphics +import Foundation +import SwiftUI + +extension RootTabs { + private static var sidebarPersistentWidthThreshold: CGFloat { + 980 + } + + static let sidebarSplitIdealWidth: CGFloat = 316 + static let sidebarSplitMaximumWidth: CGFloat = 340 + static let sidebarDrawerMaximumWidth: CGFloat = 340 + static let sidebarShowButtonAccessibilityIdentifier = "RootTabs.Sidebar.Show" + static let sidebarHideButtonAccessibilityIdentifier = "RootTabs.Sidebar.Hide" + + enum AppTab: Hashable { + case control + case chat + case talk + case agent + case settings + } + + enum SidebarDestination: String, CaseIterable, Hashable, Identifiable { + case chat + case talk + case overview + case activity + case agents + case workboard + case skillWorkshop + case instances + case sessions + case dreaming + case usage + case cron + case docs + case settings + case gateway + + var id: String { + rawValue + } + + var title: String { + switch self { + case .chat: "Chat" + case .talk: "Talk" + case .overview: "Overview" + case .activity: "Activity" + case .agents: "Agents" + case .workboard: "Workboard" + case .skillWorkshop: "Skill Workshop" + case .instances: "Instances" + case .sessions: "Sessions" + case .dreaming: "Dreaming" + case .usage: "Usage" + case .cron: "Cron Jobs" + case .docs: "Docs" + case .settings: "Settings" + case .gateway: "Settings / Gateway" + } + } + + var sidebarTitle: String { + switch self { + case .gateway: "Connection" + default: self.title + } + } + + var subtitle: String { + switch self { + case .chat: "Agent chat and recent work." + case .talk: "Realtime voice and fallback controls." + case .overview: "Status, entry points, health." + case .activity: "Gateway, session, and device activity." + case .agents: "Agent roster and readiness." + case .workboard: "Agent work queue and session handoff." + case .skillWorkshop: "Review and apply proposed skills." + case .instances: "Latest presence from OpenClaw nodes." + case .sessions: "Active sessions and defaults." + case .dreaming: "Memory signals and background synthesis." + case .usage: "API usage and costs." + case .cron: "Wakeups and recurring runs." + case .docs: "Reference docs and setup guides." + case .settings: "Connection, permissions, channels, and app options." + case .gateway: "Pairing, diagnostics, permissions, and device controls." + } + } + + var systemImage: String { + switch self { + case .chat: "bubble.left" + case .talk: "waveform.circle" + case .overview: "chart.bar" + case .activity: "waveform.path.ecg" + case .agents: "person.2" + case .workboard: "folder" + case .skillWorkshop: "hammer" + case .instances: "dot.radiowaves.left.and.right" + case .sessions: "doc.text" + case .dreaming: "moon.stars" + case .usage: "chart.bar.xaxis" + case .cron: "timer" + case .docs: "book" + case .settings: "gearshape" + case .gateway: "gearshape" + } + } + + var appTab: AppTab { + switch self { + case .chat: + .chat + case .talk: + .talk + case .agents: + .agent + case .settings, .gateway: + .settings + case .overview, .activity, .workboard, .skillWorkshop, .instances, .sessions, .dreaming, + .usage, + .cron, .docs: + .control + } + } + + var settingsRoute: SettingsRoute? { + switch self { + case .gateway: + .gateway + case .chat, .talk, .overview, .activity, .agents, .workboard, .skillWorkshop, .instances, .sessions, + .dreaming, + .usage, .cron, .settings, .docs: + nil + } + } + } + + enum SidebarLayoutMode: Equatable { + case drawer + case split + } + + static func sidebarLayoutMode(containerSize: CGSize) -> SidebarLayoutMode { + containerSize.width < self.sidebarPersistentWidthThreshold || containerSize.height > containerSize.width + ? .drawer + : .split + } + + static func preferredSidebarVisibility(layoutMode: SidebarLayoutMode) -> Bool { + layoutMode == .split + } + + static func shouldCollapseSidebarAfterSelection(layoutMode: SidebarLayoutMode) -> Bool { + layoutMode == .drawer + } + + static func sidebarWidth(containerWidth: CGFloat, isDrawerLayout: Bool) -> CGFloat { + if isDrawerLayout { + return min(self.sidebarDrawerMaximumWidth, max(280, containerWidth * 0.86)) + } + return min(self.sidebarSplitMaximumWidth, max(self.sidebarSplitIdealWidth, containerWidth * 0.25)) + } + + static func shouldShowSidebarRevealControl(isSidebarVisible: Bool) -> Bool { + !isSidebarVisible + } + + static func shouldShowSidebarRevealInDestinationHeader( + isSidebarVisible: Bool, + layoutMode: SidebarLayoutMode) -> Bool + { + switch layoutMode { + case .split: + true + case .drawer: + self.shouldShowSidebarRevealControl(isSidebarVisible: isSidebarVisible) + } + } + + static func requestedInitialSidebarVisibility(arguments: [String]) -> Bool? { + guard let flagIndex = arguments.firstIndex(of: "--openclaw-sidebar-visibility") else { + return nil + } + let valueIndex = arguments.index(after: flagIndex) + guard arguments.indices.contains(valueIndex) else { return nil } + + switch arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "visible", "show", "shown", "open", "true", "1": + return true + case "hidden", "hide", "closed", "false", "0": + return false + default: + return nil + } + } + + static func shouldOpenRootTabFromPhoneHub(_ destination: SidebarDestination) -> Bool { + switch destination { + case .chat, .talk, .agents, .gateway, .settings: + true + case .overview, .activity, .workboard, .skillWorkshop, .instances, .sessions, .dreaming, + .usage, + .cron, .docs: + false + } + } + + static func defaultSidebarDestination(for tab: AppTab) -> SidebarDestination { + switch tab { + case .control: + .overview + case .chat: + .chat + case .talk: + .talk + case .agent: + .agents + case .settings: + .settings + } + } + + enum StartupPresentationRoute: Equatable { + case none + case onboarding + case settings + } + + static func startupPresentationRoute( + gatewayConnected: Bool, + hasConnectedOnce: Bool, + onboardingComplete: Bool, + hasExistingGatewayConfig: Bool, + shouldPresentOnLaunch: Bool) -> StartupPresentationRoute + { + if gatewayConnected { + return .none + } + if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete { + return .onboarding + } + if !hasExistingGatewayConfig { + return .settings + } + return .none + } + + static func shouldPresentQuickSetup( + quickSetupDismissed: Bool, + showOnboarding: Bool, + hasPresentedSheet: Bool, + gatewayConnected: Bool, + hasExistingGatewayConfig: Bool, + discoveredGatewayCount: Int) -> Bool + { + guard !quickSetupDismissed else { return false } + guard !showOnboarding else { return false } + guard !hasPresentedSheet else { return false } + guard !gatewayConnected else { return false } + guard !hasExistingGatewayConfig else { return false } + return discoveredGatewayCount > 0 + } + + struct SidebarGroup: Identifiable { + let title: String + let destinations: [SidebarDestination] + + var id: String { + self.title + } + } + + static let sidebarGroups: [SidebarGroup] = [ + SidebarGroup(title: "CHAT", destinations: [.chat, .talk]), + SidebarGroup( + title: "CONTROL", + destinations: [ + .overview, + .activity, + .agents, + .workboard, + .skillWorkshop, + .instances, + .sessions, + .dreaming, + .usage, + .cron, + ]), + SidebarGroup( + title: "SETTINGS", + destinations: [.settings]), + SidebarGroup(title: "REFERENCE", destinations: [.docs]), + ] + + static var phoneControlGroups: [SidebarGroup] { + self.sidebarGroups + .map { group in + SidebarGroup( + title: group.title, + destinations: group.destinations.filter { $0 != .agents }) + } + .filter { !$0.destinations.isEmpty } + } +} diff --git a/apps/ios/Sources/RootView.swift b/apps/ios/Sources/RootView.swift deleted file mode 100644 index d0cfcb9025b3..000000000000 --- a/apps/ios/Sources/RootView.swift +++ /dev/null @@ -1,15 +0,0 @@ -import SwiftUI - -struct RootView: View { - @AppStorage(AppAppearancePreference.storageKey) private var appearancePreferenceRaw: String = - AppAppearancePreference.system.rawValue - - var body: some View { - RootTabs() - .preferredColorScheme(self.appearancePreference.colorScheme) - } - - private var appearancePreference: AppAppearancePreference { - AppAppearancePreference(rawValue: self.appearancePreferenceRaw) ?? .system - } -} diff --git a/apps/ios/Sources/Screen/ScreenController.swift b/apps/ios/Sources/Screen/ScreenController.swift index 6cb60a973188..24dc81ac88fa 100644 --- a/apps/ios/Sources/Screen/ScreenController.swift +++ b/apps/ios/Sources/Screen/ScreenController.swift @@ -181,16 +181,6 @@ final class ScreenController { return try await WebViewJavaScriptSupport.evaluateToString(webView: webView, javaScript: javaScript) } - func snapshotPNGBase64(maxWidth: CGFloat? = nil) async throws -> String { - let image = try await self.snapshotImage(maxWidth: maxWidth) - guard let data = image.pngData() else { - throw NSError(domain: "Screen", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "snapshot encode failed", - ]) - } - return data.base64EncodedString() - } - func snapshotBase64( maxWidth: CGFloat? = nil, format: OpenClawCanvasSnapshotFormat, diff --git a/apps/ios/Sources/Services/NodeServiceProtocols.swift b/apps/ios/Sources/Services/NodeServiceProtocols.swift index c750e98a5c71..e2095973d369 100644 --- a/apps/ios/Sources/Services/NodeServiceProtocols.swift +++ b/apps/ios/Sources/Services/NodeServiceProtocols.swift @@ -31,12 +31,7 @@ protocol LocationServicing: Sendable { desiredAccuracy: OpenClawLocationAccuracy, maxAgeMs: Int?, timeoutMs: Int?) async throws -> CLLocation - func startLocationUpdates( - desiredAccuracy: OpenClawLocationAccuracy, - significantChangesOnly: Bool) -> AsyncStream - func stopLocationUpdates() func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void) - func stopMonitoringSignificantLocationChanges() } @MainActor diff --git a/apps/ios/Sources/SessionKey.swift b/apps/ios/Sources/SessionKey.swift index 89798b6a2931..62ee695a010b 100644 --- a/apps/ios/Sources/SessionKey.swift +++ b/apps/ios/Sources/SessionKey.swift @@ -14,10 +14,12 @@ enum SessionKey { return "agent:\(trimmedAgent):\(normalizedBase)" } - static func isCanonicalMainSessionKey(_ value: String?) -> Bool { - let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return false } - if trimmed == "global" { return true } - return trimmed.hasPrefix("agent:") + static func agentId(from value: String?) -> String? { + let parts = (value ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: ":", omittingEmptySubsequences: false) + guard parts.count >= 3, parts[0].lowercased() == "agent" else { return nil } + let agentId = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines) + return agentId.isEmpty ? nil : agentId } } diff --git a/apps/ios/Sources/Settings/SettingsNetworkingHelpers.swift b/apps/ios/Sources/Settings/SettingsNetworkingHelpers.swift deleted file mode 100644 index f061ff9a2045..000000000000 --- a/apps/ios/Sources/Settings/SettingsNetworkingHelpers.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation - -struct SettingsHostPort: Equatable { - var host: String - var port: Int -} - -enum SettingsNetworkingHelpers { - static func parseHostPort(from address: String) -> SettingsHostPort? { - let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - if trimmed.hasPrefix("["), - let close = trimmed.firstIndex(of: "]"), - close < trimmed.endIndex - { - let host = String(trimmed[trimmed.index(after: trimmed.startIndex).. String { - if let host, let port { - let needsBrackets = host.contains(":") && !host.hasPrefix("[") && !host.hasSuffix("]") - let hostPart = needsBrackets ? "[\(host)]" : host - return "http://\(hostPart):\(port)" - } - return "http://\(fallback)" - } -} diff --git a/apps/ios/Sources/Status/GatewayActionsDialog.swift b/apps/ios/Sources/Status/GatewayActionsDialog.swift deleted file mode 100644 index 8c1ec42f3b83..000000000000 --- a/apps/ios/Sources/Status/GatewayActionsDialog.swift +++ /dev/null @@ -1,25 +0,0 @@ -import SwiftUI - -extension View { - func gatewayActionsDialog( - isPresented: Binding, - onDisconnect: @escaping () -> Void, - onOpenSettings: @escaping () -> Void) -> some View - { - self.confirmationDialog( - "Gateway", - isPresented: isPresented, - titleVisibility: .visible) - { - Button("Disconnect", role: .destructive) { - onDisconnect() - } - Button("Open Settings") { - onOpenSettings() - } - Button("Cancel", role: .cancel) {} - } message: { - Text("Disconnect from the gateway?") - } - } -} diff --git a/apps/ios/Sources/Voice/RealtimeTalkRelaySession.swift b/apps/ios/Sources/Voice/RealtimeTalkRelaySession.swift index 93ca818a3711..f8df5bc4882a 100644 --- a/apps/ios/Sources/Voice/RealtimeTalkRelaySession.swift +++ b/apps/ios/Sources/Voice/RealtimeTalkRelaySession.swift @@ -103,6 +103,12 @@ final class RealtimeTalkRelaySession { let failed: Bool } + private enum StartupWaitResult { + case ready + case failed(TalkRuntimeIssue) + case cancelled + } + private nonisolated static let expectedInputEncoding = "pcm16" private nonisolated static let expectedOutputEncoding = "pcm16" private nonisolated static let defaultSampleRateHz = 24000 @@ -110,16 +116,23 @@ final class RealtimeTalkRelaySession { private nonisolated static let bargeInRmsThreshold: Float = 0.08 private nonisolated static let bargeInCooldownMs: Double = 900 private nonisolated static let minOutputBeforeBargeInMs: Double = 250 + private nonisolated static let startupReadyTimeoutSeconds = 12 private let gateway: GatewayNodeSession private let options: Options private let pcmPlayer: PCMStreamingAudioPlaying private let logger = Logger(subsystem: "ai.openclaw", category: "RealtimeTalkRelay") private let onStatus: (String) -> Void + private let onIssue: (TalkRuntimeIssue) -> Void private let onSpeakingChanged: (Bool) -> Void private let audioEngine = AVAudioEngine() private var relaySessionId: String? + private var hasReceivedReady = false + private var hasReceivedFailure = false + private var startupIssue: TalkRuntimeIssue? + private var startupWaiter: CheckedContinuation? + private var pendingPreRelayEvents: [EventFrame] = [] private var inputSampleRateHz = Double(RealtimeTalkRelaySession.defaultSampleRateHz) private var outputSampleRateHz = Double(RealtimeTalkRelaySession.defaultSampleRateHz) private var eventTask: Task? @@ -151,34 +164,53 @@ final class RealtimeTalkRelaySession { options: Options, pcmPlayer: PCMStreamingAudioPlaying, onStatus: @escaping (String) -> Void, + onIssue: @escaping (TalkRuntimeIssue) -> Void = { _ in }, onSpeakingChanged: @escaping (Bool) -> Void) { self.gateway = gateway self.options = options self.pcmPlayer = pcmPlayer self.onStatus = onStatus + self.onIssue = onIssue self.onSpeakingChanged = onSpeakingChanged } func start() async throws { self.isClosed = false + self.hasReceivedReady = false + self.hasReceivedFailure = false + self.startupIssue = nil + self.startupWaiter = nil + self.pendingPreRelayEvents.removeAll() self.onStatus("Connecting realtime…") - let result = try await self.createRelaySession() - guard let relaySessionId = result.relaysessionid?.trimmingCharacters(in: .whitespacesAndNewlines), - !relaySessionId.isEmpty - else { - throw NSError(domain: "RealtimeTalkRelay", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Gateway did not return a realtime relay session", - ]) - } - self.relaySessionId = relaySessionId + let eventStream = await self.gateway.subscribeServerEvents(bufferingNewest: 200) + self.startEventPump(stream: eventStream) do { + let result = try await self.createRelaySession() + guard let relaySessionId = result.relaysessionid?.trimmingCharacters(in: .whitespacesAndNewlines), + !relaySessionId.isEmpty + else { + throw NSError(domain: "RealtimeTalkRelay", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Gateway did not return a realtime relay session", + ]) + } + self.relaySessionId = relaySessionId self.audioSender = RealtimeAudioSender(gateway: self.gateway, relaySessionId: relaySessionId) - let eventStream = await self.gateway.subscribeServerEvents(bufferingNewest: 200) - self.startEventPump(stream: eventStream) self.configureAudioContract(result.audio) try self.startMicrophonePump() - self.onStatus("Listening (Realtime)") + self.onStatus("Waiting for realtime…") + await self.drainPendingPreRelayEvents() + switch await self.waitForStartupResult(timeoutSeconds: Self.startupReadyTimeoutSeconds) { + case .ready: + return + case let .failed(issue): + self.close(sendClose: true) + throw NSError(domain: "RealtimeTalkRelay", code: 6, userInfo: [ + NSLocalizedDescriptionKey: issue.displayMessage, + ]) + case .cancelled: + return + } } catch { let createdRelaySessionId = self.relaySessionId self.close(sendClose: false) @@ -196,6 +228,7 @@ final class RealtimeTalkRelaySession { private func close(sendClose: Bool) { guard !self.isClosed else { return } self.isClosed = true + self.finishStartupWait(.cancelled) self.stopMicrophonePump() self.eventTask?.cancel() self.eventTask = nil @@ -299,14 +332,21 @@ final class RealtimeTalkRelaySession { guard event.event == "talk.event", let payload = event.payload?.dictionaryValue else { return } - if let relaySessionId, - payload["relaySessionId"]?.stringValue != relaySessionId - { + guard let relaySessionId else { + self.pendingPreRelayEvents.append(event) + if self.pendingPreRelayEvents.count > 200 { + self.pendingPreRelayEvents.removeFirst(self.pendingPreRelayEvents.count - 200) + } + return + } + if payload["relaySessionId"]?.stringValue != relaySessionId { return } guard let type = payload["type"]?.stringValue else { return } switch type { case "ready": + self.hasReceivedReady = true + self.finishStartupWait(.ready) self.onStatus("Listening (Realtime)") case "audio": guard let base64 = payload["audioBase64"]?.stringValue, @@ -331,17 +371,107 @@ final class RealtimeTalkRelaySession { await self.handleToolCall(payload) case "error": let message = payload["message"]?.stringValue ?? "Realtime failed" + let issue = Self.issue( + payload: payload, + fallbackMessage: message, + fallbackProvider: self.options.provider, + fallbackModel: self.options.model) GatewayDiagnostics.log("talk realtime: error=\(Self.safeLogMessage(message))") + self.hasReceivedFailure = true + self.startupIssue = issue + self.onIssue(issue) + self.finishStartupWait(.failed(issue)) self.onStatus(message) case "close": GatewayDiagnostics.log("talk realtime: close") - self.onStatus("Ready") + if self.hasReceivedReady { + self.onStatus("Ready") + } else if !self.hasReceivedFailure { + let issue = TalkRuntimeIssue( + code: .realtimeUnavailable, + message: "Realtime closed before it became ready.", + provider: self.options.provider, + model: self.options.model, + transport: "gateway-relay", + phase: "connect") + self.onIssue(issue) + self.startupIssue = issue + self.finishStartupWait(.failed(issue)) + self.onStatus("Realtime failed before connecting") + } self.close(sendClose: false) default: return } } + private func waitForStartupResult(timeoutSeconds: Int) async -> StartupWaitResult { + if self.isClosed { return .cancelled } + if self.hasReceivedReady { return .ready } + if let startupIssue { return .failed(startupIssue) } + return await withCheckedContinuation { continuation in + if self.isClosed { + continuation.resume(returning: .cancelled) + return + } + self.startupWaiter = continuation + Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(max(0, timeoutSeconds)) * 1_000_000_000) + await self?.timeoutStartupWaiterIfNeeded() + } + } + } + + private func drainPendingPreRelayEvents() async { + let pendingEvents = self.pendingPreRelayEvents + self.pendingPreRelayEvents.removeAll() + for event in pendingEvents { + await self.handleGatewayEvent(event) + } + } + + private func finishStartupWait(_ result: StartupWaitResult) { + guard let waiter = self.startupWaiter else { return } + self.startupWaiter = nil + waiter.resume(returning: result) + } + + private func timeoutStartupWaiterIfNeeded() { + guard !self.isClosed, self.startupWaiter != nil, !self.hasReceivedReady, self.startupIssue == nil else { + return + } + let issue = TalkRuntimeIssue( + code: .realtimeUnavailable, + message: "Realtime did not become ready in time.", + provider: self.options.provider, + model: self.options.model, + transport: "gateway-relay", + phase: "connect") + self.hasReceivedFailure = true + self.startupIssue = issue + self.onIssue(issue) + self.onStatus(issue.displayMessage) + self.finishStartupWait(.failed(issue)) + } + + private static func issue( + payload: [String: AnyCodable], + fallbackMessage: String, + fallbackProvider: String?, + fallbackModel: String?) -> TalkRuntimeIssue + { + let provider = payload["provider"]?.stringValue ?? fallbackProvider + let model = payload["model"]?.stringValue ?? fallbackModel + let transport = payload["transport"]?.stringValue ?? "gateway-relay" + let phase = payload["phase"]?.stringValue + return TalkRuntimeIssue.realtimeUnavailable( + message: fallbackMessage, + provider: provider, + model: model, + transport: transport, + phase: phase) + } + private func recordOutputAudioChunk(byteCount: Int) { self.outputAudioChunkCount += 1 self.outputAudioByteCount += byteCount @@ -804,6 +934,25 @@ final class RealtimeTalkRelaySession { } extension RealtimeTalkRelaySession { + func _test_setRelaySessionId(_ relaySessionId: String) { + self.relaySessionId = relaySessionId + } + + func _test_handleGatewayEvent(_ event: EventFrame) async { + await self.handleGatewayEvent(event) + } + + func _test_waitForStartupCancelled(timeoutSeconds: Int) async -> Bool { + if case .cancelled = await self.waitForStartupResult(timeoutSeconds: timeoutSeconds) { + return true + } + return false + } + + func _test_startupReadyTimeoutSeconds() -> Int { + Self.startupReadyTimeoutSeconds + } + func _test_markOutputAudioStarted(nowMs: Double) { self.markOutputAudioStarted(byteCount: 4800, nowMs: nowMs) } diff --git a/apps/ios/Sources/Voice/TalkModeGatewayConfig.swift b/apps/ios/Sources/Voice/TalkModeGatewayConfig.swift index 2adf394771a6..3ae355c4fe92 100644 --- a/apps/ios/Sources/Voice/TalkModeGatewayConfig.swift +++ b/apps/ios/Sources/Voice/TalkModeGatewayConfig.swift @@ -3,10 +3,99 @@ import OpenClawKit enum TalkModeExecutionMode { case native - case realtimeClient case realtimeRelay } +struct TalkRuntimeIssue: Equatable { + enum Code: String { + case realtimeUnavailable = "realtime_unavailable" + } + + let code: Code + let message: String + let provider: String? + let model: String? + let transport: String? + let phase: String? + let occurredAt: Date + + init( + code: Code, + message: String, + provider: String? = nil, + model: String? = nil, + transport: String? = nil, + phase: String? = nil, + occurredAt: Date = Date()) + { + self.code = code + self.message = message.trimmingCharacters(in: .whitespacesAndNewlines) + self.provider = provider?.trimmingCharacters(in: .whitespacesAndNewlines) + self.model = model?.trimmingCharacters(in: .whitespacesAndNewlines) + self.transport = transport?.trimmingCharacters(in: .whitespacesAndNewlines) + self.phase = phase?.trimmingCharacters(in: .whitespacesAndNewlines) + self.occurredAt = occurredAt + } + + var displayMessage: String { + if !self.message.isEmpty { return self.message } + return "Realtime voice did not start." + } + + var fallbackStatusText: String { + "Listening (iOS Speech fallback)" + } + + var fallbackBannerTitle: String { + "Using iOS Speech fallback" + } + + var fallbackBannerOwnerLabel: String { + "Fallback active" + } + + var fallbackBannerMessage: String { + "Realtime voice did not start. Talk is running with iOS speech recognition and TTS." + } + + var technicalDetails: String { + var lines = [ + "code: \(self.code.rawValue)", + "message: \(self.displayMessage)", + ] + if let provider, !provider.isEmpty { lines.append("provider: \(provider)") } + if let model, !model.isEmpty { lines.append("model: \(model)") } + if let transport, !transport.isEmpty { lines.append("transport: \(transport)") } + if let phase, !phase.isEmpty { lines.append("phase: \(phase)") } + return lines.joined(separator: "\n") + } + + var diagnosticSummary: String { + var parts = [self.displayMessage] + if let provider, !provider.isEmpty { parts.append("provider: \(provider)") } + if let model, !model.isEmpty { parts.append("model: \(model)") } + if let transport, !transport.isEmpty { parts.append("transport: \(transport)") } + if let phase, !phase.isEmpty { parts.append("phase: \(phase)") } + return parts.joined(separator: " • ") + } + + static func realtimeUnavailable( + message: String, + provider: String? = nil, + model: String? = nil, + transport: String? = nil, + phase: String? = nil) -> TalkRuntimeIssue + { + TalkRuntimeIssue( + code: .realtimeUnavailable, + message: message, + provider: provider, + model: model, + transport: transport, + phase: phase) + } +} + struct TalkVoiceModeDescriptor: Equatable { let title: String let subtitle: String? diff --git a/apps/ios/Sources/Voice/TalkModeManager+Permissions.swift b/apps/ios/Sources/Voice/TalkModeManager+Permissions.swift index a68ee451a273..364a4d7ff371 100644 --- a/apps/ios/Sources/Voice/TalkModeManager+Permissions.swift +++ b/apps/ios/Sources/Voice/TalkModeManager+Permissions.swift @@ -64,22 +64,6 @@ extension TalkModeManager { } } - static func permissionMessage( - kind: String, - status: AVAudioSession.RecordPermission) -> String - { - switch status { - case .denied: - return "\(kind) permission denied" - case .undetermined: - return "\(kind) permission not granted" - case .granted: - return "\(kind) permission denied" - @unknown default: - return "\(kind) permission denied" - } - } - static func permissionMessage( kind: String, status: SFSpeechRecognizerAuthorizationStatus) -> String diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 9b91abee3823..53bcd30595b0 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -60,23 +60,28 @@ final class TalkModeManager: NSObject { var gatewayTalkVoiceModeTitle: String = "Not loaded" var gatewayTalkVoiceModeSubtitle: String? var gatewayTalkVoiceModeAccessibilityValue: String = "Not loaded" + var gatewayTalkActiveModeTitle: String = "Not active" + var gatewayTalkActiveModeSubtitle: String? + var gatewayTalkLastIssueText: String? + var gatewayTalkCurrentFallbackIssue: TalkRuntimeIssue? var gatewayTalkPermissionState: TalkGatewayPermissionState = .unknown var isGatewayConnected: Bool { self.gatewayConnected } - var hasActiveAudioCapture: Bool { - self.isEnabled || self.isListening || self.isPushToTalkActive || self.realtimeRelaySession != nil - || self.realtimeRelayStartInFlight - } - private enum CaptureMode { case idle case continuous case pushToTalk } + private enum RealtimeStartResult { + case started + case unavailable(TalkRuntimeIssue) + case ignored + } + private var isStarting = false private var startAttemptID = 0 private var captureMode: CaptureMode = .idle @@ -129,6 +134,8 @@ final class TalkModeManager: NSObject { voiceId: nil, transport: nil, isRealtime: false) + private var pendingRealtimeIssue: TalkRuntimeIssue? + private var realtimeRelayStartIssue: TalkRuntimeIssue? private var apiKey: String? private var voiceAliases: [String: String] = [:] private var interruptOnSpeech: Bool = true @@ -192,6 +199,8 @@ final class TalkModeManager: NSObject { } } else { self.stopRealtimeSession() + self.gatewayTalkActiveModeTitle = "Not active" + self.gatewayTalkActiveModeSubtitle = nil if self.isEnabled, !self.isSpeaking { self.statusText = "Offline" } @@ -299,11 +308,15 @@ final class TalkModeManager: NSObject { return } if self.realtimeWebRTCEnabled { - let started = self.executionMode == .realtimeRelay + let realtimeStart = self.executionMode == .realtimeRelay ? await self.startRealtimeRelayIfAvailable() : await self.startRealtimeIfAvailable() - if started { + switch realtimeStart { + case .started, .ignored: return + case let .unavailable(issue): + self.pendingRealtimeIssue = issue + self.gatewayTalkLastIssueText = issue.diagnosticSummary } } @@ -324,7 +337,11 @@ final class TalkModeManager: NSObject { self.captureMode = .continuous try self.startRecognition() self.isListening = true - self.statusText = "Listening" + if let issue = self.pendingRealtimeIssue { + self.markNativeFallbackActive(after: issue) + } else { + self.markNativeTalkActive() + } self.startSilenceMonitor() await self.subscribeChatIfNeeded(sessionKey: self.mainSessionKey) self.logger.info("listening") @@ -379,6 +396,11 @@ final class TalkModeManager: NSObject { self.isPushToTalkActive = false self.captureMode = .idle self.statusText = "Off" + self.pendingRealtimeIssue = nil + self.gatewayTalkCurrentFallbackIssue = nil + self.gatewayTalkActiveModeTitle = "Not active" + self.gatewayTalkActiveModeSubtitle = nil + self.gatewayTalkLastIssueText = nil self.lastTranscript = "" self.lastHeard = nil self.silenceTask?.cancel() @@ -425,6 +447,8 @@ final class TalkModeManager: NSObject { self.isPushToTalkActive = false self.captureMode = .idle self.statusText = "Paused" + self.gatewayTalkActiveModeTitle = "Paused" + self.gatewayTalkActiveModeSubtitle = nil self.lastTranscript = "" self.lastHeard = nil self.silenceTask?.cancel() @@ -446,13 +470,6 @@ final class TalkModeManager: NSObject { return wasActive } - func setForegroundAudioCaptureAllowed(_ allowed: Bool) { - self.foregroundAudioCaptureAllowed = allowed - if !allowed { - self.cancelPendingStart() - } - } - func resumeAfterBackground(wasSuspended: Bool, wasKeptActive: Bool = false) async { if wasKeptActive { return } guard wasSuspended else { return } @@ -460,14 +477,6 @@ final class TalkModeManager: NSObject { await self.start() } - func userTappedOrb() { - if let realtimeSession { - realtimeSession.cancelResponse() - } - self.realtimeRelaySession?.cancelOutput() - self.stopSpeaking() - } - func beginPushToTalk() async throws -> OpenClawTalkPTTStartPayload { guard self.gatewayConnected else { self.statusText = "Offline" @@ -1047,8 +1056,10 @@ final class TalkModeManager: NSObject { } } - private func startRealtimeIfAvailable() async -> Bool { - guard let gateway else { return false } + private func startRealtimeIfAvailable() async -> RealtimeStartResult { + guard let gateway else { + return .unavailable(self.realtimeIssue(message: "Gateway not connected", phase: "start")) + } let startedAt = Self.nowSeconds() if self.prefetchedRealtimeSession == nil, let prefetchTask = self.realtimePrefetchTask { GatewayDiagnostics.log("talk.timeline realtime awaiting in-flight prefetch") @@ -1069,49 +1080,53 @@ final class TalkModeManager: NSObject { prefetchedSession: prefetchedSession) guard self.realtimeSession === session, self.isEnabled else { session.stop() - return true + return .ignored } self.isListening = true self.captureMode = .continuous - self.statusText = "Listening" + self.markRealtimeActive() GatewayDiagnostics.log( "talk.timeline realtime start ready elapsedMs=\(Self.elapsedMs(since: startedAt))") GatewayDiagnostics.log("talk realtime: started direct OpenAI WebRTC session") - return true + return .started } catch { guard self.realtimeSession === session, self.isEnabled else { session.stop() - return true + return .ignored } self.stopRealtimeSession() + let issue = self.realtimeIssue(from: error, phase: "start") GatewayDiagnostics .log("talk realtime: unavailable; falling back to speech pipeline error=\(error.localizedDescription)") GatewayDiagnostics.log( "talk.timeline realtime start failed elapsedMs=\(Self.elapsedMs(since: startedAt)) " + "error=\(error.localizedDescription)") - return false + return .unavailable(issue) } } - private func startRealtimeRelayIfAvailable() async -> Bool { - guard let gateway else { return false } + private func startRealtimeRelayIfAvailable() async -> RealtimeStartResult { + guard let gateway else { + return .unavailable(self.realtimeIssue(message: "Gateway not connected", phase: "start")) + } guard self.foregroundAudioCaptureAllowed else { self.statusText = "Paused" GatewayDiagnostics.log("talk realtime ignored: app backgrounded") - return true + return .ignored } if self.realtimeRelaySession != nil { self.captureMode = .continuous self.isListening = true GatewayDiagnostics.log("talk realtime ignored: already active") - return true + return .started } guard !self.realtimeRelayStartInFlight else { GatewayDiagnostics.log("talk realtime ignored: already starting") - return true + return .ignored } self.realtimeRelayStartInFlight = true defer { self.realtimeRelayStartInFlight = false } + self.prepareRealtimeRelayStart() GatewayDiagnostics.log("talk.timeline realtime relay start attempt sessionKey=\(self.mainSessionKey)") let startedAt = Self.nowSeconds() let relaySession = RealtimeTalkRelaySession( @@ -1124,13 +1139,15 @@ final class TalkModeManager: NSObject { pcmPlayer: self.pcmPlayer, onStatus: { [weak self] status in guard let self else { return } - self.statusText = status - self.isListening = status.localizedCaseInsensitiveContains("listening") - if status.localizedCaseInsensitiveContains("thinking") { - self.isListening = false - self.isSpeaking = false - self.isUserSpeechDetected = false - } + self.handleRealtimeRelayStatus(status) + }, + onIssue: { [weak self] issue in + guard let self else { return } + self.realtimeRelayStartIssue = issue + self.pendingRealtimeIssue = issue + self.gatewayTalkLastIssueText = issue.diagnosticSummary + self.gatewayTalkActiveModeTitle = "Realtime unavailable" + self.gatewayTalkActiveModeSubtitle = issue.displayMessage }, onSpeakingChanged: { [weak self] speaking in guard let self else { return } @@ -1145,23 +1162,35 @@ final class TalkModeManager: NSObject { try await relaySession.start() guard self.realtimeRelaySession === relaySession, self.isEnabled else { relaySession.stop() - return true + return .ignored + } + if let issue = self.realtimeRelayStartIssue { + self.realtimeRelaySession = nil + relaySession.stop() + GatewayDiagnostics.log( + "talk.timeline realtime relay start unavailable elapsedMs=\(Self.elapsedMs(since: startedAt)) " + + "issue=\(issue.code.rawValue)") + return .unavailable(issue) } self.isListening = true self.captureMode = .continuous + self.realtimeRelayStartIssue = nil GatewayDiagnostics.log( "talk.timeline realtime relay start ready elapsedMs=\(Self.elapsedMs(since: startedAt))") - return true + return .started } catch { guard self.realtimeRelaySession === relaySession, self.isEnabled else { relaySession.stop() - return true + return .ignored } self.realtimeRelaySession = nil + let issue = self.realtimeRelayStartIssue + ?? self.realtimeIssue(from: error, phase: "start") + self.realtimeRelayStartIssue = nil GatewayDiagnostics.log( "talk.timeline realtime relay start failed elapsedMs=\(Self.elapsedMs(since: startedAt)) " + "error=\(error.localizedDescription)") - return false + return .unavailable(issue) } } @@ -2363,6 +2392,103 @@ extension TalkModeManager { self.gatewayTalkVoiceModeAccessibilityValue = descriptor.accessibilityValue } + private func markRealtimeActive() { + self.pendingRealtimeIssue = nil + self.gatewayTalkCurrentFallbackIssue = nil + self.gatewayTalkLastIssueText = nil + self.gatewayTalkActiveModeTitle = self.configuredVoiceModeDescriptor.title + self.gatewayTalkActiveModeSubtitle = self.configuredVoiceModeDescriptor.subtitle + self.statusText = "Listening (Realtime)" + } + + private func handleRealtimeRelayStatus(_ status: String) { + if status == "Listening (Realtime)" { + self.markRealtimeActive() + } else { + self.statusText = status + if status == "Ready" { + self.realtimeRelaySession = nil + self.gatewayTalkActiveModeTitle = "Not active" + self.gatewayTalkActiveModeSubtitle = nil + self.isListening = false + self.isSpeaking = false + self.isUserSpeechDetected = false + } + } + self.isListening = status.localizedCaseInsensitiveContains("listening") + if status.localizedCaseInsensitiveContains("thinking") { + self.isListening = false + self.isSpeaking = false + self.isUserSpeechDetected = false + } + } + + private func prepareRealtimeRelayStart() { + self.realtimeRelayStartIssue = nil + self.pendingRealtimeIssue = nil + self.gatewayTalkCurrentFallbackIssue = nil + } + + private func markNativeTalkActive() { + self.pendingRealtimeIssue = nil + self.gatewayTalkCurrentFallbackIssue = nil + self.gatewayTalkActiveModeTitle = "iOS Speech + TTS" + self.gatewayTalkActiveModeSubtitle = nil + self.statusText = "Listening" + } + + private func markNativeFallbackActive(after issue: TalkRuntimeIssue) { + self.gatewayTalkActiveModeTitle = "iOS Speech fallback" + self.gatewayTalkActiveModeSubtitle = issue.displayMessage + self.gatewayTalkCurrentFallbackIssue = issue + self.gatewayTalkLastIssueText = issue.diagnosticSummary + self.statusText = issue.fallbackStatusText + } + + private func realtimeIssue(message: String, phase: String) -> TalkRuntimeIssue { + TalkRuntimeIssue.realtimeUnavailable( + message: message, + provider: self.realtimeProvider, + model: self.realtimeModelId, + transport: self.executionMode == .realtimeRelay ? "gateway-relay" : "webrtc", + phase: phase) + } + + private func realtimeIssue(from error: Error, phase: String) -> TalkRuntimeIssue { + if let gatewayError = error as? GatewayResponseError, + let issue = Self.talkRuntimeIssue( + from: gatewayError, + fallbackProvider: self.realtimeProvider, + fallbackModel: self.realtimeModelId, + fallbackTransport: self.executionMode == .realtimeRelay ? "gateway-relay" : "webrtc", + fallbackPhase: phase) + { + return issue + } + return self.realtimeIssue(message: error.localizedDescription, phase: phase) + } + + private static func talkRuntimeIssue( + from gatewayError: GatewayResponseError, + fallbackProvider: String?, + fallbackModel: String?, + fallbackTransport: String?, + fallbackPhase: String) -> TalkRuntimeIssue? + { + guard let rawIssue = gatewayError.details["talkIssue"]?.dictionaryValue else { return nil } + let message = rawIssue["message"]?.stringValue ?? gatewayError.message + let provider = rawIssue["provider"]?.stringValue ?? fallbackProvider + let model = rawIssue["model"]?.stringValue ?? fallbackModel + let transport = rawIssue["transport"]?.stringValue ?? fallbackTransport + let phase = rawIssue["phase"]?.stringValue ?? fallbackPhase + return TalkRuntimeIssue.realtimeUnavailable( + message: message, + provider: provider, + model: model, + transport: transport, + phase: phase) + } + private func restoreConfiguredVoiceModeDescriptor() { self.applyVoiceModeDescriptor(self.configuredVoiceModeDescriptor) } @@ -2836,7 +2962,11 @@ extension TalkModeManager: TalkRealtimeWebRTCSessionDelegate { func realtimeSession(_ session: TalkRealtimeWebRTCSession, didChangeStatus status: String) { guard session === self.realtimeSession else { return } GatewayDiagnostics.log("talk.timeline realtime status=\(status)") - self.statusText = status + if status == "Listening" { + self.markRealtimeActive() + } else { + self.statusText = status + } self.isListening = status == "Listening" self.isSpeaking = status == "Speaking" if status == "Thinking" { @@ -2877,6 +3007,8 @@ extension TalkModeManager: TalkRealtimeWebRTCSessionDelegate { self.isListening = false self.isSpeaking = false self.isUserSpeechDetected = false + self.gatewayTalkActiveModeTitle = "Not active" + self.gatewayTalkActiveModeSubtitle = nil if self.isEnabled { self.statusText = self.gatewayConnected ? "Ready" : "Offline" } @@ -2909,21 +3041,47 @@ extension TalkModeManager { self.gatewayTalkUsesRealtimeRelay } - func _test_seedTranscript(_ transcript: String) { - self.lastTranscript = transcript - self.lastHeard = Date() + func _test_markNativeFallbackActive(after issue: TalkRuntimeIssue) { + self.markNativeFallbackActive(after: issue) } - func _test_handleTranscript(_ transcript: String, isFinal: Bool) async { - await self.handleTranscript(transcript: transcript, isFinal: isFinal) + func _test_recordRealtimeIssue(_ issue: TalkRuntimeIssue) { + self.pendingRealtimeIssue = issue + self.gatewayTalkLastIssueText = issue.diagnosticSummary + self.gatewayTalkActiveModeTitle = "Realtime unavailable" + self.gatewayTalkActiveModeSubtitle = issue.displayMessage } - func _test_backdateLastHeard(seconds: TimeInterval) { - self.lastHeard = Date().addingTimeInterval(-seconds) + func _test_handleRealtimeRelayStatus(_ status: String) { + self.handleRealtimeRelayStatus(status) } - func _test_runSilenceCheck() async { - await self.checkSilence() + func _test_prepareRealtimeRelayStart() { + self.prepareRealtimeRelayStart() + } + + func _test_realtimeIssue(from error: Error, phase: String) -> TalkRuntimeIssue { + self.realtimeIssue(from: error, phase: phase) + } + + func _test_hasPendingRealtimeIssue() -> Bool { + self.pendingRealtimeIssue != nil + } + + func _test_gatewayTalkActiveModeTitle() -> String { + self.gatewayTalkActiveModeTitle + } + + func _test_gatewayTalkActiveModeSubtitle() -> String? { + self.gatewayTalkActiveModeSubtitle + } + + func _test_gatewayTalkLastIssueText() -> String? { + self.gatewayTalkLastIssueText + } + + func _test_gatewayTalkCurrentFallbackIssue() -> TalkRuntimeIssue? { + self.gatewayTalkCurrentFallbackIssue } func _test_incrementalReset() { diff --git a/apps/ios/Sources/Voice/TalkPermissionPromptView.swift b/apps/ios/Sources/Voice/TalkPermissionPromptView.swift index 5e5219a4a0e6..446d75109ace 100644 --- a/apps/ios/Sources/Voice/TalkPermissionPromptView.swift +++ b/apps/ios/Sources/Voice/TalkPermissionPromptView.swift @@ -3,7 +3,6 @@ import SwiftUI struct TalkPermissionPromptView: View { enum Style { case card - case settings case sheet } diff --git a/apps/ios/Sources/Voice/TalkRealtimeWebRTCSession.swift b/apps/ios/Sources/Voice/TalkRealtimeWebRTCSession.swift index 1aebae886b8c..6ebfa4879ec6 100644 --- a/apps/ios/Sources/Voice/TalkRealtimeWebRTCSession.swift +++ b/apps/ios/Sources/Voice/TalkRealtimeWebRTCSession.swift @@ -61,7 +61,6 @@ final class TalkRealtimeWebRTCSession: NSObject { let runId: String? let status: String? let startedAt: Double? - let endedAt: Double? let error: String? let stopReason: String? let timeoutPhase: String? @@ -196,11 +195,6 @@ final class TalkRealtimeWebRTCSession: NSObject { Self.logger.info("timeline +\(self.elapsedMs(), privacy: .public)ms \(message, privacy: .public)") } - func cancelResponse() { - self.sendRealtimeEvent(["type": "response.cancel"]) - self.cancelActiveToolCalls() - } - private func cancelActiveToolCalls() { let runIds = Array(Set(activeToolRunIds.values)) for task in self.activeToolTasks.values { diff --git a/apps/ios/Sources/Voice/TalkSpeechLocale.swift b/apps/ios/Sources/Voice/TalkSpeechLocale.swift index 80fc1ae8ac36..a532ca6db852 100644 --- a/apps/ios/Sources/Voice/TalkSpeechLocale.swift +++ b/apps/ios/Sources/Voice/TalkSpeechLocale.swift @@ -70,14 +70,6 @@ enum TalkSpeechLocale { return (recognizer, recognizer?.locale.identifier) } - static func normalizedExplicitLocaleID(_ raw: String?) -> String? { - TalkConfigParsing.normalizedExplicitSpeechLocaleID(raw, automaticID: self.automaticID) - } - - private static func normalizedLocaleID(_ raw: String?) -> String? { - TalkConfigParsing.normalizedSpeechLocaleID(raw) - } - private static func canonicalID(_ raw: String) -> String { raw.replacingOccurrences(of: "_", with: "-") } diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist index a2b6b3d651f5..bcf39f7ad0bf 100644 --- a/apps/ios/SwiftSources.input.xcfilelist +++ b/apps/ios/SwiftSources.input.xcfilelist @@ -1,6 +1,7 @@ Sources/Calendar/CalendarService.swift Sources/Camera/CameraController.swift Sources/Capabilities/NodeCapabilityRouter.swift +Sources/Chat/AppleReviewDemoChatTransport.swift Sources/Chat/IOSGatewayChatTransport.swift Sources/Contacts/ContactsService.swift Sources/Device/DeviceInfoHelper.swift @@ -15,11 +16,11 @@ Sources/Design/ChatProTab.swift Sources/Design/CommandCenterTab.swift Sources/Design/TalkProTab.swift Sources/Design/OpenClawProComponents.swift -Sources/Design/OpenClawProScreens.swift Sources/Design/SettingsProTab.swift Sources/Design/SettingsProTabSupport.swift Sources/Design/SettingsProTabSections.swift Sources/Design/SettingsProTabActions.swift +Sources/Design/TalkRuntimeIssueBanner.swift Sources/Design/CommandCenterSupport.swift Sources/Design/AgentProTab+Overview.swift Sources/Design/AgentProTab+Destinations.swift @@ -29,6 +30,15 @@ Sources/Design/AgentProTab+Usage.swift Sources/Design/AgentProTab+DetailComponents.swift Sources/Design/AgentProTab+GatewayData.swift Sources/Design/AgentProModels.swift +Sources/Design/IPadActivityScreen.swift +Sources/Design/IPadSidebarFeaturePreviews.swift +Sources/Design/IPadSidebarFeatureScreens.swift +Sources/Design/IPadSkillWorkshopScreen.swift +Sources/Design/IPadSidebarScreenChrome.swift +Sources/Design/IPadWorkboardScreen.swift +Sources/Design/OpenClawDocsScreen.swift +Sources/Design/RootTabsPhoneControlHub.swift +Sources/Design/SettingsChannelsDestination.swift Sources/EventKit/EventKitAuthorization.swift Sources/Gateway/DeepLinkAgentPromptAlert.swift Sources/Gateway/ExecApprovalPromptDialog.swift @@ -56,7 +66,6 @@ Sources/Model/NodeAppModel.swift Sources/Model/WatchReplyCoordinator.swift Sources/Motion/MotionService.swift Sources/Onboarding/GatewayOnboardingReset.swift -Sources/Onboarding/GatewayOnboardingView.swift Sources/Onboarding/OnboardingStateStore.swift Sources/Onboarding/OnboardingWizardSteps.swift Sources/Onboarding/OnboardingWizardView.swift @@ -71,7 +80,7 @@ Sources/Push/PushRelayClient.swift Sources/Push/PushRelayKeychainStore.swift Sources/Reminders/RemindersService.swift Sources/RootTabs.swift -Sources/RootView.swift +Sources/RootTabsNavigation.swift Sources/Screen/ScreenController.swift Sources/Screen/ScreenRecordService.swift Sources/Screen/ScreenWebView.swift @@ -82,9 +91,7 @@ Sources/Services/WatchMessagingPayloadCodec.swift Sources/Services/WatchMessagingService.swift Sources/SessionKey.swift Sources/Settings/PrivacyAccessSectionView.swift -Sources/Settings/SettingsNetworkingHelpers.swift Sources/Settings/VoiceWakeWordsSettingsView.swift -Sources/Status/GatewayActionsDialog.swift Sources/Status/GatewayStatusBuilder.swift Sources/Status/VoiceWakeToast.swift Sources/Voice/TalkGatewayPermissionState.swift diff --git a/apps/ios/Tests/CommandCenterTabLayoutTests.swift b/apps/ios/Tests/CommandCenterTabLayoutTests.swift new file mode 100644 index 000000000000..e49161529053 --- /dev/null +++ b/apps/ios/Tests/CommandCenterTabLayoutTests.swift @@ -0,0 +1,27 @@ +import SwiftUI +import Testing +@testable import OpenClaw + +@MainActor +@Suite struct CommandCenterTabLayoutTests { + @Test func splitLayoutDisabledForCompactWidth() { + #expect( + !CommandCenterTab.usesSplitSectionsLayout( + horizontalSizeClass: .compact, + containerWidth: 1_200)) + } + + @Test func splitLayoutDisabledBelowWidthThreshold() { + #expect( + !CommandCenterTab.usesSplitSectionsLayout( + horizontalSizeClass: .regular, + containerWidth: 900)) + } + + @Test func splitLayoutEnabledForRegularWideLayout() { + #expect( + CommandCenterTab.usesSplitSectionsLayout( + horizontalSizeClass: .regular, + containerWidth: 1_024)) + } +} diff --git a/apps/ios/Tests/CommandCenterTabSessionFilterTests.swift b/apps/ios/Tests/CommandCenterTabSessionFilterTests.swift new file mode 100644 index 000000000000..3c1614fa9071 --- /dev/null +++ b/apps/ios/Tests/CommandCenterTabSessionFilterTests.swift @@ -0,0 +1,41 @@ +import Testing +@testable import OpenClaw + +struct CommandCenterTabSessionFilterTests { + @Test func `hides direct agent device sessions`() { + #expect(!CommandCenterTab.isRecentChatSession("main", defaultSessionKey: "main")) + #expect(!CommandCenterTab.isRecentChatSession("agent:main:main", defaultSessionKey: "main")) + #expect(!CommandCenterTab.isRecentChatSession("agent:rust-claw:main", defaultSessionKey: "main")) + #expect(!CommandCenterTab.isRecentChatSession("agent:main:node-0b88d67b7e42", defaultSessionKey: "main")) + #expect(!CommandCenterTab.isRecentChatSession("agent:main:work", defaultSessionKey: "work")) + #expect(!CommandCenterTab.isRecentChatSession("main", defaultSessionKey: "agent:rust-claw:work")) + #expect(!CommandCenterTab.isRecentChatSession("global", defaultSessionKey: "agent:rust-claw:work")) + #expect(!CommandCenterTab.isRecentChatSession("node-0b88d67b7e42", defaultSessionKey: "agent:rust-claw:work")) + #expect(!CommandCenterTab.isRecentChatSession("work", defaultSessionKey: "agent:rust-claw:work")) + #expect(!CommandCenterTab.isRecentChatSession("agent:main:work", defaultSessionKey: "agent:rust-claw:work")) + #expect(!CommandCenterTab.isRecentChatSession("agent:main:main:thread:42", defaultSessionKey: "main")) + #expect(!CommandCenterTab.isRecentChatSession("agent:support:main:thread:1234:42", defaultSessionKey: "main")) + #expect(!CommandCenterTab.isRecentChatSession( + "agent:main:node-0b88d67b7e42:thread:42", + defaultSessionKey: "main")) + #expect(!CommandCenterTab.isRecentChatSession("agent:main:work:thread:42", defaultSessionKey: "work")) + #expect(!CommandCenterTab.isRecentChatSession( + "agent:main:work:thread:42", + defaultSessionKey: "agent:rust-claw:work")) + } + + @Test func `keeps agent scoped channel and cron sessions`() { + #expect(CommandCenterTab.isRecentChatSession( + "agent:main:signal:direct:+15555550123", + defaultSessionKey: "main")) + #expect(CommandCenterTab.isRecentChatSession( + "agent:rust-claw:mattermost:channel:abc123", + defaultSessionKey: "main")) + #expect(CommandCenterTab.isRecentChatSession( + "agent:rust-claw:cron:3cd2eb6f-b8a5-4db7-b74a-f6a3f7eab3d3", + defaultSessionKey: "main")) + #expect(CommandCenterTab.isRecentChatSession( + "agent:main:slack:channel:c1:thread:123", + defaultSessionKey: "main")) + } +} diff --git a/apps/ios/Tests/GatewayConnectionControllerTests.swift b/apps/ios/Tests/GatewayConnectionControllerTests.swift index 5a0c45cf61c0..233d58449ab8 100644 --- a/apps/ios/Tests/GatewayConnectionControllerTests.swift +++ b/apps/ios/Tests/GatewayConnectionControllerTests.swift @@ -1,7 +1,7 @@ import Foundation -import OpenClawKit import Testing import UIKit +@testable import OpenClawKit @testable import OpenClaw @Suite(.serialized) struct GatewayConnectionControllerTests { @@ -97,8 +97,14 @@ import UIKit clientId: "openclaw-ios", displayName: "OpenClaw iOS", includeApprovalScope: true) + let withAdminScope = appModel._test_makeOperatorConnectOptions( + clientId: "openclaw-ios", + displayName: "OpenClaw iOS", + includeAdminScope: true, + includeApprovalScope: false) #expect(withoutApprovalScope.role == "operator") + #expect(!withoutApprovalScope.scopes.contains("operator.admin")) #expect(withoutApprovalScope.scopes.contains("operator.read")) #expect(withoutApprovalScope.scopes.contains("operator.write")) #expect(!withoutApprovalScope.scopes.contains("operator.approvals")) @@ -106,9 +112,10 @@ import UIKit #expect(!withoutApprovalScope.scopesAreExplicit) #expect(withApprovalScope.scopes.contains("operator.approvals")) + #expect(withAdminScope.scopes.contains("operator.admin")) } - @Test @MainActor func operatorTalkPermissionUpgradeUsesExplicitScopes() { + @Test @MainActor func operatorTalkPermissionUpgradeUsesExplicitLeastPrivilegeScopes() { let appModel = NodeAppModel() let options = appModel._test_makeOperatorConnectOptions( clientId: "openclaw-ios", @@ -117,11 +124,60 @@ import UIKit forceExplicitScopes: true) #expect(options.scopesAreExplicit) + #expect(!options.scopes.contains("operator.admin")) + #expect(!options.scopes.contains("operator.approvals")) #expect(options.scopes.contains("operator.read")) #expect(options.scopes.contains("operator.write")) #expect(options.scopes.contains("operator.talk.secrets")) } + @Test func operatorAdminScopeRequestsOnlyWhenSharedAuthOrAlreadyGranted() { + #expect( + !NodeAppModel._test_shouldRequestOperatorAdminScope( + token: nil, + password: nil, + storedOperatorScopes: ["operator.read", "operator.write", "operator.talk.secrets"])) + #expect( + NodeAppModel._test_shouldRequestOperatorAdminScope( + token: nil, + password: nil, + storedOperatorScopes: ["operator.admin"])) + #expect( + NodeAppModel._test_shouldRequestOperatorAdminScope( + token: "shared-token", + password: nil, + storedOperatorScopes: [])) + #expect( + NodeAppModel._test_shouldRequestOperatorAdminScope( + token: nil, + password: "shared-password", + storedOperatorScopes: [])) + #expect( + !NodeAppModel._test_shouldRequestOperatorAdminScope( + token: "shared-token", + password: nil, + storedOperatorScopes: [], + forceTalkPermissionUpgradeRequest: true)) + } + + @Test func storedDeviceTokenScopeGapUsesGatewayScopeCompatibility() { + #expect(!GatewayChannelActor._test_requestedScopesExceedStoredToken( + role: "operator", + requestedScopes: ["operator.read", "operator.write", "operator.talk.secrets"], + storedToken: "stored-device-token", + storedScopes: ["operator.admin"])) + #expect(!GatewayChannelActor._test_requestedScopesExceedStoredToken( + role: "operator", + requestedScopes: ["operator.read"], + storedToken: "stored-device-token", + storedScopes: [])) + #expect(GatewayChannelActor._test_requestedScopesExceedStoredToken( + role: "operator", + requestedScopes: ["operator.admin"], + storedToken: "stored-device-token", + storedScopes: ["operator.read"])) + } + @Test func operatorApprovalScopeRequestsStayBackwardCompatible() { #expect( !NodeAppModel._test_shouldRequestOperatorApprovalScope( @@ -143,6 +199,57 @@ import UIKit token: "shared-token", password: nil, storedOperatorScopes: [])) + #expect( + !NodeAppModel._test_shouldRequestOperatorApprovalScope( + token: "shared-token", + password: nil, + storedOperatorScopes: [], + forceTalkPermissionUpgradeRequest: true)) + #expect( + NodeAppModel._test_shouldRequestOperatorApprovalScope( + token: nil, + password: nil, + storedOperatorScopes: ["operator.approvals"], + forceTalkPermissionUpgradeRequest: true)) + } + + @Test @MainActor func operatorPairingProblemPreservesPrimaryGatewayConnectionState() { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + appModel.gatewayServerName = "gateway.example.com" + appModel.gatewayRemoteAddress = "127.0.0.1:53380" + let problem = GatewayConnectionProblem( + kind: .pairingScopeUpgradeRequired, + owner: .gateway, + title: "Additional permissions required", + message: "Approve the requested permissions on the gateway.", + requestId: "req-admin", + retryable: false, + pauseReconnect: true) + + appModel._test_applyOperatorGatewayConnectionProblem(problem) + + #expect(appModel._test_isGatewayConnected()) + #expect(appModel.gatewayServerName == "gateway.example.com") + #expect(appModel.gatewayRemoteAddress == "127.0.0.1:53380") + #expect(appModel.lastGatewayProblem == problem) + #expect(appModel.gatewayPairingPaused) + #expect(appModel.gatewayPairingRequestId == "req-admin") + + appModel._test_clearGatewayConnectionProblem() + + #expect(appModel.lastGatewayProblem == problem) + #expect(appModel.gatewayPairingPaused) + #expect(appModel.gatewayPairingRequestId == "req-admin") + + appModel._test_clearOperatorGatewayConnectionProblemIfCurrent() + + #expect(appModel._test_isGatewayConnected()) + #expect(appModel.gatewayServerName == "gateway.example.com") + #expect(appModel.lastGatewayProblem == nil) + #expect(!appModel.gatewayPairingPaused) + #expect(appModel.gatewayPairingRequestId == nil) + #expect(appModel.gatewayStatusText == "Connected") } @Test @MainActor func savedManualEndpointFallbackUsesOnboardingHostWhenAutoConnectIsEnabled() { @@ -249,6 +356,20 @@ import UIKit #expect(!appModel._test_hasGatewayLoopTasks().operator) } + @Test @MainActor func foregroundStaleConnectionRestartReappliesActiveGatewayConfig() async { + let appModel = NodeAppModel() + defer { appModel.disconnectGateway() } + + let config = Self.makeGatewayConnectConfig() + appModel.applyGatewayConnectConfig(config) + await appModel._test_restartGatewaySessionsAfterForegroundStaleConnection() + + #expect(appModel.gatewayStatusText == "Reconnecting…") + #expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: config) == true) + #expect(appModel._test_hasGatewayLoopTasks().node) + #expect(appModel._test_hasGatewayLoopTasks().operator) + } + @Test @MainActor func loadLastConnectionReadsSavedValues() { let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection") defer { diff --git a/apps/ios/Tests/GatewayConnectionSecurityTests.swift b/apps/ios/Tests/GatewayConnectionSecurityTests.swift index 2dd54eb7b4d5..dafb9946e32c 100644 --- a/apps/ios/Tests/GatewayConnectionSecurityTests.swift +++ b/apps/ios/Tests/GatewayConnectionSecurityTests.swift @@ -39,19 +39,19 @@ import Testing @Test @MainActor func discoveredTLSParams_prefersStoredPinOverAdvertisedTXT() async { let stableID = "test|\(UUID().uuidString)" defer { clearTLSFingerprint(stableID: stableID) } - clearTLSFingerprint(stableID: stableID) + self.clearTLSFingerprint(stableID: stableID) GatewayTLSStore.saveFingerprint("11", stableID: stableID) - let gateway = makeDiscoveredGateway( + let gateway = self.makeDiscoveredGateway( stableID: stableID, lanHost: "evil.example.com", tailnetDns: "evil.example.com", gatewayPort: 12345, fingerprint: "22") - let controller = makeController() + let controller = self.makeController() - let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true) + let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway) #expect(params?.expectedFingerprint == "11") #expect(params?.allowTOFU == false) } @@ -59,17 +59,17 @@ import Testing @Test @MainActor func discoveredTLSParams_doesNotTrustAdvertisedFingerprint() async { let stableID = "test|\(UUID().uuidString)" defer { clearTLSFingerprint(stableID: stableID) } - clearTLSFingerprint(stableID: stableID) + self.clearTLSFingerprint(stableID: stableID) - let gateway = makeDiscoveredGateway( + let gateway = self.makeDiscoveredGateway( stableID: stableID, lanHost: nil, tailnetDns: nil, gatewayPort: nil, fingerprint: "22") - let controller = makeController() + let controller = self.makeController() - let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true) + let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway) #expect(params?.expectedFingerprint == nil) #expect(params?.allowTOFU == false) } @@ -77,7 +77,7 @@ import Testing @Test @MainActor func autoconnectRequiresStoredPinForDiscoveredGateways() async { let stableID = "test|\(UUID().uuidString)" defer { clearTLSFingerprint(stableID: stableID) } - clearTLSFingerprint(stableID: stableID) + self.clearTLSFingerprint(stableID: stableID) let defaults = UserDefaults.standard defaults.set(true, forKey: "gateway.autoconnect") @@ -90,13 +90,13 @@ import Testing defaults.removeObject(forKey: "gateway.preferredStableID") defaults.set(stableID, forKey: "gateway.lastDiscoveredStableID") - let gateway = makeDiscoveredGateway( + let gateway = self.makeDiscoveredGateway( stableID: stableID, lanHost: "test.local", tailnetDns: nil, gatewayPort: 18789, fingerprint: nil) - let controller = makeController() + let controller = self.makeController() controller._test_setGateways([gateway]) controller._test_triggerAutoConnect() @@ -104,7 +104,7 @@ import Testing } @Test @MainActor func manualConnectionsForceTLSForNonLoopbackHosts() async { - let controller = makeController() + let controller = self.makeController() #expect(controller._test_resolveManualUseTLS(host: "gateway.example.com", useTLS: false) == true) #expect(controller._test_resolveManualUseTLS(host: "127.attacker.example", useTLS: false) == true) @@ -120,7 +120,7 @@ import Testing } @Test @MainActor func manualConnectionsAllowPrivateLanPlaintext() async { - let controller = makeController() + let controller = self.makeController() #expect(controller._test_resolveManualUseTLS(host: "openclaw.local", useTLS: false) == false) #expect(controller._test_resolveManualUseTLS(host: "192.168.1.20", useTLS: false) == false) @@ -131,7 +131,7 @@ import Testing } @Test @MainActor func manualDefaultPortUses443OnlyForTailnetTLSHosts() async { - let controller = makeController() + let controller = self.makeController() #expect(controller._test_resolveManualPort(host: "gateway.example.com", port: 0, useTLS: true) == 18789) #expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 0, useTLS: true) == 443) diff --git a/apps/ios/Tests/GatewaySettingsStoreTests.swift b/apps/ios/Tests/GatewaySettingsStoreTests.swift index e7f5ad2b59d2..80f20b1d6097 100644 --- a/apps/ios/Tests/GatewaySettingsStoreTests.swift +++ b/apps/ios/Tests/GatewaySettingsStoreTests.swift @@ -9,11 +9,9 @@ private struct KeychainEntry: Hashable { private let gatewayService = "ai.openclaw.gateway" private let nodeService = "ai.openclaw.node" -private let talkService = "ai.openclaw.talk" private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId") private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID") private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID") -private let talkAcmeProviderEntry = KeychainEntry(service: talkService, account: "provider.apiKey.acme") private let bootstrapDefaultsKeys = [ "node.instanceId", "gateway.preferredStableID", @@ -187,17 +185,4 @@ private func withLastGatewaySnapshot(_ body: () -> Void) { #expect(defaults.object(forKey: "gateway.last.host") == nil) } } - - @Test func talkProviderApiKey_genericRoundTrip() { - let keychainSnapshot = snapshotKeychain([talkAcmeProviderEntry]) - defer { restoreKeychain(keychainSnapshot) } - - _ = KeychainStore.delete(service: talkService, account: talkAcmeProviderEntry.account) - - GatewaySettingsStore.saveTalkProviderApiKey("acme-key", provider: "acme") - #expect(GatewaySettingsStore.loadTalkProviderApiKey(provider: "acme") == "acme-key") - - GatewaySettingsStore.saveTalkProviderApiKey(nil, provider: "acme") - #expect(GatewaySettingsStore.loadTalkProviderApiKey(provider: "acme") == nil) - } } diff --git a/apps/ios/Tests/GatewayStatusBuilderTests.swift b/apps/ios/Tests/GatewayStatusBuilderTests.swift index 8c0984e89385..334f6de48dc1 100644 --- a/apps/ios/Tests/GatewayStatusBuilderTests.swift +++ b/apps/ios/Tests/GatewayStatusBuilderTests.swift @@ -33,4 +33,12 @@ import Testing #expect(state == .connecting) } + + @Test func chatGatewayPillLabelsMatchDisplayState() { + #expect(ChatProTab.gatewayPillTitle(state: .disconnected, isGatewayUsable: false) == "Offline") + #expect(ChatProTab.gatewayPillTitle(state: .connecting, isGatewayUsable: false) == "Connecting") + #expect(ChatProTab.gatewayPillTitle(state: .error, isGatewayUsable: false) == "Attention") + #expect(ChatProTab.gatewayPillTitle(state: .connected, isGatewayUsable: true) == "Connected") + #expect(ChatProTab.gatewayPillTitle(state: .connected, isGatewayUsable: false) == "Unavailable") + } } diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 6f8f9ab753f1..3d00ef0cb7a1 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -1,5 +1,6 @@ import Foundation import OpenClawKit +import OpenClawProtocol import Testing import UIKit import UserNotifications @@ -214,6 +215,117 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(appModel.mainSessionKey == "agent:agent-123:main") } + @Test @MainActor func sessionKeyExtractsCanonicalAgentID() { + #expect(SessionKey.agentId(from: "agent:rust-claw:mattermost:channel:w6g") == "rust-claw") + #expect(SessionKey.agentId(from: " agent:main:main ") == "main") + #expect(SessionKey.agentId(from: "main") == nil) + #expect(SessionKey.agentId(from: "agent::main") == nil) + #expect(SessionKey.agentId(from: nil) == nil) + } + + @Test @MainActor func chatAgentNameUsesFocusedCanonicalSessionAgent() { + let appModel = NodeAppModel() + appModel.gatewayDefaultAgentId = "main" + appModel.gatewayAgents = [ + AgentSummary( + id: "main", + name: "Joshtimus Prime", + identity: nil, + workspace: nil, + model: nil, + agentruntime: nil), + AgentSummary( + id: "rust-claw", + name: "Rust Claw", + identity: nil, + workspace: nil, + model: nil, + agentruntime: nil), + ] + appModel.setSelectedAgentId("main") + + appModel.openChat(sessionKey: "agent:rust-claw:mattermost:channel:w6gjp6iz3fyp3fo15q4fwfpnno") + + #expect(appModel.selectedAgentId == "main") + #expect(appModel.activeAgentName == "Joshtimus Prime") + #expect(appModel.chatAgentId == "rust-claw") + #expect(appModel.chatAgentName == "Rust Claw") + } + + @Test @MainActor func chatAgentNameFallsBackToSelectedAgentForUnscopedSession() { + let appModel = NodeAppModel() + appModel.gatewayDefaultAgentId = "main" + appModel.gatewayAgents = [ + AgentSummary( + id: "rust-claw", + name: "Rust Claw", + identity: nil, + workspace: nil, + model: nil, + agentruntime: nil), + ] + appModel.setSelectedAgentId("rust-claw") + + appModel.openChat(sessionKey: "incident-42") + + #expect(appModel.chatAgentId == "rust-claw") + #expect(appModel.chatAgentName == "Rust Claw") + } + + @Test @MainActor func selectingAgentClearsExplicitChatFocus() { + let appModel = NodeAppModel() + appModel.gatewayDefaultAgentId = "main" + let rustSessionKey = SessionKey.makeAgentSessionKey(agentId: "rust-claw", baseKey: "main") + + appModel.setSelectedAgentId("rust-claw") + #expect(appModel.chatSessionKey == rustSessionKey) + appModel.focusChatSession(rustSessionKey) + + appModel.setSelectedAgentId("main") + #expect(appModel.defaultChatSessionKey == "main") + #expect(appModel.mainSessionKey == "main") + #expect(appModel.chatSessionKey == "main") + } + + @Test @MainActor func sameSelectedAgentKeepsExplicitChatFocus() { + let appModel = NodeAppModel() + appModel.gatewayDefaultAgentId = "main" + appModel.setSelectedAgentId("main") + appModel.openChat(sessionKey: "incident-42") + + appModel.setSelectedAgentId("main") + #expect(appModel.defaultChatSessionKey == "main") + #expect(appModel.chatSessionKey == "incident-42") + } + + @Test @MainActor func defaultChatSessionKeyIgnoresExplicitChatFocus() { + let appModel = NodeAppModel() + appModel.gatewayDefaultAgentId = "main" + appModel.setSelectedAgentId("rust-claw") + appModel.openChat(sessionKey: "incident-42") + + #expect(appModel.defaultChatSessionKey == SessionKey.makeAgentSessionKey( + agentId: "rust-claw", + baseKey: "main")) + #expect(appModel.chatSessionKey == "incident-42") + } + + @Test @MainActor func openingNilChatSessionClearsExplicitChatFocus() { + let appModel = NodeAppModel() + appModel.gatewayDefaultAgentId = "main" + appModel.setSelectedAgentId("rust-claw") + appModel.openChat(sessionKey: "incident-42") + + appModel.openChat(sessionKey: nil) + + #expect(appModel.chatSessionKey == SessionKey.makeAgentSessionKey( + agentId: "rust-claw", + baseKey: "main")) + + appModel.setSelectedAgentId("main") + #expect(appModel.chatSessionKey == "main") + } + @Test @MainActor func execApprovalPromptPresentationTracksLatestNotificationTap() throws { let appModel = NodeAppModel() try appModel._test_presentExecApprovalPrompt( @@ -913,6 +1025,38 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc #expect(appModel.openChatRequestID == 1) } + @Test @MainActor func operatorAdminScopeCacheRefreshesFromStoredToken() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"] + setenv("OPENCLAW_STATE_DIR", tempDir.path, 1) + defer { + if let previousStateDir { + setenv("OPENCLAW_STATE_DIR", previousStateDir, 1) + } else { + unsetenv("OPENCLAW_STATE_DIR") + } + try? FileManager.default.removeItem(at: tempDir) + } + + let appModel = NodeAppModel() + let identity = DeviceIdentityStore.loadOrCreate() + #expect(appModel.hasOperatorAdminScope == false) + + _ = DeviceAuthStore.storeToken( + deviceId: identity.deviceId, + role: "operator", + token: "operator-token", + scopes: ["operator.read", "operator.admin"]) + appModel._test_refreshOperatorAdminScopeFromStore() + #expect(appModel.hasOperatorAdminScope == true) + + DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: "operator") + appModel._test_refreshOperatorAdminScopeFromStore() + #expect(appModel.hasOperatorAdminScope == false) + } + @Test @MainActor func sendVoiceTranscriptThrowsWhenGatewayOffline() async { let appModel = NodeAppModel() await #expect(throws: Error.self) { diff --git a/apps/ios/Tests/OnboardingStateStoreTests.swift b/apps/ios/Tests/OnboardingStateStoreTests.swift index 94fe19a28171..63296480bfb3 100644 --- a/apps/ios/Tests/OnboardingStateStoreTests.swift +++ b/apps/ios/Tests/OnboardingStateStoreTests.swift @@ -56,12 +56,6 @@ import Testing appModel: appModel, defaults: defaults, hasSavedGatewayConnection: false)) - - OnboardingStateStore.markIncomplete(defaults: defaults) - #expect(OnboardingStateStore.shouldPresentOnLaunch( - appModel: appModel, - defaults: defaults, - hasSavedGatewayConnection: false)) } @Test func firstRunIntroDefaultsToVisibleThenPersists() { diff --git a/apps/ios/Tests/RealtimeTalkRelaySessionTests.swift b/apps/ios/Tests/RealtimeTalkRelaySessionTests.swift index e28a1400a3f2..f444cff5445b 100644 --- a/apps/ios/Tests/RealtimeTalkRelaySessionTests.swift +++ b/apps/ios/Tests/RealtimeTalkRelaySessionTests.swift @@ -1,5 +1,6 @@ import Foundation import OpenClawKit +import OpenClawProtocol import Testing @testable import OpenClaw @@ -37,4 +38,70 @@ private final class UnusedPCMStreamingAudioPlayer: PCMStreamingAudioPlaying { session._test_markOutputAudioStarted(nowMs: 500) #expect(session._test_outputStartedAtMs() == 500) } + + @Test func closeAfterClassifiedErrorDoesNotReplaceIssue() async { + var issues: [TalkRuntimeIssue] = [] + var statuses: [String] = [] + let session = RealtimeTalkRelaySession( + gateway: GatewayNodeSession(), + options: .init(sessionKey: "main", provider: "openai", model: "gpt-realtime-2", voice: nil), + pcmPlayer: UnusedPCMStreamingAudioPlayer(), + onStatus: { statuses.append($0) }, + onIssue: { issues.append($0) }, + onSpeakingChanged: { _ in }) + session._test_setRelaySessionId("relay-1") + + await session._test_handleGatewayEvent(EventFrame( + type: "event", + event: "talk.event", + payload: AnyCodable([ + "relaySessionId": "relay-1", + "type": "error", + "message": "OpenAI API key rejected with 401", + "code": "realtime_unavailable", + "provider": "openai", + "model": "gpt-realtime-2", + "transport": "gateway-relay", + "phase": "connect", + ]), + seq: nil, + stateversion: nil)) + await session._test_handleGatewayEvent(EventFrame( + type: "event", + event: "talk.event", + payload: AnyCodable([ + "relaySessionId": "relay-1", + "type": "close", + "reason": "error", + ]), + seq: nil, + stateversion: nil)) + + #expect(issues.map(\.code) == [.realtimeUnavailable]) + #expect(statuses == ["OpenAI API key rejected with 401"]) + } + + @Test func closedRelayDoesNotWaitForStartupReady() async { + let session = RealtimeTalkRelaySession( + gateway: GatewayNodeSession(), + options: .init(sessionKey: "main", provider: "openai", model: "gpt-realtime-2", voice: nil), + pcmPlayer: UnusedPCMStreamingAudioPlayer(), + onStatus: { _ in }, + onSpeakingChanged: { _ in }) + + session.stop() + + #expect(await session._test_waitForStartupCancelled(timeoutSeconds: 1)) + } + + @Test func startupReadyWaitCoversGatewayConnectBudget() { + let session = RealtimeTalkRelaySession( + gateway: GatewayNodeSession(), + options: .init(sessionKey: "main", provider: "openai", model: "gpt-realtime-2", voice: nil), + pcmPlayer: UnusedPCMStreamingAudioPlayer(), + onStatus: { _ in }, + onSpeakingChanged: { _ in }) + + #expect(session._test_startupReadyTimeoutSeconds() >= 12) + } } diff --git a/apps/ios/Tests/RootTabsPresentationTests.swift b/apps/ios/Tests/RootTabsPresentationTests.swift index 554ddd9f9759..53ffd5c0e6fd 100644 --- a/apps/ios/Tests/RootTabsPresentationTests.swift +++ b/apps/ios/Tests/RootTabsPresentationTests.swift @@ -1,4 +1,6 @@ +import SwiftUI import Testing +import UIKit @testable import OpenClaw @MainActor @@ -38,4 +40,432 @@ import Testing #expect(!shouldPresent) } + + @Test func sidebarTabsEnabledForIPadRegularWidth() { + #expect( + RootTabs.shouldUseSidebarTabs( + idiom: .pad, + horizontalSizeClass: .regular)) + } + + @Test func sidebarTabsEnabledForIPadCompactWidth() { + #expect( + RootTabs.shouldUseSidebarTabs( + idiom: .pad, + horizontalSizeClass: .compact)) + } + + @Test func sidebarTabsDisabledForIPhone() { + #expect( + !RootTabs.shouldUseSidebarTabs( + idiom: .phone, + horizontalSizeClass: .regular)) + } + + @Test func sidebarGroupsMatchAdaptiveNavigationModel() { + let groups = RootTabs.sidebarGroups + let destinationIDs = RootTabs.SidebarDestination.allCases.map(\.rawValue) + + #expect(groups.map(\.title) == ["CHAT", "CONTROL", "SETTINGS", "REFERENCE"]) + #expect(groups[0].destinations.map(\.rawValue) == ["chat", "talk"]) + #expect(groups[1].destinations == [ + .overview, + .activity, + .agents, + .workboard, + .skillWorkshop, + .instances, + .sessions, + .dreaming, + .usage, + .cron, + ]) + #expect(groups[2].destinations == [.settings]) + #expect(groups[3].destinations == [.docs]) + #expect(destinationIDs == [ + "chat", + "talk", + "overview", + "activity", + "agents", + "workboard", + "skillWorkshop", + "instances", + "sessions", + "dreaming", + "usage", + "cron", + "docs", + "settings", + "gateway", + ]) + #expect(!destinationIDs.contains("agent")) + #expect(!RootTabs.sidebarGroups.flatMap(\.destinations).contains(.gateway)) + } + + @Test func phoneControlGroupsAvoidDuplicatingTheAgentTab() { + let groups = RootTabs.phoneControlGroups + let destinations = groups.flatMap(\.destinations) + + #expect(groups.map(\.title) == ["CHAT", "CONTROL", "SETTINGS", "REFERENCE"]) + #expect(!destinations.contains(.agents)) + #expect(RootTabs.sidebarGroups.flatMap(\.destinations).contains(.agents)) + #expect(destinations.contains(.dreaming)) + #expect(destinations.contains(.instances)) + } + + @Test func sidebarUsesCompactLabelsForLongRoutes() { + #expect(RootTabs.SidebarDestination.settings.title == "Settings") + #expect(RootTabs.SidebarDestination.gateway.title == "Settings / Gateway") + #expect(RootTabs.SidebarDestination.gateway.sidebarTitle == "Connection") + } + + @Test func phoneHubUsesRootTabsOnlyForNativeChatAgentAndGateway() { + #expect(RootTabs.shouldOpenRootTabFromPhoneHub(.chat)) + #expect(RootTabs.shouldOpenRootTabFromPhoneHub(.talk)) + #expect(RootTabs.shouldOpenRootTabFromPhoneHub(.agents)) + #expect(RootTabs.shouldOpenRootTabFromPhoneHub(.gateway)) + #expect(RootTabs.shouldOpenRootTabFromPhoneHub(.settings)) + + for destination in RootTabs.SidebarDestination.allCases + where destination != .chat && destination != .talk && destination != .agents && destination != .gateway && destination != .settings + { + #expect(!RootTabs.shouldOpenRootTabFromPhoneHub(destination)) + } + } + + @Test func legacyInitialTabsMapToMatchingSidebarDestinations() { + #expect(RootTabs.defaultSidebarDestination(for: .control) == .overview) + #expect(RootTabs.defaultSidebarDestination(for: .chat) == .chat) + #expect(RootTabs.defaultSidebarDestination(for: .talk) == .talk) + #expect(RootTabs.defaultSidebarDestination(for: .agent) == .agents) + #expect(RootTabs.defaultSidebarDestination(for: .settings) == .settings) + } + + @Test func skillWorkshopMutationsRequireAdminScope() { + #expect(IPadSkillWorkshopScreen.shouldEnableProposalMutation(canWrite: true, hasOperatorAdminScope: true)) + #expect(!IPadSkillWorkshopScreen.shouldEnableProposalMutation(canWrite: true, hasOperatorAdminScope: false)) + #expect(!IPadSkillWorkshopScreen.shouldEnableProposalMutation(canWrite: false, hasOperatorAdminScope: true)) + } + + @Test func skillWorkshopHeldFilterIncludesQuarantinedAndStale() { + #expect(IPadSkillWorkshopScreen.proposalStatusFilters.contains("held")) + #expect(IPadSkillWorkshopScreen.proposalStatusMatchesFilter(status: "quarantined", filter: "held")) + #expect(IPadSkillWorkshopScreen.proposalStatusMatchesFilter(status: "stale", filter: "held")) + #expect(!IPadSkillWorkshopScreen.proposalStatusMatchesFilter(status: "pending", filter: "held")) + } + + @Test func skillWorkshopBoardLanesMatchStatusFilter() { + #expect( + IPadSkillWorkshopScreen.proposalStatusBoardLanes( + filter: "pending", + proposalStatuses: ["pending", "applied"]) == ["pending"]) + #expect( + IPadSkillWorkshopScreen.proposalStatusBoardLanes( + filter: "held", + proposalStatuses: ["quarantined", "stale"]) == ["quarantined", "stale"]) + #expect( + IPadSkillWorkshopScreen.proposalStatusBoardLanes( + filter: "all", + proposalStatuses: ["pending", "needs-review"]) == [ + "pending", + "quarantined", + "stale", + "applied", + "rejected", + "needs-review", + ]) + #expect(IPadSkillWorkshopScreen.proposalLaneLabel("quarantined") == "Quarantined") + #expect(IPadSkillWorkshopScreen.proposalLaneLabel("pending") == "Pending") + #expect(IPadSkillWorkshopScreen.proposalLaneLabel("needs-review") == "Needs Review") + #expect(IPadSkillWorkshopScreen.proposalLaneLabel("manual_QA") == "Manual QA") + } + + @Test func skillWorkshopSelectionStaysInsideActiveFilter() { + let proposals = [ + (id: "applied-1", status: "applied"), + (id: "pending-1", status: "pending"), + (id: "held-1", status: "quarantined"), + ] + + #expect( + IPadSkillWorkshopScreen.nextSelectedProposalID( + current: "applied-1", + proposals: proposals, + filter: "pending") == "pending-1") + #expect( + IPadSkillWorkshopScreen.nextSelectedProposalID( + current: "held-1", + proposals: proposals, + filter: "held") == "held-1") + #expect( + IPadSkillWorkshopScreen.nextSelectedProposalID( + current: "pending-1", + visibleProposalIDs: ["held-1"]) == "held-1") + #expect( + IPadSkillWorkshopScreen.nextSelectedProposalID( + current: "pending-1", + visibleProposalIDs: []) == nil) + } + + @Test func workboardBoardScopeLabelsStayCompact() { + #expect(IPadWorkboardScreen.normalizedScopeID(" planning ") == "planning") + #expect(IPadWorkboardScreen.boardScopeLabel(for: "") == "All boards") + #expect(IPadWorkboardScreen.boardScopeLabel(for: "planning") == "planning") + #expect(IPadWorkboardScreen.boardScopeOptions( + knownBoardIDs: ["default", " empty-board ", ""], + cardBoardIDs: ["planning", "default"]) == ["default", "empty-board", "planning"]) + #expect(IPadWorkboardScreen + .workboardSubtitle(boardScopeLabel: "All boards", selectedStatus: "active") == "All boards / Active") + #expect(IPadWorkboardScreen + .workboardSubtitle(boardScopeLabel: "planning", selectedStatus: "running") == "planning / Running") + } + + @Test func workboardCompactUnavailableCopyExplainsRealCapabilityState() { + #expect(IPadWorkboardScreen + .compactWriteUnavailableMessage(canRead: false) == + "Connect from Settings to create, move, and dispatch cards.") + #expect(IPadWorkboardScreen.compactWriteUnavailableMessage(canRead: true) == "Read-only gateway.") + } + + @Test func skillWorkshopAgentScopeNormalizesGatewayIds() { + #expect(IPadSkillWorkshopScreen.normalizedScopeID(" aiden ") == "aiden") + #expect(IPadSkillWorkshopScreen.normalizedScopeID(nil) == "") + } + + @Test func channelLifecycleControlsRequireAdminScope() { + #expect(SettingsChannelsDestination.shouldEnableChannelOperation(canRead: true, hasOperatorAdminScope: true)) + #expect(!SettingsChannelsDestination.shouldEnableChannelOperation(canRead: true, hasOperatorAdminScope: false)) + #expect(!SettingsChannelsDestination.shouldEnableChannelOperation(canRead: false, hasOperatorAdminScope: true)) + } + + @Test func clickClackStaysInChannelsIntegrationMetadata() { + #expect(SettingsChannelsDestination.fallbackLabel("clickclack") == "ClickClack") + #expect(SettingsChannelsDestination.fallbackDetail("clickclack") == "Self-hosted chat bot routing.") + #expect(SettingsChannelsDestination.fallbackSystemImage("clickclack") == "bubble.left.and.bubble.right") + } + + @Test func iPadOverviewCanSuppressStandaloneHeaderBranding() { + #expect(CommandCenterTab.shouldShowHeaderMark(hasLeadingAction: false, showsHeaderMark: true)) + #expect(!CommandCenterTab.shouldShowHeaderMark(hasLeadingAction: true, showsHeaderMark: true)) + #expect(!CommandCenterTab.shouldShowHeaderMark(hasLeadingAction: false, showsHeaderMark: false)) + } + + @Test func chatSidebarDestinationCanUseRouteHeaderInsteadOfAgentBranding() { + let standalone = ChatProTab() + let routed = ChatProTab( + headerTitle: "Chat", + headerSubtitle: "Agent conversation", + showsAgentBadge: false, + openSettings: {}) + + #expect(standalone.showsAgentBadge) + #expect(standalone.headerTitle == nil) + #expect(standalone.openSettings == nil) + #expect(routed.headerTitle == "Chat") + #expect(routed.headerSubtitle == "Agent conversation") + #expect(!routed.showsAgentBadge) + #expect(routed.openSettings != nil) + #expect(ChatProTab.defaultHeaderTitle(showsAgentBadge: true, agentDisplayName: "OpenClaw") == "OpenClaw") + #expect(ChatProTab.defaultHeaderTitle(showsAgentBadge: false, agentDisplayName: "OpenClaw") == "Chat") + } + + @Test func agentRoutesCanOpenGatewaySettingsFromHeaderPill() { + let standalone = AgentProTab() + let routed = AgentProTab( + directRoute: .instances, + headerTitle: "Instances", + openSettings: {}) + + #expect(standalone.headerTitle == "Agents") + #expect(standalone.directRoute == nil) + #expect(standalone.openSettings == nil) + #expect(AgentProTab(directRoute: .agents).directRoute == .agents) + #expect(routed.directRoute == .instances) + #expect(routed.headerTitle == "Instances") + #expect(routed.openSettings != nil) + } + + @Test func workboardDispatchSummaryReportsStartedAndFailures() throws { + let payload = Data( + """ + { + "count": 2, + "started": [{}], + "startFailures": [{}], + "promoted": [], + "reclaimed": [], + "blocked": [], + "orchestrated": [] + } + """.utf8) + let summary = try JSONDecoder().decode(IPadWorkboardDispatchSummary.self, from: payload) + + #expect(summary.summaryText == "2 dispatched: 1 started, 1 failed.") + } + + @Test func talkSidebarDestinationCanReceiveRevealAction() { + let action = OpenClawSidebarHeaderAction( + systemName: "sidebar.left", + accessibilityLabel: "Show Sidebar", + action: {}) + let routed = TalkProTab(headerLeadingAction: action, openSettings: {}) + + #expect(routed.headerLeadingAction?.systemName == "sidebar.left") + #expect(routed.headerLeadingAction?.accessibilityLabel == "Show Sidebar") + } + + @Test func iPadPortraitUsesHiddenDrawerSidebar() { + let mode = RootTabs.sidebarLayoutMode(containerSize: CGSize(width: 1024, height: 1366)) + + #expect(mode == .drawer) + #expect(!RootTabs.preferredSidebarVisibility(layoutMode: mode)) + } + + @Test func iPadWideLandscapeUsesVisibleSplitSidebar() { + let mode = RootTabs.sidebarLayoutMode(containerSize: CGSize(width: 1366, height: 1024)) + + #expect(mode == .split) + #expect(RootTabs.preferredSidebarVisibility(layoutMode: mode)) + } + + @Test func iPadSplitSidebarWidthStaysUsable() { + let width = RootTabs.sidebarWidth(containerWidth: 1366, isDrawerLayout: false) + + #expect(width >= RootTabs.sidebarSplitIdealWidth) + #expect(width <= RootTabs.sidebarSplitMaximumWidth) + } + + @Test func iPadCollapsedSplitSidebarUsesHeaderRevealWithoutReservedRail() { + #expect( + RootTabs.shouldShowSidebarRevealInDestinationHeader( + isSidebarVisible: false, + layoutMode: .split)) + #expect( + RootTabs.shouldShowSidebarRevealInDestinationHeader( + isSidebarVisible: true, + layoutMode: .split)) + #expect( + RootTabs.shouldShowSidebarRevealInDestinationHeader( + isSidebarVisible: false, + layoutMode: .drawer)) + #expect( + !RootTabs.shouldShowSidebarRevealInDestinationHeader( + isSidebarVisible: true, + layoutMode: .drawer)) + } + + @Test func initialSidebarVisibilityParsesLaunchArgument() { + #expect( + RootTabs.requestedInitialSidebarVisibility(arguments: [ + "OpenClaw", + "--openclaw-sidebar-visibility", + "hidden", + ]) == false) + #expect( + RootTabs.requestedInitialSidebarVisibility(arguments: [ + "OpenClaw", + "--openclaw-sidebar-visibility", + "visible", + ]) == true) + #expect( + RootTabs.requestedInitialSidebarVisibility(arguments: [ + "OpenClaw", + "--openclaw-sidebar-visibility", + "unknown", + ]) == nil) + } + + @Test func sidebarControlsHaveStableAccessibilityIdentifiers() { + #expect(RootTabs.sidebarShowButtonAccessibilityIdentifier == "RootTabs.Sidebar.Show") + #expect(RootTabs.sidebarHideButtonAccessibilityIdentifier == "RootTabs.Sidebar.Hide") + } + + @Test func iPadDrawerSidebarWidthStaysInsideScreen() { + let width = RootTabs.sidebarWidth(containerWidth: 744, isDrawerLayout: true) + + #expect(width >= 280) + #expect(width <= RootTabs.sidebarDrawerMaximumWidth) + } + + @Test func narrowLandscapeKeepsDrawerSidebar() { + let mode = RootTabs.sidebarLayoutMode(containerSize: CGSize(width: 900, height: 600)) + + #expect(mode == .drawer) + #expect(!RootTabs.preferredSidebarVisibility(layoutMode: mode)) + } + + @Test func drawerSelectionCollapsesSidebarButSplitSelectionDoesNot() { + #expect(RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .drawer)) + #expect(!RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .split)) + } + + @Test func hiddenSidebarShowsRevealControl() { + #expect(RootTabs.shouldShowSidebarRevealControl(isSidebarVisible: false)) + } + + @Test func sidebarRevealControlsHideWhenSidebarIsVisible() { + #expect(!RootTabs.shouldShowSidebarRevealControl(isSidebarVisible: true)) + } + + @Test func iPadSplitPrefersIntegratedVisibleSidebar() { + #expect(RootTabs.preferredSidebarVisibility(layoutMode: .split)) + #expect(!RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .split)) + #expect(!RootTabs.preferredSidebarVisibility(layoutMode: .drawer)) + #expect(RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .drawer)) + } + + @Test func destinationHeadersOwnHiddenSidebarRevealControl() { + #expect( + RootTabs.shouldShowSidebarRevealInDestinationHeader( + isSidebarVisible: false, + layoutMode: .drawer)) + #expect( + RootTabs.shouldShowSidebarRevealInDestinationHeader( + isSidebarVisible: false, + layoutMode: .split)) + #expect( + !RootTabs.shouldShowSidebarRevealInDestinationHeader( + isSidebarVisible: true, + layoutMode: .drawer)) + #expect( + RootTabs.shouldShowSidebarRevealInDestinationHeader( + isSidebarVisible: true, + layoutMode: .split)) + } + + @Test func workboardAndSkillWorkshopUseCompactTaskFlowOnPhoneSizes() { + #expect( + IPadWorkboardScreen.usesCompactTaskFlow( + horizontalSizeClass: .compact, + verticalSizeClass: .regular)) + #expect( + IPadSkillWorkshopScreen.usesCompactTaskFlow( + horizontalSizeClass: .compact, + verticalSizeClass: .regular)) + #expect( + IPadWorkboardScreen.usesCompactTaskFlow( + horizontalSizeClass: .regular, + verticalSizeClass: .compact)) + #expect( + IPadSkillWorkshopScreen.usesCompactTaskFlow( + horizontalSizeClass: .regular, + verticalSizeClass: .compact)) + } + + @Test func workboardAndSkillWorkshopKeepRegularTaskFlowOnWideIPadSizes() { + #expect( + !IPadWorkboardScreen.usesCompactTaskFlow( + horizontalSizeClass: .regular, + verticalSizeClass: .regular)) + #expect( + !IPadSkillWorkshopScreen.usesCompactTaskFlow( + horizontalSizeClass: .regular, + verticalSizeClass: .regular)) + } + + @Test func phoneHubLeavesRoomForFloatingTabBar() { + #expect(RootTabsPhoneControlHub.bottomScrollInset(verticalSizeClass: .regular) == 112) + #expect(RootTabsPhoneControlHub.bottomScrollInset(verticalSizeClass: .compact) == 72) + } } diff --git a/apps/ios/Tests/RootTabsSidebarRegressionTests.swift b/apps/ios/Tests/RootTabsSidebarRegressionTests.swift new file mode 100644 index 000000000000..fcfcbc7cc96b --- /dev/null +++ b/apps/ios/Tests/RootTabsSidebarRegressionTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing + +@Suite struct RootTabsSidebarRegressionTests { + @Test func iPadSplitHiddenSidebarUsesHeaderRevealInsteadOfReservedRail() throws { + let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8) + let navigationSource = try String(contentsOf: Self.rootTabsNavigationSourceURL(), encoding: .utf8) + let splitContent = try Self.extract( + source, + from: "private func sidebarNavigationSplitContent(sidebarWidth: CGFloat) -> some View", + to: "private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View") + + #expect(splitContent.contains("HStack(spacing: 0)")) + #expect(splitContent.contains("self.sidebarColumn")) + #expect(splitContent.contains(".frame(width: sidebarWidth, alignment: .topLeading)")) + #expect(splitContent.contains(".overlay(alignment: .trailing)")) + #expect(!splitContent.contains("self.syncSidebarVisibility(from: visibility)")) + #expect(!source.contains("NavigationSplitViewVisibility")) + #expect(!source.contains("@State private var splitColumnVisibility: NavigationSplitViewVisibility")) + #expect(!splitContent.contains("NavigationSplitView")) + #expect(!splitContent.contains("self.collapsedSidebarRail")) + #expect(!source.contains("private var collapsedSidebarRail: some View")) + #expect(!source.contains("Self.sidebarCollapsedRailWidth")) + #expect(source.contains("shouldShowSidebarRevealInDestinationHeader")) + #expect(!navigationSource.contains("static let sidebarCollapsedRailWidth")) + #expect(!navigationSource.contains("static func sidebarSplitColumnVisibility(isSidebarVisible: Bool)")) + #expect(!navigationSource + .contains("static func sidebarIsVisible(splitColumnVisibility: NavigationSplitViewVisibility)")) + } + + @Test func initialSidebarVisibilitySurvivesFirstLayoutMeasurement() throws { + let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8) + let layoutUpdate = try Self.extract( + source, + from: "private func updateSidebarLayout(containerSize: CGSize, force: Bool)", + to: "private func setSidebarVisible(_ isVisible: Bool)") + + #expect(source.contains("@State private var didResolveSidebarLayout: Bool = false")) + #expect(layoutUpdate.contains("let didResolvePreviousLayout = self.didResolveSidebarLayout")) + #expect(layoutUpdate.contains("self.didResolveSidebarLayout = true")) + #expect(layoutUpdate.contains("if layoutModeDidChange && didResolvePreviousLayout")) + #expect(layoutUpdate.contains("guard force || !self.sidebarVisibilityUserOverridden else { return }")) + } + + private static func rootTabsSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/RootTabs.swift") + } + + private static func rootTabsNavigationSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/RootTabsNavigation.swift") + } + + private static func extract(_ source: String, from start: String, to end: String) throws -> String { + let startRange = try #require(source.range(of: start)) + let tail = source[startRange.lowerBound...] + let endRange = try #require(tail.range(of: end)) + return String(tail[.. some View", + to: "private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View") + let drawerContent = try Self.extract( + source, + from: "private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View", + to: "private var sidebarDetailShell: some View") + + #expect(!source.contains("@State private var splitColumnVisibility: NavigationSplitViewVisibility")) + #expect(!source.contains("Self.sidebarSplitColumnVisibility(isSidebarVisible:")) + #expect(!source.contains("self.syncSidebarVisibility(from: visibility)")) + #expect(splitContent.contains("HStack(spacing: 0)")) + #expect(splitContent.contains("self.sidebarColumn")) + #expect(splitContent.contains(".frame(width: sidebarWidth, alignment: .topLeading)")) + #expect(splitContent.contains(".overlay(alignment: .trailing)")) + #expect(splitContent.contains("self.sidebarVerticalSeparator")) + #expect(splitContent.contains("self.sidebarDetailNavigationShell")) + #expect(!splitContent.contains("NavigationSplitView")) + #expect(!splitContent.contains("self.collapsedSidebarRail")) + #expect(!source.contains("Self.sidebarCollapsedRailWidth")) + #expect(drawerContent.contains("ZStack(alignment: .topLeading)")) + #expect(drawerContent.contains("Color.black.opacity(0.28)")) + #expect(drawerContent.contains(".transition(.move(edge: .leading).combined(with: .opacity))")) + #expect(!drawerContent.contains("NavigationSplitView")) + } + + @Test func sidebarKeepsNavigationModelDestinationOnly() throws { + let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8) + let navigationSource = try String(contentsOf: Self.rootTabsNavigationSourceURL(), encoding: .utf8) + let sidebarColumn = try Self.extract( + source, + from: "private var sidebarColumn: some View", + to: "private var sidebarList: some View") + + #expect(source.contains("ForEach(Self.sidebarGroups)")) + #expect(!source.contains("Section(\"Context\")")) + #expect(!source.contains("sidebarAgentMenu")) + #expect(!source.contains("sidebarDeviceMenu")) + #expect(sidebarColumn.contains("self.sidebarIdentityHeader")) + #expect(source.contains("private var sidebarIdentityHeader: some View")) + #expect(source.contains("OpenClawProMark(size: 30")) + #expect(source.contains("Text(\"OpenClaw\")")) + #expect(source.contains("private var sidebarGatewayStatusTitle: String")) + #expect(source.contains("private var sidebarGatewayStatusColor: Color")) + #expect(!sidebarColumn.contains("activeAgent")) + #expect(!source.contains("shouldShowSidebarColumnHeader")) + #expect(!source.contains("private var sidebarColumnHeader: some View")) + #expect(sidebarColumn.contains(".safeAreaPadding(.top, 8)")) + #expect(source.contains(".scrollContentBackground(.hidden)")) + #expect(source.contains(".listStyle(.sidebar)")) + #expect(source.contains("private var sidebarHorizontalSeparator: some View")) + #expect(source.contains("private var sidebarVerticalSeparator: some View")) + #expect(source.contains("1 / UIScreen.main.scale")) + #expect(!source.contains("geometry.size.height >= Self.sidebarListNonScrollingMinimumHeight")) + #expect(!source.contains("private var sidebarListContent: some View")) + #expect(source.contains(".listRowSeparator(.hidden, edges: .all)")) + #expect(source.contains(".listSectionSeparator(.hidden, edges: .all)")) + #expect(source.contains("if self.isSidebarDrawerLayout {")) + #expect(source.contains("private var sidebarFooter: some View")) + #expect(!source.contains("LabeledContent(\"Version\"")) + #expect(navigationSource.contains("SidebarGroup(title: \"CHAT\", destinations: [.chat, .talk])")) + #expect(!navigationSource.contains("title: \"AGENT\"")) + #expect(navigationSource.contains("case settings")) + #expect(!navigationSource.contains("case settingsChannels")) + #expect(!navigationSource.contains("case settingsApprovals")) + #expect(!navigationSource.contains("case settingsPrivacy")) + #expect(navigationSource.contains("SidebarGroup(\n title: \"SETTINGS\"")) + #expect(navigationSource.contains("destinations: [.settings]")) + #expect(!navigationSource.contains("destinations: [.gateway")) + #expect(!navigationSource.contains("SidebarGroup(title: \"REFERENCE\", destinations: [.settings")) + #expect(navigationSource.contains("SidebarGroup(title: \"REFERENCE\", destinations: [.docs])")) + } + + @Test func sidebarRoutesUseDestinationHeadersInsteadOfRepeatedProductBranding() throws { + let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8) + let agentOverviewSource = try String(contentsOf: Self.agentProTabOverviewSourceURL(), encoding: .utf8) + let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8) + let sidebarDetail = try Self.extract( + rootSource, + from: "private var sidebarDetail: some View", + to: "private var sidebarDetailNavigationShell: some View") + + #expect(sidebarDetail.contains("headerTitle: \"Chat\"")) + #expect(sidebarDetail.contains("headerTitle: \"Overview\"")) + #expect(sidebarDetail.contains("headerTitle: \"Agents\"")) + #expect(sidebarDetail.contains("headerTitle: \"Instances\"")) + #expect(!sidebarDetail.contains("headerTitle: \"Nodes\"")) + #expect(sidebarDetail.contains("directRoute: .agents")) + #expect(sidebarDetail.contains("directRoute: .instances")) + #expect(sidebarDetail.contains("directRoute: .dreaming")) + #expect(sidebarDetail.contains("directRoute: .usage")) + #expect(sidebarDetail.contains("directRoute: .cron")) + #expect(!sidebarDetail.contains("initialRoute: .nodes")) + #expect(!sidebarDetail.contains("initialRoute: .usage")) + #expect(!sidebarDetail.contains("initialRoute: .cron")) + #expect(sidebarDetail.contains("headerTitle: \"Dreaming\"")) + #expect(sidebarDetail.contains("headerTitle: \"Usage\"")) + #expect(sidebarDetail.contains("headerTitle: \"Cron Jobs\"")) + #expect(!sidebarDetail.contains("headerTitle: \"OpenClaw\"")) + #expect(agentOverviewSource.contains("OpenClawAdaptiveHeaderRow(")) + #expect(agentOverviewSource.contains("title: self.headerTitle")) + #expect(!agentOverviewSource.contains("Text(\"OpenClaw\")")) + #expect(docsSource.contains("OpenClawAdaptiveHeaderRow(")) + #expect(docsSource.contains("title: \"Docs\"")) + #expect(!docsSource.contains("Text(\"OpenClaw Docs\")")) + } + + @Test func agentsDirectRouteKeepsSingleSidebarControl() throws { + let source = try String(contentsOf: Self.agentProTabSourceURL(), encoding: .utf8) + let destinationsSource = try String(contentsOf: Self.agentProTabDestinationsSourceURL(), encoding: .utf8) + let nodesSource = try String(contentsOf: Self.agentProNodesDestinationSourceURL(), encoding: .utf8) + let dreamingSource = try String(contentsOf: Self.agentProDreamingDestinationSourceURL(), encoding: .utf8) + + #expect(!source.contains("ToolbarItem")) + #expect(source.contains("self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden")) + #expect(destinationsSource.contains("self.directHeaderLeadingAction(for: .instances)")) + #expect(destinationsSource.contains("self.directHeaderLeadingAction(for: .dreaming)")) + #expect(destinationsSource.contains("self.directHeader(\n for: .usage")) + #expect(destinationsSource.contains("self.directHeader(\n for: .cron")) + #expect(destinationsSource.contains("self.directRoute == route ? self.headerLeadingAction : nil")) + #expect(nodesSource.contains("OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)")) + #expect(dreamingSource.contains("OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)")) + } + + @Test func routedHeadersUseSharedAdaptiveLayout() throws { + let componentsSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8) + let featureChromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8) + let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8) + let overviewSource = try String(contentsOf: Self.commandCenterSourceURL(), encoding: .utf8) + let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8) + let agentOverviewSource = try String(contentsOf: Self.agentProTabOverviewSourceURL(), encoding: .utf8) + let settingsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8) + + #expect(componentsSource.contains("struct OpenClawAdaptiveHeaderRow: View")) + #expect(componentsSource.contains("ViewThatFits(in: .horizontal)")) + #expect(componentsSource.contains("private var stackedLayout: some View")) + #expect(componentsSource.contains(".layoutPriority(1)")) + #expect(componentsSource.contains(".fixedSize(horizontal: true, vertical: false)")) + #expect(featureChromeSource.contains("OpenClawAdaptiveHeaderRow(")) + #expect(docsSource.contains("OpenClawAdaptiveHeaderRow(")) + #expect(overviewSource.contains("OpenClawAdaptiveHeaderRow(")) + #expect(chatSource.contains("OpenClawAdaptiveHeaderRow(")) + #expect(agentOverviewSource.contains("OpenClawAdaptiveHeaderRow(")) + #expect(settingsSource.contains("OpenClawAdaptiveHeaderRow(")) + } + + @Test func phoneHubKeepsDocsAsDestinationOnly() throws { + let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8) + + #expect(source.contains("case .docs:")) + #expect(source.contains("OpenClawDocsScreen(")) + #expect(source.contains("headerLeadingAction: self.phoneDetailBackAction")) + #expect(source.contains("gatewayAction: { self.openRootDestination(.gateway) }")) + #expect(!source.contains("Label(\"Docs\", systemImage: \"book\")")) + #expect(!source.contains("https://docs.openclaw.ai")) + } + + @Test func rootShellPreviewMatrixCoversPhoneAndIPadStates() throws { + let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8) + + #expect(source.contains("#Preview(\n \"Shell iPhone portrait\"")) + #expect(source.contains("#Preview(\n \"Shell iPhone landscape\"")) + #expect(source.contains("#Preview(\n \"Shell iPhone connected\"")) + #expect(source.contains("#Preview(\n \"Shell iPhone gateway error\"")) + #expect(source.contains("#Preview(\n \"Shell iPad portrait drawer\"")) + #expect(source.contains("#Preview(\n \"Shell iPad landscape split\"")) + #expect(source.contains("#Preview(\n \"Shell iPad connecting\"")) + #expect(source.contains("#Preview(\n \"Shell iPad gateway error\"")) + } + + @Test func sharedChatPreviewMatrixCoversConnectionStates() throws { + let source = try String(contentsOf: Self.sharedChatPreviewSourceURL(), encoding: .utf8) + + #expect(source.contains("#Preview(\"Chat connected\")")) + #expect(source.contains("#Preview(\"Chat empty\")")) + #expect(source.contains("#Preview(\"Chat loading\")")) + #expect(source.contains("#Preview(\"Chat gateway error\")")) + #expect(source.contains("enum Scenario")) + #expect(source.contains("case connected")) + #expect(source.contains("case empty")) + #expect(source.contains("case loading")) + #expect(source.contains("case error")) + #expect(source.contains("Gateway not connected. Check Tailscale and retry.")) + } + + @Test func phoneHubKeepsContentAboveFloatingTabBar() throws { + let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8) + + #expect(source.contains(".safeAreaPadding(.bottom, self.bottomScrollInset)")) + #expect(!source.contains(".padding(.bottom, self.bottomScrollInset)")) + #expect(!source.contains("bottomViewportInset")) + #expect(!source.contains("bottomTabBarClearance")) + } + + @Test func phoneHubHeaderStaysTaskFirst() throws { + let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8) + + #expect(source.contains("private var gatewayActionRow: some View")) + #expect(source.contains("self.openRootDestination(.gateway)")) + #expect(source.contains("private var phoneDetailBackAction: OpenClawSidebarHeaderAction")) + #expect(source.contains("accessibilityLabel: \"Back to Control\"")) + #expect(source.contains("accessibilityIdentifier: \"OpenClawPhoneDetailBackButton\"")) + #expect(source.contains(".navigationBarBackButtonHidden(true)")) + #expect(source.contains(".toolbar(.hidden, for: .navigationBar)")) + #expect(source.matches(of: /headerLeadingAction: self\.phoneDetailBackAction/).count == 10) + #expect(!source.contains("directRoute: .agents")) + #expect(!source.contains("ToolbarItem(placement: .topBarTrailing)")) + #expect(!source.contains("Image(systemName: \"gearshape\")")) + #expect(!source.contains("self.metric(label:")) + #expect(!source.contains("private func metric(label:")) + } + + @Test func workboardUsesRealGatewayMethods() throws { + let source = try String(contentsOf: Self.iPadWorkboardScreenSourceURL(), encoding: .utf8) + + #expect(source.contains("workboard.cards.list")) + #expect(source.contains("workboard.cards.create")) + #expect(source.contains("workboard.cards.move")) + #expect(source.contains("workboard.cards.archive")) + #expect(source.contains("workboard.cards.dispatch")) + #expect(source.contains(".padding(.bottom, 12)")) + #expect(!source.contains("Workboard gateway contract unavailable")) + #expect(!source.contains("supportsGatewayContract")) + #expect(!source.contains("Compact mobile queue control")) + #expect(!source.contains("Multi-column queue control")) + } + + @Test func workboardCreateActionSurfacesUnavailableReasons() throws { + let source = try String(contentsOf: Self.iPadWorkboardScreenSourceURL(), encoding: .utf8) + let createFunction = try Self.extract( + source, + from: "private func createCard() async -> Bool", + to: "private func move(_ card: IPadWorkboardCard, to status: String) async") + + #expect(source.contains("private var createUnavailableMessage: String?")) + #expect(source.contains("Enter a title to create a card.")) + #expect(source.contains("Card creation is already in progress.")) + #expect(source.contains("private func newCardButton(expands: Bool) -> some View")) + #expect(source.contains("private func beginCreateCard()")) + #expect(source.contains("self.newCardButton(expands: false)")) + #expect(source.contains("self.newCardButton(expands: true)")) + #expect(source.contains("Label(\"New Card\", systemImage: \"plus\")")) + #expect(source.contains(".accessibilityHint(\"Opens card title and notes entry\")")) + #expect(source.contains(".accessibilityHint(self.createUnavailableMessage ?? \"Creates a workboard card\")")) + #expect(source.contains("if await self.createCard()")) + #expect(source.contains(".disabled(self.isCreatingCard)")) + #expect(!source.contains("Button(\"Create\")")) + #expect(!source.contains("TextField(\"New card\"")) + #expect(!source.contains(".disabled(!self.canWrite || self.draftTitle")) + #expect(createFunction.contains("self.errorText = createUnavailableMessage")) + #expect(createFunction.contains("return false")) + #expect(createFunction.contains("return true")) + } + + @Test func taskScopeControlsSendRealGatewayParams() throws { + let source = try Self.iPadTaskFeatureScreensSource() + + #expect(source.contains("private var boardScopeMenu: some View")) + #expect(source.contains("method: \"workboard.boards.list\"")) + #expect(source.contains("IPadWorkboardListParams(boardId: selectedBoardParam)")) + #expect(source.contains("boardId: selectedBoardParam")) + #expect(source + .matches( + of: /method: "workboard\.cards\.dispatch"[\s\S]*?IPadWorkboardListParams\(boardId: selectedBoardParam\)/) + .count == 1) + #expect(source.contains("private var agentScopeMenu: some View")) + #expect(source.contains("IPadSkillProposalListParams(agentId: selectedAgentParam)")) + #expect(source.contains("agentId: selectedAgentParam")) + #expect(!source + .contains( + "params: EmptyParams(),\n timeoutSeconds: 20)\n let response = try JSONDecoder().decode(IPadSkillProposalManifest.self")) + } + + @Test func compactTaskRowsKeepPhoneNativeActions() throws { + let source = try Self.iPadTaskFeatureScreensSource() + let compactControls = try Self.extract( + source, + from: "private var compactQueueControls: some View", + to: "private var compactRefreshButton: some View") + + #expect(source.contains("struct IPadWorkboardQueueRow")) + #expect(source.contains("private var actionMenuItems: some View")) + #expect(source.components(separatedBy: ".contextMenu {").count - 1 >= 2) + #expect(source.components(separatedBy: ".swipeActions(edge: .leading").count - 1 >= 2) + #expect(source.components(separatedBy: ".swipeActions(edge: .trailing").count - 1 >= 2) + #expect(source.contains("@State private var presentedProposalRoute: IPadSkillProposalSheetRoute?")) + #expect(source.contains(".sheet(item: self.$presentedProposalRoute)")) + #expect(source.contains("private func selectProposal(")) + #expect(!source.contains("proposalSheetPresented")) + #expect(source.contains("self.presentedSheet = .card(card)")) + #expect(!source.contains("Label(\"Gateway\", systemImage: \"network\")")) + #expect(!source.contains("Button(\"Gateway\")")) + #expect(!source.contains("actionTitle: self.canRead ? nil : \"Gateway\"")) + #expect(!source.contains("Workboard offline")) + #expect(!source.contains("Workshop offline")) + #expect(!source.contains("Connect gateway to")) + #expect(source.contains("private var compactRefreshButton: some View")) + #expect(source.contains("private var compactBoardScopeMenu: some View")) + #expect(source.contains("Color(uiColor: .secondarySystemGroupedBackground)")) + #expect(source.contains(".allowsHitTesting(false)")) + #expect(compactControls.contains("self.compactRefreshButton")) + #expect(compactControls.contains("self.compactBoardScopeMenu")) + #expect(!compactControls.contains("Self.workboardSubtitle(")) + #expect(!compactControls.contains("Label(\"Refresh\"")) + #expect(compactControls.contains("Label(\"Dispatch\"")) + } + + @Test func skillWorkshopUsesKanbanLanesOnWideIPad() throws { + let source = try String(contentsOf: Self.iPadSkillWorkshopScreenSourceURL(), encoding: .utf8) + let previewSource = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8) + let content = try Self.extract( + source, + from: "private var proposalContent: some View", + to: "private var proposalBoard: some View") + let board = try Self.extract( + source, + from: "private var proposalBoard: some View", + to: "private var proposalList: some View") + + #expect(content.contains("if self.isCompactWidth")) + #expect(content.contains("self.proposalList")) + #expect(content.contains("self.proposalBoard")) + #expect(!content.contains("self.proposalDetail")) + #expect(board.contains("ScrollView(.horizontal)")) + #expect(board.contains("IPadSkillProposalKanbanColumn(")) + #expect(source.contains("private struct IPadSkillProposalKanbanCard")) + #expect(source.contains("static let defaultProposalStatusBoardLanes")) + #expect(source.contains("private func proposals(forLaneStatus status: String)")) + #expect(previewSource.contains("#Preview(\n \"Skill Workshop iPad kanban lanes\"")) + #expect(previewSource.contains("private struct IPadSkillWorkshopKanbanPreview")) + #expect(previewSource.contains("IPadSkillProposalKanbanColumn(")) + #expect(previewSource.contains("status: \"needs-review\"")) + #expect(previewSource.contains("status: \"manual_QA\"")) + } + + @Test func compactTaskRowsHavePopulatedPhonePreviews() throws { + let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8) + + #expect(source.contains("#Preview(\"Workboard phone queue rows\")")) + #expect(source.contains("#Preview(\"Skill Workshop phone queue rows\")")) + #expect(source.contains("private struct IPadWorkboardCompactRowsPreview")) + #expect(source.contains("private struct IPadSkillWorkshopCompactRowsPreview")) + #expect(source.contains("IPadWorkboardPreviewFixtures.cards")) + #expect(source.contains("IPadSkillWorkshopPreviewFixtures.proposals")) + } + + @Test func taskScreenPreviewMatricesCoverPrimaryStates() throws { + let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8) + + #expect(source.contains("#Preview(\"Workboard states\")")) + #expect(source.contains("private struct IPadWorkboardStatesPreview")) + #expect(source.contains("self.previewHeader(\"Connected\")")) + #expect(source.contains("self.previewHeader(\"Empty\")")) + #expect(source.contains("self.previewHeader(\"Loading\")")) + #expect(source.contains("self.previewHeader(\"Error\")")) + #expect(source.contains("title: \"Loading cards\"")) + #expect(source.contains("title: \"Cards unavailable\"")) + #expect(source.contains("IPadWorkboardKanbanColumn(")) + + #expect(source.contains("#Preview(\"Skill Workshop states\")")) + #expect(source.contains("private struct IPadSkillWorkshopStatesPreview")) + #expect(source.contains("self.previewHeader(\"Offline / Error\")")) + #expect(source.contains("title: \"No proposals\"")) + #expect(source.contains("title: \"Workshop offline\"")) + #expect(source.contains("title: \"Proposal unavailable\"")) + #expect(source.contains("#Preview(\n \"Skill Workshop iPad kanban lanes\"")) + #expect(source.contains("private struct IPadSkillWorkshopKanbanPreview")) + #expect(source.contains("\"needs-review\"")) + #expect(source.contains("\"manual_QA\"")) + } + + @Test func activityPreviewMatrixCoversConnectionStates() throws { + let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8) + + #expect(source.contains("#Preview(\"Activity states\")")) + #expect(source.contains("private struct IPadActivityStatesPreview")) + #expect(source.contains("self.previewHeader(\"Connected\")")) + #expect(source.contains("self.previewHeader(\"Loading\")")) + #expect(source.contains("self.previewHeader(\"Empty\")")) + #expect(source.contains("self.previewHeader(\"Error\")")) + #expect(source.contains("title: \"Sessions unavailable\"")) + #expect(source.contains("title: \"No recent sessions\"")) + #expect(source.contains("title: \"Loading sessions\"")) + } + + @Test func routedFeatureScreensReuseSharedProComponents() throws { + let source = try Self.iPadTaskFeatureScreensSource() + let componentsSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8) + let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8) + + #expect(source.contains("ProMetricGrid(metrics: self.metrics)")) + #expect(source.contains("ProPanelHeader(")) + #expect(source.contains("ProStatusRow(")) + #expect(!source.contains("private struct ProMetricGrid")) + #expect(!source.contains("private struct ProMetric")) + #expect(!source.contains("private struct ProPanelHeader")) + #expect(!source.contains("private struct ProStatusRow")) + #expect(!channelsSource.contains("private struct SettingsChannelPanelHeader")) + #expect(!channelsSource.contains("private struct SettingsChannelInfoRow")) + #expect(componentsSource.contains("struct ProMetricGrid")) + #expect(componentsSource.contains("struct ProPanelHeader")) + #expect(componentsSource.contains("struct ProStatusRow")) + } + + @Test func activityScreenStaysSplitFromTaskFeatureScreens() throws { + let taskSource = try Self.iPadTaskFeatureScreensSource() + let activitySource = try String(contentsOf: Self.iPadActivityScreenSourceURL(), encoding: .utf8) + let projectSource = try String(contentsOf: Self.xcodeProjectSourceURL(), encoding: .utf8) + + #expect(activitySource.contains("struct IPadActivityScreen: View")) + #expect(activitySource.contains("IOSGatewayChatTransport(gateway: self.appModel.operatorSession)")) + #expect(activitySource.contains("IPadSidebarScreenChrome(")) + #expect(!taskSource.contains("struct IPadActivityScreen")) + #expect(!taskSource.contains("import OpenClawChatUI")) + #expect(projectSource.contains("IPadActivityScreen.swift in Sources")) + } + + @Test func routedFeatureChromeStaysSplitFromTaskFeatureScreens() throws { + let taskSource = try Self.iPadTaskFeatureScreensSource() + let chromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8) + let projectSource = try String(contentsOf: Self.xcodeProjectSourceURL(), encoding: .utf8) + + #expect(chromeSource.contains("struct IPadSidebarScreenChrome: View")) + #expect(chromeSource.contains("OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)")) + #expect(chromeSource.contains("OpenClawGatewayCompactPill()")) + #expect(!taskSource.contains("struct IPadSidebarScreenChrome")) + #expect(projectSource.contains("IPadSidebarScreenChrome.swift in Sources")) + } + + @Test func routedFeatureChromeKeepsGatewayPillActionable() throws { + let chromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8) + let featureSource = try Self.iPadTaskFeatureScreensSource() + let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8) + + #expect(chromeSource.contains("let gatewayAction: (() -> Void)?")) + #expect(chromeSource.contains("private var gatewayPill: some View")) + #expect(chromeSource.contains("Button(action: gatewayAction)")) + #expect(chromeSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")")) + #expect(featureSource.matches(of: /gatewayAction: self\.openSettings/).count == 2) + #expect(rootSource.contains("IPadActivityScreen(")) + #expect(rootSource + .matches(of: /IPadActivityScreen\([\s\S]*?openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/) + .count == 1) + } + + @Test func routedGatewayPillsOpenGatewaySettings() throws { + let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8) + let agentSource = try String(contentsOf: Self.agentProTabSourceURL(), encoding: .utf8) + let agentOverviewSource = try String(contentsOf: Self.agentProTabOverviewSourceURL(), encoding: .utf8) + let overviewSource = try String(contentsOf: Self.commandCenterSourceURL(), encoding: .utf8) + let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8) + let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8) + let settingsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8) + + #expect(rootSource.matches(of: /openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count >= 2) + #expect(rootSource.matches(of: /gatewayAction: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count == 1) + #expect(!rootSource.contains("showGatewayActions")) + #expect(!rootSource.contains("gatewayActionsDialog")) + #expect(overviewSource.contains("Button(action: self.openSettings)")) + #expect(overviewSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")")) + #expect(agentSource.contains("let openSettings: (() -> Void)?")) + #expect(agentOverviewSource.contains("OpenClawGatewayCompactPill()")) + #expect(agentOverviewSource.contains("Button(action: openSettings)")) + #expect(rootSource + .matches(of: /AgentProTab\([\s\S]*?openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/) + .count >= 3) + #expect(chatSource.contains("let openSettings: (() -> Void)?")) + #expect(chatSource.contains("private var connectionPillButton: some View")) + #expect(docsSource.contains("let gatewayAction: (() -> Void)?")) + #expect(settingsSource.contains("NavigationLink(value: SettingsRoute.gateway)")) + #expect(rootSource.contains("case .settings:")) + #expect(rootSource.contains("SettingsProTab(headerLeadingAction: self.sidebarHeaderLeadingAction)")) + #expect(rootSource.contains("directRoute: self.selectedSidebarDestination.settingsRoute ?? .gateway")) + #expect(rootSource.contains("SettingsProTab(initialRoute: self.selectedSidebarDestination.settingsRoute)")) + #expect(settingsSource.contains("title: \"Channels / Integrations\"")) + #expect(settingsSource.contains("route: .channels")) + #expect(docsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")")) + } + + @Test func gatewaySettingsKeepsPairingTrustDiagnosticsAndTailscaleActions() throws { + let settingsSource = try String(contentsOf: Self.settingsProTabSourceURL(), encoding: .utf8) + let sectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8) + let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8) + let trustSource = try String(contentsOf: Self.gatewayTrustPromptAlertSourceURL(), encoding: .utf8) + let controllerSource = try String(contentsOf: Self.gatewayConnectionControllerSourceURL(), encoding: .utf8) + + #expect(sectionsSource.contains("var gatewayDestination: some View")) + #expect(sectionsSource.contains("self.gatewayActions")) + #expect(sectionsSource.contains("self.manualGatewayCard")) + #expect(sectionsSource.contains("self.gatewaySetupCard")) + #expect(sectionsSource.contains("self.discoveredGatewaysCard")) + #expect(sectionsSource.contains("self.gatewayAdvancedCard")) + #expect(sectionsSource.contains("title: \"Reconnect\"")) + #expect(sectionsSource.contains("Task { await self.reconnectGateway() }")) + #expect(sectionsSource.contains("title: \"Diagnose\"")) + #expect(sectionsSource.contains("Task { await self.runDiagnostics() }")) + #expect(sectionsSource.contains("title: \"Scan QR\"")) + #expect(sectionsSource.contains("self.openGatewayQRScanner()")) + #expect(sectionsSource.contains("title: \"Connect\"")) + #expect(sectionsSource.contains("Task { await self.applySetupCodeAndConnect() }")) + #expect(sectionsSource.contains("Task { await self.connect(gateway) }")) + #expect(sectionsSource.contains("tailnetWarningText")) + #expect(sectionsSource.contains("GatewayProblemBanner(")) + #expect(sectionsSource.contains("Task { await self.handleGatewayProblemPrimaryAction(problem) }")) + + #expect(actionsSource.contains("await self.gatewayController.connectLastKnown()")) + #expect(actionsSource.contains("self.gatewayController.refreshActiveGatewayRegistrationFromSettings()")) + #expect(actionsSource.contains("self.gatewayController.restartDiscovery()")) + #expect(actionsSource.contains("await self.appModel.refreshGatewayOverviewIfConnected()")) + #expect(actionsSource.contains("await TCPProbe.probe(host: trimmed, port: port")) + #expect(actionsSource.contains("Check Tailscale or LAN.")) + #expect(actionsSource.contains("Tailscale is off on this device. Turn it on, then try again.")) + #expect(actionsSource.contains("Run /pair approve in your OpenClaw chat")) + #expect(actionsSource.contains("self.resetOnboarding()")) + #expect(actionsSource.contains("self.gatewayController.trustRotatedGatewayCertificate(from: problem)")) + #expect(actionsSource.contains("await self.retryGatewayConnectionFromProblem()")) + + #expect(settingsSource.contains("GatewayProblemDetailsSheet(")) + #expect(settingsSource.contains("QRScannerView(")) + #expect(trustSource.contains("Trust this gateway?")) + #expect(trustSource.contains("Trust and connect")) + #expect(controllerSource.contains("acceptPendingTrustPrompt()")) + #expect(controllerSource.contains("trustRotatedGatewayCertificate(from problem: GatewayConnectionProblem)")) + } + + @Test func gatewaySettingsPreviewMatrixCoversPrimaryStates() throws { + let supportSource = try String(contentsOf: Self.settingsProTabSupportSourceURL(), encoding: .utf8) + + #expect(supportSource.contains("#Preview(\"Gateway settings states\")")) + #expect(supportSource.contains("private struct SettingsGatewayStatesPreview")) + #expect(supportSource.contains("self.stateSection(\"Connected\")")) + #expect(supportSource.contains("self.stateSection(\"Loading\")")) + #expect(supportSource.contains("self.stateSection(\"Empty\")")) + #expect(supportSource.contains("self.stateSection(\"Error\")")) + #expect(supportSource.contains("GatewayProblemBanner(")) + #expect(supportSource.contains("kind: .pairingRequired")) + #expect(supportSource.contains("Run /pair approve in your OpenClaw chat")) + #expect(supportSource.contains("Tailscale is off on this device. Turn it on, then try again.")) + #expect(supportSource.contains("self.previewButton(\"Scan QR\"")) + #expect(supportSource.contains("self.previewButton(\"Connect\"")) + #expect(supportSource.contains("self.previewButton(\"Reconnect\"")) + #expect(supportSource.contains("self.previewButton(\"Diagnose\"")) + } + + @Test func nativeChatUsesGatewayTransport() throws { + let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8) + let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8) + + #expect(chatSource.contains("IOSGatewayChatTransport(gateway: self.appModel.operatorSession)")) + #expect(channelsSource.contains("Message routing and external channel clients.")) + #expect(channelsSource.contains("\"clickclack\": SettingsChannelFallbackMetadata")) + #expect(channelsSource.contains("label: \"ClickClack\"")) + #expect(channelsSource.contains("Self-hosted chat bot routing.")) + } + + private static func rootTabsSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/RootTabs.swift") + } + + private static func phoneHubSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Design/RootTabsPhoneControlHub.swift") + } + + private static func proComponentsSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Design/OpenClawProComponents.swift") + } + + private static func commandCenterSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Design/CommandCenterTab.swift") + } + + private static func agentProTabSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Design/AgentProTab.swift") + } + + private static func agentProTabOverviewSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Design/AgentProTab+Overview.swift") + } + + private static func agentProTabDestinationsSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Design/AgentProTab+Destinations.swift") + } + + private static func agentProNodesDestinationSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Design/AgentProNodesDestination.swift") + } + + private static func agentProDreamingDestinationSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Design/AgentProDreamingDestination.swift") + } + + private static func rootTabsNavigationSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/RootTabsNavigation.swift") + } + + private static func iPadSidebarFeatureScreensSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Design/IPadSidebarFeatureScreens.swift") + } + + private static func iPadTaskFeatureScreensSource() throws -> String { + try [ + self.iPadWorkboardScreenSourceURL(), + self.iPadSkillWorkshopScreenSourceURL(), + self.iPadSidebarFeatureScreensSourceURL(), + ] + .map { try String(contentsOf: $0, encoding: .utf8) } + .joined(separator: "\n") + } + + private static func iPadWorkboardScreenSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Design/IPadWorkboardScreen.swift") + } + + private static func iPadSkillWorkshopScreenSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Design/IPadSkillWorkshopScreen.swift") + } + + private static func iPadSidebarFeaturePreviewsSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Design/IPadSidebarFeaturePreviews.swift") + } + + private static func iPadActivityScreenSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Design/IPadActivityScreen.swift") + } + + private static func iPadSidebarScreenChromeSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Design/IPadSidebarScreenChrome.swift") + } + + private static func chatProTabSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Design/ChatProTab.swift") + } + + private static func docsSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Design/OpenClawDocsScreen.swift") + } + + private static func settingsProTabSectionsSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Design/SettingsProTabSections.swift") + } + + private static func settingsProTabSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Design/SettingsProTab.swift") + } + + private static func settingsProTabActionsSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Design/SettingsProTabActions.swift") + } + + private static func settingsProTabSupportSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Design/SettingsProTabSupport.swift") + } + + private static func gatewayTrustPromptAlertSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Gateway/GatewayTrustPromptAlert.swift") + } + + private static func gatewayConnectionControllerSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Gateway/GatewayConnectionController.swift") + } + + private static func channelsSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Design/SettingsChannelsDestination.swift") + } + + private static func sharedChatPreviewSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("shared/OpenClawKit/Sources/OpenClawChatUI/ChatView+Previews.swift") + } + + private static func xcodeProjectSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("OpenClaw.xcodeproj/project.pbxproj") + } + + private static func extract(_ source: String, from start: String, to end: String) throws -> String { + let startRange = try #require(source.range(of: start)) + let tail = source[startRange.lowerBound...] + let endRange = try #require(tail.range(of: end)) + return String(tail[.. UIWindow { - let window = UIWindow(frame: UIScreen.main.bounds) + @MainActor private static func host(_ view: some View, size: CGSize? = nil) -> UIWindow { + let frame = CGRect(origin: .zero, size: size ?? UIScreen.main.bounds.size) + let window = UIWindow(frame: frame) window.rootViewController = UIHostingController(rootView: view) window.makeKeyAndVisible() window.rootViewController?.view.setNeedsLayout() @@ -41,18 +42,101 @@ import UIKit } } - @Test @MainActor func rootTabsBuildAViewHierarchy() { - let appModel = NodeAppModel() - let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false) + @Test @MainActor func rootTabsBuildsDeviceOrientationShellMatrix() { + for scenario in Self.rootTabsShellScenarios() { + let appModel = NodeAppModel() + let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false) - let root = RootTabs() + let root = RootTabs() + .environment(appModel) + .environment(appModel.voiceWake) + .environment(gatewayController) + .environment(\.rootTabsUserInterfaceIdiomOverride, scenario.idiom) + .environment(\.horizontalSizeClass, scenario.horizontalSizeClass) + .environment(\.verticalSizeClass, scenario.verticalSizeClass) + + _ = Self.host(root, size: scenario.size) + } + } + + @Test @MainActor func rootTabsBuildGatewayStateViewHierarchies() { + for appModel in Self.rootTabsGatewayStateModels() { + let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false) + + let root = RootTabs() + .environment(appModel) + .environment(appModel.voiceWake) + .environment(gatewayController) + + _ = Self.host(root) + } + } + + @Test @MainActor func phoneControlHubBuildsGatewayStateViewHierarchies() { + for appModel in Self.rootTabsGatewayStateModels() { + let root = RootTabsPhoneControlHub( + groups: RootTabs.phoneControlGroups, + initialDestination: nil, + openRootDestination: { _ in }) + .environment(appModel) + + _ = Self.host(root) + } + } + + @Test @MainActor func phoneControlHubBuildsLandscapeCompactState() { + let appModel = NodeAppModel() + let root = RootTabsPhoneControlHub( + groups: RootTabs.phoneControlGroups, + initialDestination: nil, + openRootDestination: { _ in }) .environment(appModel) - .environment(appModel.voiceWake) - .environment(gatewayController) + .environment(\.horizontalSizeClass, .regular) + .environment(\.verticalSizeClass, .compact) _ = Self.host(root) } + @Test @MainActor func routedSidebarScreensBuildOfflineStates() { + let appModel = NodeAppModel() + let screens: [AnyView] = [ + AnyView(CommandCenterTab(openChat: {}, openSettings: {})), + AnyView(IPadActivityScreen(openChat: {}, openSettings: {})), + AnyView(OpenClawDocsScreen()), + AnyView(IPadWorkboardScreen(openChat: {}, openSettings: {})), + AnyView(IPadSkillWorkshopScreen(openSettings: {})), + AnyView(AgentProTab(directRoute: .agents)), + AnyView(AgentProTab(directRoute: .instances)), + AnyView(CommandSessionsScreen(openChat: {})), + AnyView(AgentProTab(directRoute: .dreaming)), + AnyView(AgentProTab(directRoute: .usage)), + AnyView(AgentProTab(directRoute: .cron)), + ] + + for screen in screens { + let root = NavigationStack { screen } + .environment(appModel) + _ = Self.host(root) + } + } + + @Test @MainActor func taskScreensBuildPhoneLandscapeCompactStates() { + let appModel = NodeAppModel() + let screens: [AnyView] = [ + AnyView(IPadWorkboardScreen(openChat: {}, openSettings: {})), + AnyView(IPadSkillWorkshopScreen(openSettings: {})), + ] + + for screen in screens { + let root = NavigationStack { screen } + .environment(appModel) + .environment(\.horizontalSizeClass, .regular) + .environment(\.verticalSizeClass, .compact) + + _ = Self.host(root) + } + } + @Test @MainActor func voiceWakeWordsViewBuildsAViewHierarchy() { let appModel = NodeAppModel() let root = NavigationStack { VoiceWakeWordsSettingsView() } @@ -64,4 +148,51 @@ import UIKit let root = VoiceWakeToast(command: "openclaw: do something") _ = Self.host(root) } + + @MainActor private static func rootTabsGatewayStateModels() -> [NodeAppModel] { + let offlineModel = NodeAppModel() + + let connectingModel = NodeAppModel() + connectingModel.gatewayStatusText = "Connecting..." + + let connectedModel = NodeAppModel() + connectedModel.enterAppleReviewDemoMode() + + let errorModel = NodeAppModel() + errorModel.gatewayStatusText = "Gateway error: connection refused" + + return [offlineModel, connectingModel, connectedModel, errorModel] + } + + private static func rootTabsShellScenarios() -> [RootTabsShellScenario] { + [ + RootTabsShellScenario( + idiom: .phone, + size: CGSize(width: 393, height: 852), + horizontalSizeClass: .compact, + verticalSizeClass: .regular), + RootTabsShellScenario( + idiom: .phone, + size: CGSize(width: 852, height: 393), + horizontalSizeClass: .regular, + verticalSizeClass: .compact), + RootTabsShellScenario( + idiom: .pad, + size: CGSize(width: 1024, height: 1366), + horizontalSizeClass: .regular, + verticalSizeClass: .regular), + RootTabsShellScenario( + idiom: .pad, + size: CGSize(width: 1366, height: 1024), + horizontalSizeClass: .regular, + verticalSizeClass: .regular), + ] + } + + private struct RootTabsShellScenario { + let idiom: UIUserInterfaceIdiom + let size: CGSize + let horizontalSizeClass: UserInterfaceSizeClass + let verticalSizeClass: UserInterfaceSizeClass + } } diff --git a/apps/ios/Tests/TalkModeConfigParsingTests.swift b/apps/ios/Tests/TalkModeConfigParsingTests.swift index 02f5da7bd951..247f2eaee992 100644 --- a/apps/ios/Tests/TalkModeConfigParsingTests.swift +++ b/apps/ios/Tests/TalkModeConfigParsingTests.swift @@ -1,4 +1,5 @@ import Foundation +import OpenClawKit import Testing @testable import OpenClaw @@ -183,6 +184,132 @@ import Testing #expect(manager._test_gatewayTalkUsesRealtimeRelay()) } + @Test func buildsGenericRealtimeFallbackIssueForDisplay() { + let issue = TalkRuntimeIssue.realtimeUnavailable( + message: "OpenAI API key rejected with 401", + provider: "openai", + model: "gpt-realtime-2", + transport: "gateway-relay", + phase: "start") + + #expect(issue.code == .realtimeUnavailable) + #expect(issue.displayMessage == "OpenAI API key rejected with 401") + #expect(issue.diagnosticSummary.contains("provider: openai")) + #expect(issue.diagnosticSummary.contains("model: gpt-realtime-2")) + #expect(issue.fallbackStatusText == "Listening (iOS Speech fallback)") + #expect(issue.fallbackBannerTitle == "Using iOS Speech fallback") + #expect(issue.fallbackBannerOwnerLabel == "Fallback active") + #expect(issue + .fallbackBannerMessage == + "Realtime voice did not start. Talk is running with iOS speech recognition and TTS.") + #expect(issue.technicalDetails.contains("code: realtime_unavailable")) + } + + @Test func nativeFallbackKeepsRealtimeIssueVisible() { + let manager = TalkModeManager(allowSimulatorCapture: true) + let issue = TalkRuntimeIssue( + code: .realtimeUnavailable, + message: "Realtime closed before it became ready.", + provider: "openai", + model: "gpt-realtime-2", + transport: "gateway-relay", + phase: "connect") + + manager._test_markNativeFallbackActive(after: issue) + + #expect(manager.statusText == "Listening (iOS Speech fallback)") + #expect(manager._test_gatewayTalkActiveModeTitle() == "iOS Speech fallback") + #expect(manager._test_gatewayTalkActiveModeSubtitle() == "Realtime closed before it became ready.") + #expect(manager._test_gatewayTalkLastIssueText()?.contains("phase: connect") == true) + #expect(manager._test_gatewayTalkCurrentFallbackIssue() == issue) + } + + @Test func gatewayTalkIssueDetailsDriveRealtimeFailureDisplay() { + let manager = TalkModeManager(allowSimulatorCapture: true) + let error = GatewayResponseError( + method: "talk.session.create", + code: "UNAVAILABLE", + message: "Error: OpenAI API key rejected with 401", + details: [ + "talkIssue": AnyCodable([ + "code": "realtime_unavailable", + "message": "OpenAI API key rejected with 401", + "provider": "openai", + "model": "gpt-realtime-2", + "transport": "gateway-relay", + "phase": "request", + ]), + ]) + + let issue = manager._test_realtimeIssue(from: error, phase: "start") + + #expect(issue.code == .realtimeUnavailable) + #expect(issue.displayMessage == "OpenAI API key rejected with 401") + #expect(issue.provider == "openai") + #expect(issue.model == "gpt-realtime-2") + #expect(issue.transport == "gateway-relay") + #expect(issue.phase == "request") + } + + @Test func relayStartupIssueSurvivesUntilReadyStatus() { + let manager = TalkModeManager(allowSimulatorCapture: true) + let issue = TalkRuntimeIssue( + code: .realtimeUnavailable, + message: "OpenAI API key rejected with 401", + provider: "openai", + model: "gpt-realtime-2", + transport: "gateway-relay", + phase: "connect") + + manager._test_recordRealtimeIssue(issue) + manager._test_handleRealtimeRelayStatus("Connecting realtime…") + + #expect(manager._test_gatewayTalkActiveModeTitle() == "Realtime unavailable") + #expect(manager._test_gatewayTalkLastIssueText()?.contains("OpenAI API key rejected") == true) + + manager._test_handleRealtimeRelayStatus("Listening (Realtime)") + + #expect(manager.statusText == "Listening (Realtime)") + #expect(manager._test_gatewayTalkLastIssueText() == nil) + #expect(manager._test_gatewayTalkCurrentFallbackIssue() == nil) + } + + @Test func relayCloseClearsActiveRealtimeMode() { + let manager = TalkModeManager(allowSimulatorCapture: true) + + manager._test_handleRealtimeRelayStatus("Listening (Realtime)") + #expect(manager.statusText == "Listening (Realtime)") + #expect(manager._test_gatewayTalkActiveModeTitle() != "Not active") + + manager._test_handleRealtimeRelayStatus("Ready") + + #expect(manager.statusText == "Ready") + #expect(manager._test_gatewayTalkActiveModeTitle() == "Not active") + #expect(manager._test_gatewayTalkActiveModeSubtitle() == nil) + } + + @Test func relayRetryClearsStaleFallbackTriggerButKeepsLastIssueVisible() { + let manager = TalkModeManager(allowSimulatorCapture: true) + let issue = TalkRuntimeIssue( + code: .realtimeUnavailable, + message: "Realtime closed before it became ready.", + provider: "openai", + model: "gpt-realtime-2", + transport: "gateway-relay", + phase: "connect") + + manager._test_recordRealtimeIssue(issue) + manager._test_markNativeFallbackActive(after: issue) + #expect(manager._test_hasPendingRealtimeIssue()) + #expect(manager._test_gatewayTalkCurrentFallbackIssue() == issue) + + manager._test_prepareRealtimeRelayStart() + + #expect(!manager._test_hasPendingRealtimeIssue()) + #expect(manager._test_gatewayTalkCurrentFallbackIssue() == nil) + #expect(manager._test_gatewayTalkLastIssueText()?.contains("Realtime closed before") == true) + } + @Test func mapsWebRTCRealtimeTransportToGatewayRelayOnIOS() { let config: [String: Any] = [ "talk": [ diff --git a/apps/ios/Tests/TalkProStateTests.swift b/apps/ios/Tests/TalkProStateTests.swift index 3d00aee697f4..997905356492 100644 --- a/apps/ios/Tests/TalkProStateTests.swift +++ b/apps/ios/Tests/TalkProStateTests.swift @@ -5,6 +5,7 @@ import Testing @Test func disabledTalkWithoutLoadedConfigCanStartAndRetryLoad() { let state = TalkProState( gatewayConnected: true, + isDemoMode: false, isEnabled: false, statusText: "Offline", isConfigLoaded: false, @@ -23,6 +24,7 @@ import Testing @Test func enabledTalkWithoutLoadedConfigCanBeStopped() { let state = TalkProState( gatewayConnected: true, + isDemoMode: false, isEnabled: true, statusText: "Offline", isConfigLoaded: false, @@ -41,6 +43,7 @@ import Testing @Test func enabledTalkWithLoadedConfigCanBeStopped() { let state = TalkProState( gatewayConnected: true, + isDemoMode: false, isEnabled: true, statusText: "Ready", isConfigLoaded: true, @@ -57,6 +60,7 @@ import Testing @Test func missingScopeTakesPriorityOverUnloadedConfig() { let state = TalkProState( gatewayConnected: true, + isDemoMode: false, isEnabled: false, statusText: "Offline", isConfigLoaded: false, @@ -70,4 +74,25 @@ import Testing #expect(state.primaryAction == .enablePermission) #expect(state.primaryButtonTitle == "Enable Talk") } + + @Test func demoModeKeepsTalkDisabled() { + let state = TalkProState( + gatewayConnected: true, + isDemoMode: true, + isEnabled: true, + statusText: "Ready", + isConfigLoaded: true, + isListening: true, + isSpeaking: true, + isUserSpeechDetected: true, + permissionState: .ready) + + #expect(state.title == "Demo mode only") + #expect(state.chipText == "Demo") + #expect(state.icon == "waveform.slash") + #expect(state.primaryAction == .waiting) + #expect(state.primaryButtonTitle == "Demo Mode Only") + #expect(state.primaryButtonIcon == "lock.fill") + #expect(state.waveformMode(micLevel: 0.8) == .still) + } } diff --git a/apps/ios/fastlane/metadata/en-US/description.txt b/apps/ios/fastlane/metadata/en-US/description.txt index 3a6dedb0bbda..0ca98a1f0c53 100644 --- a/apps/ios/fastlane/metadata/en-US/description.txt +++ b/apps/ios/fastlane/metadata/en-US/description.txt @@ -1,12 +1,12 @@ OpenClaw is a personal AI assistant you run on your own devices. -Pair this iOS app with your OpenClaw Gateway to use your iPhone or iPad as a secure node for chat, voice, approvals, sharing, and device-aware automation. +Pair this iOS app with your OpenClaw Gateway to use your iPhone as a secure node for chat, voice, approvals, sharing, and device-aware automation. What you can do: - Pair with your private OpenClaw Gateway by QR code or setup code -- Chat with your assistant from iPhone or iPad +- Chat with your assistant from iPhone - Use realtime Talk mode and push-to-talk -- Review Gateway action approvals from your iPhone or iPad +- Review Gateway action approvals from your iPhone - Share text, links, and media directly from iOS into OpenClaw - Enable device capabilities such as camera, screen, location, photos, contacts, calendar, and reminders when you choose - Receive push wakes and node status updates for connected workflows @@ -16,4 +16,4 @@ OpenClaw is local-first: you control your gateway, keys, configuration, and perm Getting started: 1) Set up your OpenClaw Gateway 2) Open the iOS app and pair with your gateway -3) Start using chat, Talk mode, approvals, and automations from your iPhone or iPad +3) Start using chat, Talk mode, approvals, and automations from your iPhone diff --git a/apps/ios/fastlane/metadata/en-US/promotional_text.txt b/apps/ios/fastlane/metadata/en-US/promotional_text.txt index a674636ebe33..0df778e5bebf 100644 --- a/apps/ios/fastlane/metadata/en-US/promotional_text.txt +++ b/apps/ios/fastlane/metadata/en-US/promotional_text.txt @@ -1 +1 @@ -Pair your iPhone or iPad with your OpenClaw Gateway for chat, realtime voice, approvals, device capabilities, and private automation. +Pair your iPhone with your OpenClaw Gateway for chat, realtime voice, approvals, device capabilities, and private automation. diff --git a/apps/ios/fastlane/metadata/en-US/release_notes.txt b/apps/ios/fastlane/metadata/en-US/release_notes.txt index 99afd00b10b2..581bab59456e 100644 --- a/apps/ios/fastlane/metadata/en-US/release_notes.txt +++ b/apps/ios/fastlane/metadata/en-US/release_notes.txt @@ -1 +1,3 @@ -Maintenance update for the current OpenClaw release. +OpenClaw is now available on iPhone. + +Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, share content from iOS, and bring device capabilities like camera, location, screen, and notifications into your private automation workflows. diff --git a/apps/ios/fastlane/metadata/review_information/notes.txt b/apps/ios/fastlane/metadata/review_information/notes.txt index 22a99b207ce5..e0e90d86632e 100644 --- a/apps/ios/fastlane/metadata/review_information/notes.txt +++ b/apps/ios/fastlane/metadata/review_information/notes.txt @@ -1 +1,3 @@ -OpenClaw iOS client for gateway-connected workflows. Reviewers can follow the standard onboarding and pairing flow in-app. +OpenClaw normally pairs with a private Gateway. For App Review, tap Set Up Manually on the Connect Gateway screen, paste APPLE-REVIEW-DEMO in Setup Code, then tap Apply Setup Code. This enables local offline demo mode; no Gateway is required. Reviewers can also scan a QR code containing APPLE-REVIEW-DEMO. + +Demo mode marks the app as connected to an Apple Review Demo Gateway and exposes the Chat, Command, Agent, Talk, and Settings surfaces without requiring a running Gateway. Live automation, realtime Talk execution, and external tool calls require pairing with a real OpenClaw Gateway. diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 2b26057250a7..f5b44c3d1872 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -163,12 +163,6 @@ targets: - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - UISupportedInterfaceOrientations~ipad: - - UIInterfaceOrientationPortrait - - UIInterfaceOrientationPortraitUpsideDown - - UIInterfaceOrientationLandscapeLeft - - UIInterfaceOrientationLandscapeRight - OpenClawShareExtension: type: app-extension platform: iOS @@ -189,6 +183,7 @@ targets: ENABLE_APP_INTENTS_METADATA_GENERATION: NO PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_SHARE_BUNDLE_ID)" PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_SHARE_PROFILE)" + TARGETED_DEVICE_FAMILY: "1,2" SWIFT_VERSION: "6.0" SWIFT_STRICT_CONCURRENCY: complete info: @@ -225,6 +220,7 @@ targets: CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)" DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)" PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID)" + TARGETED_DEVICE_FAMILY: "1,2" SWIFT_VERSION: "6.0" SWIFT_STRICT_CONCURRENCY: complete SUPPORTS_LIVE_ACTIVITIES: YES diff --git a/apps/macos/Package.resolved b/apps/macos/Package.resolved index c15459672189..11b658910f02 100644 --- a/apps/macos/Package.resolved +++ b/apps/macos/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "92edc1a12985a9d17dc33bfc8b590ab5f5a9566bb2bdd8debdb79e6a8d445908", + "originHash" : "4f7b315ce0e0a16d150d8d74dce445628c03d8926485ad2f5595e091b4d33440", "pins" : [ { "identity" : "axorcist", @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/steipete/Peekaboo.git", "state" : { - "revision" : "36108b4ea7d7848d616c3cad1e7a03e40d6be857", - "version" : "3.2.1" + "revision" : "1fa8eead7eeac3ff618a3111fc333ae78db043d2", + "version" : "3.5.2" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sparkle-project/Sparkle", "state" : { - "revision" : "066e75a8b3e99962685d6a90cdd5293ebffd9261", - "version" : "2.9.1" + "revision" : "d46d456107feacc80711b21847b82b07bd9fb46e", + "version" : "2.9.3" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", - "version" : "1.12.0" + "revision" : "92448c359f00ebe36ae97d3bd9086f13c7692b5a", + "version" : "1.13.2" } }, { diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index 8dad5aa870ce..5f1736992c34 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -19,7 +19,7 @@ let package = Package( .package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.4.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.10.1"), .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.0"), - .package(url: "https://github.com/steipete/Peekaboo.git", exact: "3.2.1"), + .package(url: "https://github.com/steipete/Peekaboo.git", exact: "3.5.2"), .package(path: "../shared/OpenClawKit"), .package(path: "../swabble"), ], diff --git a/apps/macos/Sources/OpenClaw/CronJobsStore.swift b/apps/macos/Sources/OpenClaw/CronJobsStore.swift index 1dd5668cc9fa..a1a29584937b 100644 --- a/apps/macos/Sources/OpenClaw/CronJobsStore.swift +++ b/apps/macos/Sources/OpenClaw/CronJobsStore.swift @@ -72,7 +72,7 @@ final class CronJobsStore { do { if let status = try? await GatewayConnection.shared.cronStatus() { self.schedulerEnabled = status.enabled - self.schedulerStorePath = status.storePath + self.schedulerStorePath = status.sqlitePath ?? status.storePath self.schedulerNextWakeAtMs = status.nextWakeAtMs } self.jobs = try await GatewayConnection.shared.cronList(includeDisabled: true) diff --git a/apps/macos/Sources/OpenClaw/ExecApprovals.swift b/apps/macos/Sources/OpenClaw/ExecApprovals.swift index 20a2e0644d00..0880e5feaf17 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovals.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovals.swift @@ -1,4 +1,5 @@ import CryptoKit +import Darwin import Foundation import OSLog import Security @@ -229,6 +230,12 @@ enum ExecApprovalsStore { private static let secureStateDirPermissions = 0o700 private static let fileLock = NSRecursiveLock() + private enum LegacyMigrationResult { + case notNeeded + case migrated + case blocked + } + private static func withFileLock(_ body: () throws -> T) rethrows -> T { self.fileLock.lock() defer { self.fileLock.unlock() } @@ -243,6 +250,195 @@ enum ExecApprovalsStore { OpenClawPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path } + private static func legacyStateDirURLs() -> [URL] { + if let home = OpenClawEnv.path("OPENCLAW_HOME") { + var urls = [ + URL(fileURLWithPath: home, isDirectory: true) + .appendingPathComponent(".openclaw", isDirectory: true), + ] + let osHomeURL = FileManager().homeDirectoryForCurrentUser + .appendingPathComponent(".openclaw", isDirectory: true) + if !urls.contains(where: { + $0.standardizedFileURL.path == osHomeURL.standardizedFileURL.path + }) { + urls.append(osHomeURL) + } + return urls + } + return [ + FileManager().homeDirectoryForCurrentUser + .appendingPathComponent(".openclaw", isDirectory: true), + ] + } + + private static func legacyFileURLIfPending() -> URL? { + guard OpenClawEnv.path("OPENCLAW_STATE_DIR") != nil else { return nil } + let targetURL = self.fileURL() + for stateDirURL in self.legacyStateDirURLs() { + let legacyURL = stateDirURL + .appendingPathComponent("exec-approvals.json", isDirectory: false) + guard legacyURL.standardizedFileURL.path != targetURL.standardizedFileURL.path else { + continue + } + guard FileManager().fileExists(atPath: legacyURL.path) else { continue } + guard !FileManager().fileExists(atPath: targetURL.path) else { return nil } + return legacyURL + } + return nil + } + + private static func unmigratedLegacyFallbackFile() -> ExecApprovalsFile { + ExecApprovalsFile( + version: 1, + socket: nil, + defaults: ExecApprovalsDefaults( + security: .deny, + ask: .always, + askFallback: .deny, + autoAllowSkills: nil), + agents: [:]) + } + + private static func isLegacyDefaultSocketPath(_ raw: String, legacyFileURL: URL) -> Bool { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return true } + let expanded = self.expandPath(trimmed) + let legacySocket = legacyFileURL.deletingLastPathComponent() + .appendingPathComponent("exec-approvals.sock", isDirectory: false) + .path + return URL(fileURLWithPath: expanded).standardizedFileURL.path + == URL(fileURLWithPath: legacySocket).standardizedFileURL.path + } + + private static func hasSymlinkParent(_ url: URL) -> Bool { + var cursor = url.deletingLastPathComponent() + let manager = FileManager() + while true { + var isDirectory = ObjCBool(false) + if manager.fileExists(atPath: cursor.path, isDirectory: &isDirectory) { + if (try? manager.destinationOfSymbolicLink(atPath: cursor.path)) != nil { + return true + } + } + let parent = cursor.deletingLastPathComponent() + if parent.path == cursor.path { return false } + cursor = parent + } + } + + private static func archiveMigratedLegacyFile(_ legacyURL: URL) throws -> URL { + let manager = FileManager() + var archiveURL = URL(fileURLWithPath: "\(legacyURL.path).migrated") + if manager.fileExists(atPath: archiveURL.path) { + archiveURL = URL(fileURLWithPath: "\(archiveURL.path)-\(UUID().uuidString)") + } + try manager.moveItem(at: legacyURL, to: archiveURL) + return archiveURL + } + + private static func writeMigratedFileExclusively(_ data: Data, to targetURL: URL) throws -> Bool { + let tempURL = targetURL.deletingLastPathComponent() + .appendingPathComponent(".exec-approvals.migration.\(UUID().uuidString)") + let fd = open(tempURL.path, O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR) + if fd == -1 { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + var closed = false + defer { + if !closed { close(fd) } + } + do { + try data.withUnsafeBytes { rawBuffer in + guard let base = rawBuffer.baseAddress else { return } + var offset = 0 + while offset < rawBuffer.count { + let written = Darwin.write( + fd, + base.advanced(by: offset), + rawBuffer.count - offset) + if written < 0 { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + offset += written + } + } + close(fd) + closed = true + let copied = copyfile( + tempURL.path, + targetURL.path, + nil, + copyfile_flags_t(COPYFILE_EXCL)) + if copied == -1 { + if errno == EEXIST { + try? FileManager().removeItem(at: tempURL) + return false + } + try? FileManager().removeItem(at: targetURL) + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + try? FileManager().removeItem(at: tempURL) + return true + } catch { + try? FileManager().removeItem(at: tempURL) + throw error + } + } + + private static func migrateLegacyFileIfNeeded() -> LegacyMigrationResult { + guard let legacyURL = self.legacyFileURLIfPending() else { return .notNeeded } + let targetURL = self.fileURL() + do { + if self.hasSymlinkParent(targetURL) { + throw NSError(domain: "ExecApprovals", code: 10, userInfo: [ + NSLocalizedDescriptionKey: "target path has a symlink parent", + ]) + } + let data = try Data(contentsOf: legacyURL) + var file = try JSONDecoder().decode(ExecApprovalsFile.self, from: data) + guard file.version == 1 else { + throw NSError(domain: "ExecApprovals", code: 11, userInfo: [ + NSLocalizedDescriptionKey: "unsupported legacy approvals version", + ]) + } + file = self.normalizeIncoming(file) + let rawSocketPath = file.socket?.path? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if self.isLegacyDefaultSocketPath(rawSocketPath, legacyFileURL: legacyURL) { + if file.socket == nil { + file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) + } + file.socket?.path = self.socketPath() + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let migrated = try encoder.encode(file) + self.ensureSecureStateDirectory() + try FileManager().createDirectory( + at: targetURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + if FileManager().fileExists(atPath: targetURL.path) { return .notNeeded } + let created = try self.writeMigratedFileExclusively(migrated, to: targetURL) + if !created { return .notNeeded } + try? FileManager().setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: targetURL.path) + do { + _ = try self.archiveMigratedLegacyFile(legacyURL) + } catch { + self.logger + .warning( + "exec approvals legacy archive failed: \(error.localizedDescription, privacy: .public)") + } + return .migrated + } catch { + self.logger + .error( + "exec approvals legacy migration failed: \(error.localizedDescription, privacy: .public)") + return .blocked + } + } + static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile { let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -278,6 +474,14 @@ enum ExecApprovalsStore { static func readSnapshot() -> ExecApprovalsSnapshot { self.withFileLock { + if self.legacyFileURLIfPending() != nil { + let file = self.unmigratedLegacyFallbackFile() + return ExecApprovalsSnapshot( + path: self.fileURL().path, + exists: false, + hash: self.hashRaw(nil), + file: file) + } let url = self.fileURL() guard FileManager().fileExists(atPath: url.path) else { return ExecApprovalsSnapshot( @@ -322,6 +526,14 @@ enum ExecApprovalsStore { static func loadFile() -> ExecApprovalsFile { self.withFileLock { + if self.legacyFileURLIfPending() != nil { + switch self.migrateLegacyFileIfNeeded() { + case .migrated, .notNeeded: + break + case .blocked: + return self.unmigratedLegacyFallbackFile() + } + } let url = self.fileURL() guard FileManager().fileExists(atPath: url.path) else { return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) @@ -361,6 +573,14 @@ enum ExecApprovalsStore { static func ensureFile() -> ExecApprovalsFile { self.withFileLock { + if self.legacyFileURLIfPending() != nil { + switch self.migrateLegacyFileIfNeeded() { + case .migrated, .notNeeded: + break + case .blocked: + return self.unmigratedLegacyFallbackFile() + } + } self.ensureSecureStateDirectory() let url = self.fileURL() let existed = FileManager().fileExists(atPath: url.path) diff --git a/apps/macos/Sources/OpenClaw/GatewayConnection.swift b/apps/macos/Sources/OpenClaw/GatewayConnection.swift index 33757a134632..f6e4dd1a2dbd 100644 --- a/apps/macos/Sources/OpenClaw/GatewayConnection.swift +++ b/apps/macos/Sources/OpenClaw/GatewayConnection.swift @@ -775,6 +775,7 @@ extension GatewayConnection { struct CronSchedulerStatus: Decodable { let enabled: Bool let storePath: String + let sqlitePath: String? let jobs: Int let nextWakeAtMs: Int? } diff --git a/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift index c431028abfc6..d134b22b349a 100644 --- a/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift +++ b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift @@ -24,6 +24,17 @@ enum HostEnvSanitizer { "NO_COLOR", "FORCE_COLOR", ] + private static let gitAllowProtocolKey = "GIT_ALLOW_PROTOCOL" + private static let gitProtocolFromUserKey = "GIT_PROTOCOL_FROM_USER" + private static let gitProtocolFromUserDisabledValue = "0" + private static let cargoTargetExecutableOverridePattern = + #"^CARGO_TARGET_[A-Z0-9_]+_(LINKER|RUNNER)$"# + private static let gitDefaultAlwaysAllowedProtocols: Set = [ + "git", + "http", + "https", + "ssh", + ] private static func isBlocked(_ upperKey: String) -> Bool { if self.blockedKeys.contains(upperKey) { return true } @@ -37,6 +48,12 @@ enum HostEnvSanitizer { private static func isBlockedOverride(_ upperKey: String) -> Bool { if self.blockedOverrideKeys.contains(upperKey) { return true } + if upperKey.range( + of: self.cargoTargetExecutableOverridePattern, + options: .regularExpression) != nil + { + return true + } return self.blockedOverridePrefixes.contains(where: { upperKey.hasPrefix($0) }) } @@ -82,6 +99,25 @@ enum HostEnvSanitizer { Array(Set(values)).sorted() } + private static func isPermissiveGitProtocolFromUserValue(_ value: String) -> Bool { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if normalized == "true" || normalized == "yes" || normalized == "on" { + return true + } + let isInteger = normalized.range(of: #"^[+-]?[0-9]+$"#, options: .regularExpression) != nil + let isZero = normalized.range(of: #"^[+-]?0+$"#, options: .regularExpression) != nil + return isInteger && !isZero + } + + private static func sanitizeInheritedGitAllowProtocolValue(_ value: String) -> String { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + if normalized.isEmpty { return "" } + let safeProtocols = normalized + .split(separator: ":", omittingEmptySubsequences: false) + .filter { self.gitDefaultAlwaysAllowedProtocols.contains(String($0)) } + return safeProtocols.joined(separator: ":") + } + static func inspectOverrides( overrides: [String: String]?, blockPathOverrides: Bool = true) -> HostEnvOverrideDiagnostics @@ -120,6 +156,22 @@ enum HostEnvSanitizer { let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { continue } let upper = key.uppercased() + // Preserve inherited Git allowlists without widening malformed or unsafe entries by + // deletion. Protocols outside Git's safe default set are removed instead. + if upper == self.gitAllowProtocolKey { + merged[key] = self.sanitizeInheritedGitAllowProtocolValue(value) + continue + } + // Preserve non-permissive Git boolean values. Permissive values must become explicit + // `0` because Git's unset default still permits protocols with policy `user`. + if upper == self.gitProtocolFromUserKey { + if !self.isPermissiveGitProtocolFromUserValue(value) { + merged[key] = value + } else { + merged[key] = self.gitProtocolFromUserDisabledValue + } + continue + } if self.isBlockedInherited(upper) { continue } merged[key] = value } diff --git a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift index 374fecd666c5..cd9d6f2988df 100644 --- a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift +++ b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift @@ -28,6 +28,7 @@ enum HostEnvSecurityPolicy { "AWS_SESSION_TOKEN", "AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET", + "BASHOPTS", "BASH_ENV", "BROWSER", "BUNDLE_GEMFILE", @@ -36,7 +37,9 @@ enum HostEnvSecurityPolicy { "BZR_PLUGIN_PATH", "BZR_SSH", "CARGO_BUILD_RUSTC", + "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", "CARGO_BUILD_RUSTC_WRAPPER", + "CARGO_BUILD_RUSTDOC", "CARGO_HOME", "CATALINA_OPTS", "CC", @@ -70,12 +73,14 @@ enum HostEnvSecurityPolicy { "ERL_ZFLAGS", "EXINIT", "FCEDIT", + "FPATH", "GCONV_PATH", "GEM_HOME", "GEM_PATH", "GH_TOKEN", "GITHUB_TOKEN", "GITLAB_TOKEN", + "GIT_ALLOW_PROTOCOL", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_ASKPASS", "GIT_COMMON_DIR", @@ -87,6 +92,7 @@ enum HostEnvSecurityPolicy { "GIT_INDEX_FILE", "GIT_NAMESPACE", "GIT_OBJECT_DIRECTORY", + "GIT_PROTOCOL_FROM_USER", "GIT_PROXY_COMMAND", "GIT_SEQUENCE_EDITOR", "GIT_SSH", @@ -109,6 +115,8 @@ enum HostEnvSecurityPolicy { "GVIMINIT", "HELM_HOME", "HELM_PLUGINS", + "HGEDITOR", + "HGMERGE", "HGRCPATH", "HOSTALIASES", "IFS", @@ -116,6 +124,7 @@ enum HostEnvSecurityPolicy { "JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS", "JULIA_EDITOR", + "KSH_ENV", "LDFLAGS", "LESSCLOSE", "LESSOPEN", @@ -127,6 +136,7 @@ enum HostEnvSecurityPolicy { "LUA_INIT_5_3", "LUA_INIT_5_4", "LUA_PATH", + "MAKE", "MAKEFLAGS", "MAVEN_OPTS", "MFLAGS", @@ -167,7 +177,10 @@ enum HostEnvSecurityPolicy { "RUBYLIB", "RUBYOPT", "RUBYSHELL", + "RUSTC", + "RUSTC_WORKSPACE_WRAPPER", "RUSTC_WRAPPER", + "RUSTDOC", "RUSTFLAGS", "R_ENVIRON", "R_ENVIRON_USER", @@ -183,6 +196,7 @@ enum HostEnvSecurityPolicy { "SUDO_EDITOR", "SVN_EDITOR", "SVN_SSH", + "TCLLIBPATH", "TF_CLI_CONFIG_FILE", "TF_PLUGIN_CACHE_DIR", "UV_DEFAULT_INDEX", @@ -207,13 +221,16 @@ enum HostEnvSecurityPolicy { static let blockedKeys: Set = [ "ANT_OPTS", + "BASHOPTS", "BASH_ENV", "BROWSER", "BZR_EDITOR", "BZR_PLUGIN_PATH", "BZR_SSH", "CARGO_BUILD_RUSTC", + "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", "CARGO_BUILD_RUSTC_WRAPPER", + "CARGO_BUILD_RUSTDOC", "CATALINA_OPTS", "CC", "CMAKE_CXX_COMPILER", @@ -232,7 +249,9 @@ enum HostEnvSecurityPolicy { "ERL_FLAGS", "ERL_ZFLAGS", "EXINIT", + "FPATH", "GCONV_PATH", + "GIT_ALLOW_PROTOCOL", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", "GIT_DIR", @@ -243,6 +262,7 @@ enum HostEnvSecurityPolicy { "GIT_INDEX_FILE", "GIT_NAMESPACE", "GIT_OBJECT_DIRECTORY", + "GIT_PROTOCOL_FROM_USER", "GIT_SEQUENCE_EDITOR", "GIT_SSL_CAINFO", "GIT_SSL_CAPATH", @@ -253,6 +273,8 @@ enum HostEnvSecurityPolicy { "GRADLE_OPTS", "GVIMINIT", "HELM_PLUGINS", + "HGEDITOR", + "HGMERGE", "HGRCPATH", "HOSTALIASES", "IFS", @@ -260,11 +282,13 @@ enum HostEnvSecurityPolicy { "JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS", "JULIA_EDITOR", + "KSH_ENV", "LUA_INIT", "LUA_INIT_5_1", "LUA_INIT_5_2", "LUA_INIT_5_3", "LUA_INIT_5_4", + "MAKE", "MAKEFLAGS", "MAVEN_OPTS", "MFLAGS", @@ -285,7 +309,10 @@ enum HostEnvSecurityPolicy { "RUBYLIB", "RUBYOPT", "RUBYSHELL", + "RUSTC", + "RUSTC_WORKSPACE_WRAPPER", "RUSTC_WRAPPER", + "RUSTDOC", "R_ENVIRON", "R_ENVIRON_USER", "R_PROFILE", @@ -297,6 +324,7 @@ enum HostEnvSecurityPolicy { "SUDO_ASKPASS", "SVN_EDITOR", "SVN_SSH", + "TCLLIBPATH", "VAGRANT_VAGRANTFILE", "VIMINIT", "_JAVA_OPTIONS" @@ -425,6 +453,11 @@ enum HostEnvSecurityPolicy { "REQUESTS_CA_BUNDLE", "RUSTC_WRAPPER", "RUSTFLAGS", + "RUSTUP_DIST_ROOT", + "RUSTUP_DIST_SERVER", + "RUSTUP_HOME", + "RUSTUP_TOOLCHAIN", + "RUSTUP_UPDATE_ROOT", "R_LIBS_USER", "SSH_ASKPASS", "SSH_AUTH_SOCK", diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift index 08ac2560257f..106d08a43ce4 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift @@ -2,6 +2,43 @@ import Foundation import OpenClawKit import OSLog +struct MacNodeGatewayTLSSessionCache { + private struct Key: Equatable { + let url: URL + let required: Bool + let expectedFingerprint: String? + let allowTOFU: Bool + let storeKey: String? + + init(url: URL, params: GatewayTLSParams) { + self.url = url + self.required = params.required + self.expectedFingerprint = params.expectedFingerprint + self.allowTOFU = params.allowTOFU + self.storeKey = params.storeKey + } + } + + private var cachedKey: Key? + private var cachedBox: WebSocketSessionBox? + + mutating func sessionBox(url: URL, params: GatewayTLSParams) -> WebSocketSessionBox { + let key = Key(url: url, params: params) + if let cachedKey = self.cachedKey, cachedKey == key, let cachedBox = self.cachedBox { + return cachedBox + } + let box = WebSocketSessionBox(session: GatewayTLSPinningSession(params: params)) + self.cachedKey = key + self.cachedBox = box + return box + } + + mutating func invalidate() { + self.cachedKey = nil + self.cachedBox = nil + } +} + @MainActor final class MacNodeModeCoordinator { static let shared = MacNodeModeCoordinator() @@ -11,6 +48,7 @@ final class MacNodeModeCoordinator { private let runtime: MacNodeRuntime private let session: GatewayNodeSession private var autoRepairedTLSFingerprintsByStoreKey: [String: String] = [:] + private var tlsSessionCache = MacNodeGatewayTLSSessionCache() private init() { let session = GatewayNodeSession() @@ -268,7 +306,10 @@ final class MacNodeModeCoordinator { } private func buildSessionBox(url: URL, connectionMode: AppState.ConnectionMode) -> WebSocketSessionBox? { - guard url.scheme?.lowercased() == "wss" else { return nil } + guard url.scheme?.lowercased() == "wss" else { + self.tlsSessionCache.invalidate() + return nil + } let stableID = Self.tlsPinStoreKey(for: url) let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) guard let params = Self.tlsParams( @@ -276,8 +317,10 @@ final class MacNodeModeCoordinator { connectionMode: connectionMode, root: OpenClawConfigFile.loadDict(), storedFingerprint: stored) - else { return nil } - let session = GatewayTLSPinningSession(params: params) - return WebSocketSessionBox(session: session) + else { + self.tlsSessionCache.invalidate() + return nil + } + return self.tlsSessionCache.sessionBox(url: url, params: params) } } diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Window.swift b/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Window.swift index 23133811e80e..39f667cc2741 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Window.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Window.swift @@ -92,7 +92,13 @@ extension VoiceWakeOverlayController { let contentHeight = ceil(used.height + (textInset.height * 2)) let total = contentHeight + self.verticalPadding * 2 - self.model.isOverflowing = total > self.maxHeight + // Defer the overflow state mutation to break the SwiftUI onChange → measuredHeight → + // isOverflowing → re-render → onChange synchronous render loop (fixes #43480). + let overflowing = total > self.maxHeight + DispatchQueue.main.async { [weak self] in + guard let self, self.model.isOverflowing != overflowing else { return } + self.model.isOverflowing = overflowing + } return max(self.minHeight, min(total, self.maxHeight)) } diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift b/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift index 1bc2de9ce9fb..9d7d8de270ba 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift @@ -47,19 +47,20 @@ struct VoiceWakeSettings: View { private var voiceSummaryPanel: some View { let enabled = voiceWakeSupported && self.state.swabbleEnabled let pushToTalk = voiceWakeSupported && self.state.voicePushToTalkEnabled + let statusColor: Color = !voiceWakeSupported ? .orange : enabled || pushToTalk ? .green : .secondary return HStack(alignment: .center, spacing: 14) { ZStack { Circle() - .fill((enabled || pushToTalk ? Color.green : Color.secondary).opacity(0.18)) - Image(systemName: enabled ? "waveform.badge.mic" : "mic.slash") + .fill(statusColor.opacity(0.18)) + Image(systemName: self.voiceSummaryIconName(enabled: enabled)) .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(enabled || pushToTalk ? .green : .secondary) + .foregroundStyle(statusColor) } .frame(width: 46, height: 46) VStack(alignment: .leading, spacing: 4) { - Text(enabled ? "Voice Wake active" : pushToTalk ? "Push-to-talk active" : "Voice controls idle") + Text(self.voiceSummaryTitle(enabled: enabled, pushToTalk: pushToTalk)) .font(.headline) Text(self.voiceSummarySubtitle) .font(.footnote) @@ -84,6 +85,26 @@ struct VoiceWakeSettings: View { } } + private func voiceSummaryIconName(enabled: Bool) -> String { + if !voiceWakeSupported { + return "exclamationmark.triangle.fill" + } + return enabled ? "waveform.badge.mic" : "mic.slash" + } + + private func voiceSummaryTitle(enabled: Bool, pushToTalk: Bool) -> String { + if !voiceWakeSupported { + return "Voice Wake unavailable" + } + if enabled { + return "Voice Wake active" + } + if pushToTalk { + return "Push-to-talk active" + } + return "Voice controls idle" + } + private var voiceSummarySubtitle: String { if !voiceWakeSupported { return "Voice Wake requires macOS 26 or newer." @@ -98,16 +119,31 @@ struct VoiceWakeSettings: View { } private var unsupportedVoiceWakePanel: some View { - HStack(spacing: 10) { + HStack(alignment: .top, spacing: 12) { Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.yellow) - Text("Voice Wake requires macOS 26 or newer.") - .font(.callout.weight(.medium)) + .font(.title3.weight(.semibold)) + .foregroundStyle(.orange) + .frame(width: 28) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 4) { + Text("Voice Wake requires macOS 26 or newer") + .font(.callout.weight(.semibold)) + Text("The Voice Wake and push-to-talk controls are hidden on older macOS versions.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() } - .padding(.horizontal, 14) - .padding(.vertical, 12) - .background(.yellow.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(.orange.opacity(0.18)) + } } var body: some View { @@ -119,72 +155,68 @@ struct VoiceWakeSettings: View { self.voiceSummaryPanel - SettingsCardGroup("Activation") { - SettingsCardToggleRow( - title: "Enable Voice Wake", - subtitle: "Listen for a wake phrase before running voice commands. Recognition runs fully on-device.", - binding: self.voiceWakeBinding) - .disabled(!voiceWakeSupported) + if voiceWakeSupported { + SettingsCardGroup("Activation") { + SettingsCardToggleRow( + title: "Enable Voice Wake", + subtitle: "Listen for a wake phrase before running voice commands. Recognition runs fully on-device.", + binding: self.voiceWakeBinding) - SettingsCardToggleRow( - title: "Trigger Talk Mode", - subtitle: "Start a full voice conversation when a wake phrase is detected.", - binding: self.$state.voiceWakeTriggersTalkMode) - .disabled(!self.state.swabbleEnabled) + SettingsCardToggleRow( + title: "Trigger Talk Mode", + subtitle: "Start a full voice conversation when a wake phrase is detected.", + binding: self.$state.voiceWakeTriggersTalkMode) + .disabled(!self.state.swabbleEnabled) - SettingsCardToggleRow( - title: "Hold Right Option to talk", - subtitle: "Start listening while you hold the key and show the preview overlay.", - binding: self.$state.voicePushToTalkEnabled) - .disabled(!voiceWakeSupported) + SettingsCardToggleRow( + title: "Hold Right Option to talk", + subtitle: "Start listening while you hold the key and show the preview overlay.", + binding: self.$state.voicePushToTalkEnabled) - if self.state.voicePushToTalkEnabled, self.state.talkEnabled { - SettingsCardRow( - title: "Push-to-talk paused", - subtitle: "Push-to-Talk resumes when Talk Mode is turned off.") - { - Image(systemName: "pause.circle.fill") - .foregroundStyle(.orange) + if self.state.voicePushToTalkEnabled, self.state.talkEnabled { + SettingsCardRow( + title: "Push-to-talk paused", + subtitle: "Push-to-Talk resumes when Talk Mode is turned off.") + { + Image(systemName: "pause.circle.fill") + .foregroundStyle(.orange) + } } + + SettingsCardToggleRow( + title: "Play phase-transition sounds", + subtitle: "Play short sounds when Talk Mode switches between listening, thinking, and speaking.", + binding: self.$state.talkPhaseSoundsEnabled) + + SettingsCardToggleRow( + title: "Right Option stops speech", + subtitle: "Tap Right Option to interrupt speech and return to listening.", + binding: self.$state.talkShiftToStopEnabled, + showsDivider: false) } - SettingsCardToggleRow( - title: "Play phase-transition sounds", - subtitle: "Play short sounds when Talk Mode switches between listening, thinking, and speaking.", - binding: self.$state.talkPhaseSoundsEnabled) - .disabled(!voiceWakeSupported) + SettingsCardGroup("Recognition") { + self.localePicker + self.micPicker + self.levelMeter + } - SettingsCardToggleRow( - title: "Right Option stops speech", - subtitle: "Tap Right Option to interrupt speech and return to listening.", - binding: self.$state.talkShiftToStopEnabled, - showsDivider: false) - .disabled(!voiceWakeSupported) - } + SettingsCardGroup("Test") { + VoiceWakeTestCard( + testState: self.$testState, + isTesting: self.$isTesting, + onToggle: self.toggleTest) + .padding(.horizontal, 14) + .padding(.vertical, 12) + } - if !voiceWakeSupported { + self.chimeSection + + self.triggerTable + } else { self.unsupportedVoiceWakePanel } - SettingsCardGroup("Recognition") { - self.localePicker - self.micPicker - self.levelMeter - } - - SettingsCardGroup("Test") { - VoiceWakeTestCard( - testState: self.$testState, - isTesting: self.$isTesting, - onToggle: self.toggleTest) - .padding(.horizontal, 14) - .padding(.vertical, 12) - } - - self.chimeSection - - self.triggerTable - Spacer(minLength: 8) } .settingsDetailContent() @@ -217,9 +249,13 @@ struct VoiceWakeSettings: View { } private func activateLivePreview() { + self.loadTriggerEntries() + guard voiceWakeSupported else { + self.deactivateLivePreview() + return + } self.meterStartupTask?.cancel() self.startMicObserver() - self.loadTriggerEntries() self.meterStartupTask = Task { @MainActor in await self.loadMicsIfNeeded() guard !Task.isCancelled, self.isActive else { return } @@ -244,6 +280,11 @@ struct VoiceWakeSettings: View { } private func scheduleMeterRestart() { + guard voiceWakeSupported else { + self.state.voiceWakeMeterActive = false + Task { await self.meter.stop() } + return + } self.meterStartupTask?.cancel() self.meterStartupTask = Task { @MainActor in guard !Task.isCancelled, self.isActive else { return } @@ -662,7 +703,7 @@ struct VoiceWakeSettings: View { @MainActor private func scheduleMicRefresh() { - guard self.isActive else { return } + guard voiceWakeSupported, self.isActive else { return } MicRefreshSupport.schedule(refreshTask: &self.micRefreshTask) { await self.loadMicsIfNeeded(force: true) await self.restartMeter() @@ -724,6 +765,11 @@ struct VoiceWakeSettings: View { @MainActor private func restartMeter() async { + guard voiceWakeSupported else { + self.state.voiceWakeMeterActive = false + await self.meter.stop() + return + } guard self.isActive else { self.state.voiceWakeMeterActive = false await self.meter.stop() @@ -890,6 +936,7 @@ extension VoiceWakeSettings { _ = view.levelMeter _ = view.triggerTable _ = view.chimeSection + _ = view.unsupportedVoiceWakePanel view.addWord() if let entryId = view.triggerEntries.first?.id { diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift index eaaa452cfa5b..ed899273e234 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift @@ -4,18 +4,85 @@ import Testing @Suite(.serialized) struct ExecApprovalsStoreRefactorTests { + private var realTemporaryDirectory: URL { + let path = FileManager().temporaryDirectory.path + if path.hasPrefix("/var/") { + return URL(fileURLWithPath: "/private\(path)", isDirectory: true) + } + return FileManager().temporaryDirectory.resolvingSymlinksInPath() + } + + private func withLockedEnv( + _ values: [String: String?], + _ body: () async throws -> Void) async throws + { + func restoreEnv(_ values: [String: String?]) { + for (key, value) in values { + if let value { + setenv(key, value, 1) + } else { + unsetenv(key) + } + } + } + + await TestIsolationLock.shared.acquire() + var previousEnv: [String: String?] = [:] + for (key, value) in values { + previousEnv[key] = getenv(key).map { String(cString: $0) } + if let value { + setenv(key, value, 1) + } else { + unsetenv(key) + } + } + + do { + try await body() + restoreEnv(previousEnv) + await TestIsolationLock.shared.release() + } catch { + restoreEnv(previousEnv) + await TestIsolationLock.shared.release() + throw error + } + } + private func withTempStateDir( _ body: @escaping @Sendable (URL) async throws -> Void) async throws { - let stateDir = FileManager().temporaryDirectory + let root = self.realTemporaryDirectory .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager().removeItem(at: stateDir) } + let home = root.appendingPathComponent("home", isDirectory: true) + let stateDir = root.appendingPathComponent("state", isDirectory: true) + defer { try? FileManager().removeItem(at: root) } + try Self.seedCurrentApprovalsFile(in: stateDir) - try await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) { + try await self.withLockedEnv([ + "OPENCLAW_HOME": home.path, + "OPENCLAW_STATE_DIR": stateDir.path, + ]) { try await body(stateDir) } } + private func withTempHomeAndStateDir( + _ body: @escaping @Sendable (URL, URL) async throws -> Void) async throws + { + let root = self.realTemporaryDirectory + .appendingPathComponent("openclaw-home-state-\(UUID().uuidString)", isDirectory: true) + let home = root.appendingPathComponent("home", isDirectory: true) + let stateDir = root.appendingPathComponent("state", isDirectory: true) + defer { try? FileManager().removeItem(at: root) } + + try await self.withLockedEnv([ + "OPENCLAW_HOME": home.path, + "OPENCLAW_STATE_DIR": stateDir.path, + ]) { + try await body(home, stateDir) + } + } + @Test func `ensure file skips rewrite when unchanged`() async throws { try await self.withTempStateDir { _ in @@ -30,6 +97,50 @@ struct ExecApprovalsStoreRefactorTests { } } + @Test + func `ensure file migrates default approvals into custom state dir`() async throws { + try await self.withTempHomeAndStateDir { home, stateDir in + let legacyDir = home.appendingPathComponent(".openclaw", isDirectory: true) + try FileManager().createDirectory( + at: legacyDir, + withIntermediateDirectories: true) + let legacySocket = legacyDir.appendingPathComponent("exec-approvals.sock").path + let legacyFile = legacyDir.appendingPathComponent("exec-approvals.json") + let legacyJson = """ + { + "version": 1, + "socket": { + "path": "\(legacySocket)", + "token": "legacy-token" + }, + "defaults": { + "security": "deny", + "ask": "always" + }, + "agents": { + "main": { + "allowlist": [{ "pattern": "git status" }] + } + } + } + """ + try Data(legacyJson.utf8).write(to: legacyFile) + + let file = ExecApprovalsStore.ensureFile() + let targetURL = ExecApprovalsStore.fileURL() + + #expect(targetURL.path == stateDir.appendingPathComponent("exec-approvals.json").path) + #expect(FileManager().fileExists(atPath: targetURL.path)) + #expect(file.socket?.path == stateDir.appendingPathComponent("exec-approvals.sock").path) + #expect(file.socket?.token == "legacy-token") + #expect(file.defaults?.security == .deny) + #expect(file.defaults?.ask == .always) + #expect(file.agents?["main"]?.allowlist?.map(\.pattern) == ["git status"]) + #expect(!FileManager().fileExists(atPath: legacyFile.path)) + #expect(FileManager().fileExists(atPath: "\(legacyFile.path).migrated")) + } + } + @Test func `update allowlist accepts basename pattern`() async throws { try await self.withTempStateDir { _ in @@ -86,4 +197,19 @@ struct ExecApprovalsStoreRefactorTests { } return identifier } + + private static func seedCurrentApprovalsFile(in stateDir: URL) throws { + try FileManager().createDirectory(at: stateDir, withIntermediateDirectories: true) + let file = ExecApprovalsFile( + version: 1, + socket: ExecApprovalsSocketConfig( + path: stateDir.appendingPathComponent("exec-approvals.sock").path, + token: "test-token"), + defaults: nil, + agents: [:]) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(file) + .write(to: stateDir.appendingPathComponent("exec-approvals.json")) + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift index c534352090ae..da489e8bc885 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift @@ -89,6 +89,41 @@ struct MacNodeModeCoordinatorTests { #expect(params.allowTOFU == false) } + @Test func `tls session cache reuses session box for unchanged params`() throws { + let url = try #require(URL(string: "wss://gateway.example.com")) + var cache = MacNodeGatewayTLSSessionCache() + let params = try #require(MacNodeModeCoordinator.tlsParams( + for: url, + connectionMode: .remote, + root: ["gateway": ["remote": ["tlsFingerprint": "sha256:configured"]]], + storedFingerprint: "stored")) + + let first = cache.sessionBox(url: url, params: params) + let second = cache.sessionBox(url: url, params: params) + + #expect(ObjectIdentifier(first.session) == ObjectIdentifier(second.session)) + } + + @Test func `tls session cache rebuilds session box when params change`() throws { + let url = try #require(URL(string: "wss://gateway.example.com")) + var cache = MacNodeGatewayTLSSessionCache() + let firstParams = try #require(MacNodeModeCoordinator.tlsParams( + for: url, + connectionMode: .remote, + root: ["gateway": ["remote": ["tlsFingerprint": "sha256:configured"]]], + storedFingerprint: "stored")) + let secondParams = try #require(MacNodeModeCoordinator.tlsParams( + for: url, + connectionMode: .remote, + root: ["gateway": ["remote": ["tlsFingerprint": "sha256:rotated"]]], + storedFingerprint: "stored")) + + let first = cache.sessionBox(url: url, params: firstParams) + let second = cache.sessionBox(url: url, params: secondParams) + + #expect(ObjectIdentifier(first.session) != ObjectIdentifier(second.session)) + } + @Test func `auto repairs trusted tailscale serve pin mismatch`() throws { let url = try #require(URL(string: "wss://gateway.example.ts.net")) let failure = GatewayTLSValidationFailure( diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift index d11369a3784e..8d40b277e1d4 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift @@ -45,6 +45,7 @@ struct OpenClawChatComposer: View { let assistantAvatarText: String? let assistantAvatarTint: Color? let composerChrome: OpenClawChatView.ComposerChrome + let isComposerEnabled: Bool let messagePlaceholder: String? let talkControl: OpenClawChatTalkControl? @@ -108,6 +109,12 @@ struct OpenClawChatComposer: View { .onAppear { self.shouldFocusTextView = true } + #else + .onChange(of: self.isComposerEnabled) { _, isEnabled in + if !isEnabled { + self.isFocused = false + } + } #endif } @@ -204,6 +211,7 @@ struct OpenClawChatComposer: View { .accessibilityLabel("Attachments") .buttonStyle(.plain) .controlSize(.small) + .disabled(!self.isComposerEnabled) } else { Button { self.pickFilesMac() @@ -214,6 +222,7 @@ struct OpenClawChatComposer: View { .accessibilityLabel("Attachments") .buttonStyle(.bordered) .controlSize(.small) + .disabled(!self.isComposerEnabled) } #else if self.composerChrome == .clean { @@ -224,6 +233,7 @@ struct OpenClawChatComposer: View { .accessibilityLabel("Attachments") .buttonStyle(.plain) .controlSize(.small) + .disabled(!self.isComposerEnabled) .onChange(of: self.pickerItems) { _, newItems in Task { await self.loadPhotosPickerItems(newItems) } } @@ -235,6 +245,7 @@ struct OpenClawChatComposer: View { .accessibilityLabel("Attachments") .buttonStyle(.bordered) .controlSize(.small) + .disabled(!self.isComposerEnabled) .onChange(of: self.pickerItems) { _, newItems in Task { await self.loadPhotosPickerItems(newItems) } } @@ -486,10 +497,12 @@ struct OpenClawChatComposer: View { ChatComposerTextView( text: self.$viewModel.input, shouldFocus: self.$shouldFocusTextView, + isEnabled: self.isComposerEnabled, onSend: { - self.viewModel.send() + self.sendDraftIfEnabled() }, onPasteImageAttachment: { data, fileName, mimeType in + guard self.isComposerEnabled else { return } self.viewModel.addImageAttachment(data: data, fileName: fileName, mimeType: mimeType) }) .frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight) @@ -504,7 +517,7 @@ struct OpenClawChatComposer: View { .lineLimit(1...4) .submitLabel(.send) .onSubmit { - self.viewModel.send() + self.sendDraftIfEnabled() } .frame( minHeight: self.textMinHeight, @@ -514,6 +527,7 @@ struct OpenClawChatComposer: View { .padding(.horizontal, self.cleanFieldTextInset) .padding(.vertical, self.composerChrome == .clean ? 0 : 6) .focused(self.$isFocused) + .disabled(!self.isComposerEnabled) #endif } } @@ -542,7 +556,7 @@ struct OpenClawChatComposer: View { .disabled(self.viewModel.isAborting) } else { Button { - self.viewModel.send() + self.sendDraftIfEnabled() } label: { if self.viewModel.isSending { ProgressView().controlSize(.mini) @@ -556,14 +570,14 @@ struct OpenClawChatComposer: View { .frame(width: self.sendButtonSize, height: self.sendButtonSize) .background( RoundedRectangle(cornerRadius: self.sendButtonCornerRadius, style: .continuous) - .fill(self.viewModel.canSend ? self.sendButtonFill : Color.secondary + .fill(self.canSendMessage ? self.sendButtonFill : Color.secondary .opacity(0.32))) .overlay( RoundedRectangle(cornerRadius: self.sendButtonCornerRadius, style: .continuous) - .strokeBorder(Color.white.opacity(self.viewModel.canSend ? 0.18 : 0.08), lineWidth: 1)) + .strokeBorder(Color.white.opacity(self.canSendMessage ? 0.18 : 0.08), lineWidth: 1)) .contentShape(RoundedRectangle(cornerRadius: self.sendButtonCornerRadius, style: .continuous)) .accessibilityLabel("Send message") - .disabled(!self.viewModel.canSend) + .disabled(!self.canSendMessage) } } } @@ -641,6 +655,10 @@ struct OpenClawChatComposer: View { self.userAccent ?? OpenClawChatTheme.userBubble } + private var canSendMessage: Bool { + self.isComposerEnabled && self.viewModel.canSend + } + private var connectionStatusText: String { self.connectionOK ? "Gateway connected" : "Connecting..." } @@ -656,6 +674,7 @@ struct OpenClawChatComposer: View { #if os(macOS) private func pickFilesMac() { + guard self.isComposerEnabled else { return } let panel = NSOpenPanel() panel.title = "Select image attachments" panel.allowsMultipleSelection = true @@ -668,6 +687,7 @@ struct OpenClawChatComposer: View { } private func handleDrop(_ providers: [NSItemProvider]) -> Bool { + guard self.isComposerEnabled else { return false } let fileProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) } guard !fileProviders.isEmpty else { return false } for item in fileProviders { @@ -684,6 +704,10 @@ struct OpenClawChatComposer: View { } #else private func loadPhotosPickerItems(_ items: [PhotosPickerItem]) async { + guard self.isComposerEnabled else { + self.pickerItems = [] + return + } for item in items { do { guard let data = try await item.loadTransferable(type: Data.self) else { continue } @@ -699,6 +723,11 @@ struct OpenClawChatComposer: View { self.pickerItems = [] } #endif + + private func sendDraftIfEnabled() { + guard self.isComposerEnabled else { return } + self.viewModel.send() + } } #if os(macOS) @@ -708,6 +737,7 @@ import UniformTypeIdentifiers private struct ChatComposerTextView: NSViewRepresentable { @Binding var text: String @Binding var shouldFocus: Bool + var isEnabled: Bool var onSend: () -> Void var onPasteImageAttachment: (_ data: Data, _ fileName: String, _ mimeType: String) -> Void @@ -743,10 +773,15 @@ private struct ChatComposerTextView: NSViewRepresentable { func updateNSView(_ scrollView: NSScrollView, context: Context) { guard let textView = scrollView.documentView as? ChatComposerNSTextView else { return } textView.onPasteImageAttachment = self.onPasteImageAttachment + textView.isEditable = self.isEnabled + textView.isSelectable = self.isEnabled - if self.shouldFocus, let window = scrollView.window { + if self.shouldFocus, self.isEnabled, let window = scrollView.window { window.makeFirstResponder(textView) self.shouldFocus = false + } else if !self.isEnabled, scrollView.window?.firstResponder == textView { + scrollView.window?.makeFirstResponder(nil) + self.shouldFocus = false } let isEditing = scrollView.window?.firstResponder == textView diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView+Previews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView+Previews.swift new file mode 100644 index 000000000000..47a8fe853ea5 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView+Previews.swift @@ -0,0 +1,256 @@ +import Foundation +import OpenClawKit +import SwiftUI + +private struct OpenClawChatPreviewTransport: OpenClawChatTransport { + enum Scenario { + case connected + case empty + case loading + case error + } + + let scenario: Scenario + + init(scenario: Scenario = .connected) { + self.scenario = scenario + } + + func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { + switch self.scenario { + case .connected: + break + case .empty: + return OpenClawChatHistoryPayload( + sessionKey: sessionKey, + sessionId: "preview-empty-session", + messages: [], + thinkingLevel: "medium") + case .loading: + try await Task.sleep(nanoseconds: 60_000_000_000) + return OpenClawChatHistoryPayload( + sessionKey: sessionKey, + sessionId: "preview-loading-session", + messages: [], + thinkingLevel: "medium") + case .error: + throw NSError( + domain: "OpenClawChatPreviewTransport", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Gateway not connected. Check Tailscale and retry."]) + } + + return OpenClawChatHistoryPayload( + sessionKey: sessionKey, + sessionId: "preview-session", + messages: [ + Self.message( + role: "user", + text: "Can you check the gateway status and summarize anything risky?", + timestamp: 1), + Self.message( + role: "assistant", + text: "Gateway is reachable. The only notable item is that push relay is still using local distribution, so device tests should stay on the local lane.", + timestamp: 2), + Self.toolCall( + id: "tool-preview-1", + name: "gateway.status", + arguments: ["deep": AnyCodable(true)], + timestamp: 3), + Self.toolResult( + toolCallId: "tool-preview-1", + name: "gateway.status", + text: "status=ok, channels=ios,macos, lastHeartbeat=12s", + timestamp: 4), + ], + thinkingLevel: "medium") + } + + func listModels() async throws -> [OpenClawChatModelChoice] { + [ + OpenClawChatModelChoice( + modelID: "gpt-5.5", + name: "GPT-5.5", + provider: "openai", + contextWindow: 400_000), + OpenClawChatModelChoice( + modelID: "sonnet-4.6", + name: "Claude Sonnet 4.6", + provider: "anthropic", + contextWindow: 200_000), + ] + } + + func sendMessage( + sessionKey _: String, + message _: String, + thinking _: String, + idempotencyKey: String, + attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse + { + OpenClawChatSendResponse(runId: idempotencyKey, status: "ok") + } + + func listSessions(limit _: Int?) async throws -> OpenClawChatSessionsListResponse { + OpenClawChatSessionsListResponse( + ts: 0, + path: nil, + count: 2, + defaults: OpenClawChatSessionsDefaults( + modelProvider: "openai", + model: "gpt-5.5", + contextTokens: 400_000, + thinkingLevels: [ + OpenClawChatThinkingLevelOption(id: "off", label: "off"), + OpenClawChatThinkingLevelOption(id: "medium", label: "medium"), + OpenClawChatThinkingLevelOption(id: "high", label: "high"), + ], + thinkingDefault: "medium", + mainSessionKey: "main"), + sessions: [ + Self.session(key: "main", displayName: "Main", updatedAt: 2), + Self.session(key: "ios-preview", displayName: "iOS preview", updatedAt: 1), + ]) + } + + func requestHealth(timeoutMs _: Int) async throws -> Bool { + switch self.scenario { + case .connected, .empty, .loading: + true + case .error: + false + } + } + + func events() -> AsyncStream { + AsyncStream { continuation in + continuation.finish() + } + } + + func setActiveSessionKey(_: String) async throws {} + + private static func message(role: String, text: String, timestamp: Double) -> AnyCodable { + AnyCodable([ + "role": role, + "content": [["type": "text", "text": text]], + "timestamp": timestamp, + ]) + } + + private static func toolCall( + id: String, + name: String, + arguments: [String: AnyCodable], + timestamp: Double) -> AnyCodable + { + AnyCodable([ + "role": "assistant", + "content": [ + [ + "type": "toolCall", + "id": id, + "name": name, + "arguments": AnyCodable(arguments), + ], + ], + "timestamp": timestamp, + ]) + } + + private static func toolResult( + toolCallId: String, + name: String, + text: String, + timestamp: Double) -> AnyCodable + { + AnyCodable([ + "role": "tool", + "content": [["type": "text", "text": text]], + "timestamp": timestamp, + "toolCallId": toolCallId, + "toolName": name, + ]) + } + + private static func session( + key: String, + displayName: String, + updatedAt: Double) -> OpenClawChatSessionEntry + { + OpenClawChatSessionEntry( + key: key, + kind: nil, + displayName: displayName, + surface: "ios", + subject: nil, + room: nil, + space: nil, + updatedAt: updatedAt, + sessionId: nil, + systemSent: nil, + abortedLastRun: nil, + thinkingLevel: "medium", + verboseLevel: nil, + inputTokens: 2500, + outputTokens: 900, + totalTokens: 3400, + modelProvider: "openai", + model: "gpt-5.5", + contextTokens: 400_000) + } +} + +#Preview("Chat") { + OpenClawChatPreview(scenario: .connected) +} + +#Preview("Chat connected") { + OpenClawChatPreview(scenario: .connected) +} + +#Preview("Chat empty") { + OpenClawChatPreview( + scenario: .empty, + sessionKey: "empty-preview") +} + +#Preview("Chat loading") { + OpenClawChatPreview( + scenario: .loading, + sessionKey: "loading-preview") +} + +#Preview("Chat gateway error") { + OpenClawChatPreview( + scenario: .error, + sessionKey: "error-preview") +} + +#Preview("Onboarding chat") { + OpenClawChatView( + viewModel: OpenClawChatViewModel( + sessionKey: "ios-preview", + transport: OpenClawChatPreviewTransport()), + showsSessionSwitcher: false, + style: .onboarding, + markdownVariant: .standard, + userAccent: .blue) +} + +private struct OpenClawChatPreview: View { + let scenario: OpenClawChatPreviewTransport.Scenario + var sessionKey: String = "main" + + var body: some View { + OpenClawChatView( + viewModel: OpenClawChatViewModel( + sessionKey: self.sessionKey, + transport: OpenClawChatPreviewTransport(scenario: self.scenario)), + showsSessionSwitcher: true, + style: .standard, + markdownVariant: .standard, + userAccent: .blue, + showsAssistantTrace: true) + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift index 76a77db7da11..0d44c13317e1 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift @@ -34,6 +34,7 @@ public struct OpenClawChatView: View { private let assistantAvatarTint: Color? private let showsAssistantAvatars: Bool private let composerChrome: ComposerChrome + private let isComposerEnabled: Bool private let messagePlaceholder: String? private let emptyAssistantIntro: String? private let talkControl: OpenClawChatTalkControl? @@ -73,6 +74,7 @@ public struct OpenClawChatView: View { assistantAvatarTint: Color? = nil, showsAssistantAvatars: Bool = true, composerChrome: ComposerChrome = .full, + isComposerEnabled: Bool = true, messagePlaceholder: String? = nil, emptyAssistantIntro: String? = nil, talkControl: OpenClawChatTalkControl? = nil) @@ -89,6 +91,7 @@ public struct OpenClawChatView: View { self.assistantAvatarTint = assistantAvatarTint self.showsAssistantAvatars = showsAssistantAvatars self.composerChrome = composerChrome + self.isComposerEnabled = isComposerEnabled self.messagePlaceholder = messagePlaceholder self.emptyAssistantIntro = emptyAssistantIntro self.talkControl = talkControl @@ -128,16 +131,14 @@ public struct OpenClawChatView: View { VStack(spacing: 0) { self.messageList .padding(.horizontal, Layout.outerPaddingHorizontal) - } - .padding(.top, Layout.outerPaddingVertical) - .frame(maxWidth: .infinity) - .frame(maxHeight: .infinity, alignment: .top) - .safeAreaInset(edge: .bottom, spacing: 0) { self.composer .padding(.horizontal, Layout.composerPaddingHorizontal) .padding(.top, Layout.stackSpacing) .padding(.bottom, Layout.outerPaddingVertical) } + .padding(.top, Layout.outerPaddingVertical) + .frame(maxWidth: .infinity) + .frame(maxHeight: .infinity, alignment: .top) #endif } @@ -151,6 +152,7 @@ public struct OpenClawChatView: View { assistantAvatarText: self.assistantAvatarText, assistantAvatarTint: self.assistantAvatarTint, composerChrome: self.composerChrome, + isComposerEnabled: self.isComposerEnabled, messagePlaceholder: self.messagePlaceholder, talkControl: self.talkControl) } @@ -177,12 +179,15 @@ public struct OpenClawChatView: View { #if !os(macOS) .scrollDismissesKeyboard(.interactively) #endif + .safeAreaInset(edge: .top, spacing: 0) { + self.messageListNoticeBanner + } // Keep the scroll pinned to the bottom for new messages. - .scrollPosition(id: self.$scrollPosition, anchor: .bottom) - .onChange(of: self.scrollPosition) { _, position in - guard let position else { return } - self.isPinnedToBottom = position == self.scrollerBottomID - } + .scrollPosition(id: self.$scrollPosition, anchor: .bottom) + .onChange(of: self.scrollPosition) { _, position in + guard let position else { return } + self.isPinnedToBottom = position == self.scrollerBottomID + } if self.viewModel.isLoading, self.composerChrome == .full { ProgressView() @@ -266,7 +271,7 @@ public struct OpenClawChatView: View { .frame(maxWidth: .infinity, alignment: .leading) } - if self.composerChrome == .clean, let error = self.activeErrorText, !self.hasVisibleMessageListContent { + if let error = self.inlineCleanErrorText { let presentation = self.errorPresentation(for: error) ChatNoticeCard( systemImage: presentation.systemImage, @@ -345,23 +350,13 @@ public struct OpenClawChatView: View { EmptyView() } else if self.showsCleanLoadingPlaceholder { EmptyView() + } else if self.inlineCleanErrorText != nil { + EmptyView() } else if let error = self.activeErrorText { - let presentation = self.errorPresentation(for: error) if self.hasVisibleMessageListContent { - VStack(spacing: 0) { - ChatNoticeBanner( - systemImage: presentation.systemImage, - title: presentation.title, - message: error, - tint: presentation.tint, - dismiss: { self.viewModel.errorText = nil }, - refresh: { self.viewModel.refresh() }) - Spacer(minLength: 0) - } - .padding(.horizontal, 10) - .padding(.top, 8) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + EmptyView() } else { + let presentation = self.errorPresentation(for: error) ChatNoticeCard( systemImage: presentation.systemImage, title: presentation.title, @@ -395,6 +390,13 @@ public struct OpenClawChatView: View { return text } + private var inlineCleanErrorText: String? { + guard self.composerChrome == .clean, !self.hasVisibleMessageListContent else { + return nil + } + return self.activeErrorText + } + private var hasVisibleMessageListContent: Bool { if !self.visibleMessages.isEmpty { return true @@ -413,6 +415,28 @@ public struct OpenClawChatView: View { return false } + @ViewBuilder + private var messageListNoticeBanner: some View { + if let error = self.activeErrorText, + self.hasVisibleMessageListContent, + !self.viewModel.isLoading, + self.visibleEmptyAssistantIntro == nil, + !self.showsCleanLoadingPlaceholder + { + let presentation = self.errorPresentation(for: error) + ChatNoticeBanner( + systemImage: presentation.systemImage, + title: presentation.title, + message: error, + tint: presentation.tint, + dismiss: { self.viewModel.errorText = nil }, + refresh: { self.viewModel.refresh() }) + .padding(.horizontal, 10) + .padding(.top, 8) + .padding(.bottom, 8) + } + } + private var showsCleanLoadingPlaceholder: Bool { self.composerChrome == .clean && self.viewModel.isLoading && diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift index 9fcd6722f2bd..d37f8616479c 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -40,11 +40,19 @@ public final class OpenClawChatViewModel { @ObservationIgnored private nonisolated(unsafe) var eventTask: Task? + @ObservationIgnored + private nonisolated(unsafe) var bootstrapTask: Task? private var pendingRuns = Set() { didSet { self.pendingRunCount = self.pendingRuns.count } } private var pendingLocalUserEchoMessageIDsByRunID: [String: UUID] = [:] + private var sessionGeneration: UInt64 = 0 + private var bootstrapGeneration: UInt64 = 0 + // A newer same-session history request only invalidates older responses after it applies. + // Failed later refreshes must not drop the last successful pending-run history payload. + private var lastIssuedHistoryRequestID: UInt64 = 0 + private var latestAppliedHistoryRequestID: UInt64 = 0 @ObservationIgnored private nonisolated(unsafe) var pendingRunTimeoutTasks: [String: Task] = [:] @@ -72,6 +80,37 @@ public final class OpenClawChatViewModel { private var lastCompactAt: Date? private let compactCooldown: TimeInterval = 60 + private enum SessionSwitchIntent { + case userInitiated + case externalSync + } + + private struct SessionSnapshot { + var key: String + var generation: UInt64 + } + + private struct BootstrapContext { + var id: UInt64 + var historyRequest: HistoryRequest + + var session: SessionSnapshot { + self.historyRequest.session + } + } + + private struct HistoryRequest { + var id: UInt64 + var session: SessionSnapshot + var latestUserTurn: LatestUserTurn? + } + + private struct LatestUserTurn { + var refreshKey: String? + var occurrence: Int + var timestamp: Double? + } + private var pendingToolCallsById: [String: OpenClawChatPendingToolCall] = [:] { didSet { self.pendingToolCalls = self.pendingToolCallsById.values @@ -116,17 +155,18 @@ public final class OpenClawChatViewModel { deinit { self.eventTask?.cancel() + self.bootstrapTask?.cancel() for (_, task) in self.pendingRunTimeoutTasks { task.cancel() } } public func load() { - Task { await self.bootstrap() } + self.startBootstrap() } public func refresh() { - Task { await self.bootstrap() } + self.startBootstrap() } public func resumeFromForeground() { @@ -147,11 +187,16 @@ public final class OpenClawChatViewModel { } public func refreshSessions(limit: Int? = nil) { - Task { await self.fetchSessions(limit: limit) } + let context = self.currentSessionSnapshot() + Task { await self.fetchSessions(limit: limit, sessionSnapshot: context) } } public func switchSession(to sessionKey: String) { - Task { await self.performSwitchSession(to: sessionKey) } + self.applySessionSwitch(to: sessionKey, intent: .userInitiated) + } + + public func syncSession(to sessionKey: String) { + self.applySessionSwitch(to: sessionKey, intent: .externalSync) } public func selectThinkingLevel(_ level: String) { @@ -216,7 +261,7 @@ public final class OpenClawChatViewModel { } public var defaultModelLabel: String { - guard let defaultModelID = self.normalizedModelSelectionID(self.sessionDefaults?.model) else { + guard let defaultModelID = normalizedModelSelectionID(sessionDefaults?.model) else { return "Default" } return "Default: \(self.modelLabel(for: defaultModelID))" @@ -261,7 +306,91 @@ public final class OpenClawChatViewModel { self.diagnosticsLog?(message) } - private func bootstrap() async { + private func currentSessionSnapshot() -> SessionSnapshot { + SessionSnapshot(key: self.sessionKey, generation: self.sessionGeneration) + } + + private func isCurrentSession(_ snapshot: SessionSnapshot) -> Bool { + self.sessionKey == snapshot.key && self.sessionGeneration == snapshot.generation + } + + private func isCurrentBootstrap(_ context: BootstrapContext) -> Bool { + self.bootstrapGeneration == context.id && self.isCurrentSession(context.session) + } + + private func canApplyHistory(_ request: HistoryRequest) -> Bool { + request.id >= self.latestAppliedHistoryRequestID && self.isCurrentSession(request.session) + } + + private func advanceSessionGeneration() { + self.sessionGeneration &+= 1 + } + + private func beginHistoryRequest( + for sessionSnapshot: SessionSnapshot? = nil, + captureLatestUserTurn: Bool = true) -> HistoryRequest + { + self.lastIssuedHistoryRequestID &+= 1 + return HistoryRequest( + id: self.lastIssuedHistoryRequestID, + session: sessionSnapshot ?? self.currentSessionSnapshot(), + latestUserTurn: captureLatestUserTurn ? Self.latestUserTurn(in: self.messages) : nil) + } + + private func markHistoryRequestApplied(_ request: HistoryRequest) { + self.latestAppliedHistoryRequestID = max(self.latestAppliedHistoryRequestID, request.id) + } + + @discardableResult + private func applyHistoryPayload( + _ payload: OpenClawChatHistoryPayload, + for request: HistoryRequest, + preservingOptimisticLocalMessages: Bool, + syncThinkingOptions: Bool = false) -> Bool + { + guard self.canApplyHistory(request) else { return false } + let incoming = Self.decodeMessages(payload.messages ?? []) + self.messages = if preservingOptimisticLocalMessages { + Self.reconcileRunRefreshMessages( + previous: self.messages, + incoming: incoming, + pendingLocalUserEchoIDs: Set(self.pendingLocalUserEchoMessageIDsByRunID.values)) + } else { + Self.reconcileMessageIDs(previous: self.messages, incoming: incoming) + } + self.prunePendingLocalUserEchoMessageIDs() + self.sessionId = payload.sessionId + // Incomplete refreshes can arrive before durable assistant history. + // The latest visible user turn must survive answered before it can reject older replies. + let canInvalidateOlderHistory = if let latestUserTurn = request.latestUserTurn { + Self.hasAnsweredUser(latestUserTurn, in: self.messages) + } else { + !Self.hasUnansweredLatestUser(in: self.messages) + } + if canInvalidateOlderHistory { + self.markHistoryRequestApplied(request) + } + let appliedThinkingLevel = !self.prefersExplicitThinkingLevel + ? Self.normalizedThinkingLevel(payload.thinkingLevel) + : nil + if let level = appliedThinkingLevel { + self.thinkingLevel = level + } + if syncThinkingOptions || appliedThinkingLevel != nil { + self.syncThinkingLevelOptions() + } + return true + } + + private func startBootstrap(sessionKey requestedSessionKey: String? = nil) { + let sessionKey = requestedSessionKey ?? self.sessionKey + guard sessionKey == self.sessionKey else { return } + self.bootstrapGeneration &+= 1 + let historyRequest = self.beginHistoryRequest(captureLatestUserTurn: requestedSessionKey == nil) + let context = BootstrapContext( + id: bootstrapGeneration, + historyRequest: historyRequest) + self.bootstrapTask?.cancel() self.isLoading = true self.errorText = nil self.healthOK = false @@ -269,43 +398,75 @@ public final class OpenClawChatViewModel { self.pendingToolCallsById = [:] self.streamingAssistantText = nil self.sessionId = nil - defer { self.isLoading = false } - do { - do { - try await self.transport.setActiveSessionKey(self.sessionKey) - } catch { - // Best-effort only; history/send/health still work without push events. - } + self.bootstrapTask = Task { [weak self] in + guard let self else { return } + await self.bootstrap(context: context) + } + } - let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey) - self.messages = Self.reconcileMessageIDs( - previous: self.messages, - incoming: Self.decodeMessages(payload.messages ?? [])) - self.prunePendingLocalUserEchoMessageIDs() - self.sessionId = payload.sessionId - if !self.prefersExplicitThinkingLevel, - let level = Self.normalizedThinkingLevel(payload.thinkingLevel) - { - self.thinkingLevel = level + private func bootstrap(context: BootstrapContext) async { + guard self.isCurrentBootstrap(context) else { return } + defer { + if self.isCurrentBootstrap(context) { + self.isLoading = false } - self.syncThinkingLevelOptions() - await self.pollHealthIfNeeded(force: true) - await self.fetchSessions(limit: 50) - await self.fetchModels() + } + do { + await self.syncActiveSessionSubscription(startingWith: context.session.key) + guard self.isCurrentBootstrap(context) else { return } + + let payload = try await transport.requestHistory(sessionKey: context.session.key) + guard self.isCurrentBootstrap(context) else { return } + _ = self.applyHistoryPayload( + payload, + for: context.historyRequest, + preservingOptimisticLocalMessages: false, + syncThinkingOptions: true) + await self.pollHealthIfNeeded(force: true, sessionSnapshot: context.session) + guard self.isCurrentBootstrap(context) else { return } + await self.fetchSessions(limit: 50, sessionSnapshot: context.session) + guard self.isCurrentBootstrap(context) else { return } + await self.fetchModels(sessionSnapshot: context.session) + guard self.isCurrentBootstrap(context) else { return } self.errorText = nil } catch { + guard self.isCurrentBootstrap(context) else { return } self.errorText = error.localizedDescription chatUILogger.error("bootstrap failed \(error.localizedDescription, privacy: .public)") } } + private func syncActiveSessionSubscription(startingWith sessionKey: String) async { + var nextSessionKey = sessionKey + while true { + do { + // Subscribe requests are gateway side effects. If a stale request finishes + // after a newer switch, immediately reassert the latest visible session. + try await self.transport.setActiveSessionKey(nextSessionKey) + } catch { + let currentSessionKey = self.sessionKey + guard currentSessionKey != nextSessionKey else { + // Best-effort only; history/send/health still work without push events. + return + } + nextSessionKey = currentSessionKey + continue + } + let currentSessionKey = self.sessionKey + guard currentSessionKey != nextSessionKey else { return } + nextSessionKey = currentSessionKey + } + } + private func refreshPendingRunAfterForeground() async { guard self.pendingRunCount > 0 else { return } + let context = self.beginHistoryRequest() self.logDiagnostic( - "chat.ui foreground refresh sessionKey=\(self.sessionKey) " + "chat.ui foreground refresh sessionKey=\(context.session.key) " + "pending=\(self.pendingRunCount)") - await self.refreshHistoryAfterRun() - await self.pollHealthIfNeeded(force: true) + await self.refreshHistoryAfterRun(historyRequest: context) + await self.pollHealthIfNeeded(force: true, sessionSnapshot: context.session) + guard self.isCurrentSession(context.session) else { return } if self.hasAssistantMessageAfterLatestUser() { self.clearPendingRuns(reason: nil) self.pendingToolCallsById = [:] @@ -398,7 +559,7 @@ public final class OpenClawChatViewModel { private func prunePendingLocalUserEchoMessageIDs() { guard !self.pendingLocalUserEchoMessageIDsByRunID.isEmpty else { return } - let visibleMessageIDs = Set(self.messages.map(\.id)) + let visibleMessageIDs = Set(messages.map(\.id)) self.pendingLocalUserEchoMessageIDsByRunID = self.pendingLocalUserEchoMessageIDsByRunID.filter { self.pendingRuns.contains($0.key) && visibleMessageIDs.contains($0.value) } @@ -406,7 +567,7 @@ public final class OpenClawChatViewModel { private func adoptPendingLocalUserEcho(incoming: OpenClawChatMessage) -> Bool { guard let incomingKey = Self.userRefreshIdentityKey(for: incoming) else { return false } - guard let matchIndex = self.messages.lastIndex(where: { existing in + guard let matchIndex = messages.lastIndex(where: { existing in self.pendingLocalUserEchoMessageIDsByRunID.values.contains(existing.id) && Self.userRefreshIdentityKey(for: existing) == incomingKey }) else { @@ -474,7 +635,8 @@ public final class OpenClawChatViewModel { private static func reconcileRunRefreshMessages( previous: [OpenClawChatMessage], - incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage] + incoming: [OpenClawChatMessage], + pendingLocalUserEchoIDs: Set) -> [OpenClawChatMessage] { guard !previous.isEmpty else { return incoming } guard !incoming.isEmpty else { return previous } @@ -490,42 +652,50 @@ public final class OpenClawChatViewModel { var remainingIncomingUserRefreshCounts = countKeys( reconciled.compactMap(Self.userRefreshIdentityKey(for:))) - var lastMatchedPreviousIndex: Int? - for (index, message) in previous.enumerated() { - if let key = Self.messageIdentityKey(for: message), - incomingIdentityKeys.contains(key) - { - lastMatchedPreviousIndex = index + // Exact history rows own their incoming user count before local echo matching. + // Otherwise repeated same-text sends can consume the canonical row twice. + for message in previous { + guard let identityKey = Self.messageIdentityKey(for: message), + incomingIdentityKeys.contains(identityKey), + let userKey = Self.userRefreshIdentityKey(for: message), + let remaining = remainingIncomingUserRefreshCounts[userKey], + remaining > 0 + else { continue } - if let userKey = Self.userRefreshIdentityKey(for: message), - let remaining = remainingIncomingUserRefreshCounts[userKey], - remaining > 0 - { - remainingIncomingUserRefreshCounts[userKey] = remaining - 1 - lastMatchedPreviousIndex = index - } + remainingIncomingUserRefreshCounts[userKey] = remaining - 1 } - let trailingUserMessages = (lastMatchedPreviousIndex != nil - ? previous.suffix(from: previous.index(after: lastMatchedPreviousIndex!)) - : ArraySlice(previous)) - .filter { message in - guard message.role.lowercased() == "user" else { return false } - guard let key = Self.userRefreshIdentityKey(for: message) else { return false } - let remaining = remainingIncomingUserRefreshCounts[key] ?? 0 - if remaining > 0 { - remainingIncomingUserRefreshCounts[key] = remaining - 1 - return false - } - return true - } + let lastCanonicalPreviousIndex = previous.lastIndex { message in + guard let identityKey = Self.messageIdentityKey(for: message) else { return false } + return incomingIdentityKeys.contains(identityKey) + } + let trailingLocalCandidates = lastCanonicalPreviousIndex.map { index in + previous[previous.index(after: index)...] + } ?? [] - guard !trailingUserMessages.isEmpty else { + let pendingLocalUsers = previous.filter { message in + message.role.lowercased() == "user" && pendingLocalUserEchoIDs.contains(message.id) + } + let trailingLocalUsers = trailingLocalCandidates.filter { message in + guard message.role.lowercased() == "user" else { return false } + guard let identityKey = Self.messageIdentityKey(for: message) else { return true } + guard !incomingIdentityKeys.contains(identityKey) else { return false } + guard let userKey = Self.userRefreshIdentityKey(for: message) else { return true } + let remaining = remainingIncomingUserRefreshCounts[userKey] ?? 0 + if remaining > 0 { + remainingIncomingUserRefreshCounts[userKey] = remaining - 1 + return false + } + return true + } + let optimisticUserMessages = pendingLocalUsers + trailingLocalUsers + + guard !optimisticUserMessages.isEmpty else { return reconciled } - for message in trailingUserMessages { + for message in optimisticUserMessages { guard let messageTimestamp = message.timestamp else { reconciled.append(message) continue @@ -604,14 +774,17 @@ public final class OpenClawChatViewModel { return } - let sessionKey = self.sessionKey + let sessionSnapshot = self.currentSessionSnapshot() + let sessionKey = sessionSnapshot.key if !self.healthOK { - await self.pollHealthIfNeeded(force: true) + await self.pollHealthIfNeeded(force: true, sessionSnapshot: sessionSnapshot) + guard self.isCurrentSession(sessionSnapshot) else { return } } self.isSending = true self.errorText = nil + defer { self.isSending = false } let runId = UUID().uuidString let messageText = trimmed.isEmpty && !self.attachments.isEmpty ? "See attached." : trimmed let thinkingLevel = self.thinkingLevel @@ -674,15 +847,17 @@ public final class OpenClawChatViewModel { do { await self.waitForPendingModelPatches(in: sessionKey) + guard self.isCurrentSession(sessionSnapshot) else { return } self.logDiagnostic( "chat.ui transport send start sessionKey=\(sessionKey) " + "localRunId=\(runId)") - let response = try await self.transport.sendMessage( + let response = try await transport.sendMessage( sessionKey: sessionKey, message: messageText, thinking: thinkingLevel, idempotencyKey: runId, attachments: encodedAttachments) + guard self.isCurrentSession(sessionSnapshot) else { return } self.logDiagnostic( "chat.ui transport send accepted sessionKey=\(sessionKey) " + "localRunId=\(runId) remoteRunId=\(response.runId)") @@ -693,21 +868,24 @@ public final class OpenClawChatViewModel { self.pendingLocalUserEchoMessageIDsByRunID[response.runId] = pendingUserMessageID self.armPendingRunTimeout(runId: response.runId) } - await self.refreshHistoryAfterRun() + 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, - sessionKey: sessionKey, + sessionSnapshot: sessionSnapshot, userMessageTimestamp: userMessageTimestamp) self.armRunCompletionRefresh( runId: response.runId, - sessionKey: sessionKey, + sessionSnapshot: sessionSnapshot, userMessageTimestamp: userMessageTimestamp) } } catch { + guard self.isCurrentSession(sessionSnapshot) else { return } self.pendingLocalUserEchoMessageIDsByRunID[runId] = nil self.clearPendingRun(runId) self.errorText = error.localizedDescription @@ -716,8 +894,6 @@ public final class OpenClawChatViewModel { + "localRunId=\(runId) error=\(error.localizedDescription)") chatUILogger.error("chat transport send failed \(error.localizedDescription, privacy: .public)") } - - self.isSending = false } private func performAbort() async { @@ -726,7 +902,7 @@ public final class OpenClawChatViewModel { self.isAborting = true defer { self.isAborting = false } - let runIds = Array(self.pendingRuns) + let runIds = Array(pendingRuns) for runId in runIds { do { try await self.transport.abortRun(sessionKey: self.sessionKey, runId: runId) @@ -736,9 +912,10 @@ public final class OpenClawChatViewModel { } } - private func fetchSessions(limit: Int?) async { + private func fetchSessions(limit: Int?, sessionSnapshot: SessionSnapshot? = nil) async { do { - let res = try await self.transport.listSessions(limit: limit) + let res = try await transport.listSessions(limit: limit) + if let sessionSnapshot, !self.isCurrentSession(sessionSnapshot) { return } self.sessions = res.sessions self.sessionDefaults = res.defaults self.syncSelectedModel() @@ -748,23 +925,34 @@ public final class OpenClawChatViewModel { } } - private func fetchModels() async { + private func fetchModels(sessionSnapshot: SessionSnapshot? = nil) async { do { - self.modelChoices = try await self.transport.listModels() + let modelChoices = try await transport.listModels() + if let sessionSnapshot, !self.isCurrentSession(sessionSnapshot) { return } + self.modelChoices = modelChoices self.syncSelectedModel() } catch { // Best-effort. } } - private func performSwitchSession(to sessionKey: String) async { + private func applySessionSwitch(to sessionKey: String, intent: SessionSwitchIntent) { let next = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !next.isEmpty else { return } guard next != self.sessionKey else { return } + self.advanceSessionGeneration() self.sessionKey = next - self.onSessionChanged?(next) + if intent == .userInitiated { + self.onSessionChanged?(next) + } self.modelSelectionID = Self.defaultModelSelectionID - await self.bootstrap() + self.messages = [] + self.pendingLocalUserEchoMessageIDsByRunID.removeAll() + self.sessionId = nil + self.pendingToolCallsById = [:] + self.streamingAssistantText = nil + self.clearPendingRuns(reason: nil) + self.startBootstrap(sessionKey: next) } private func performStartNewSession() async { @@ -772,7 +960,7 @@ public final class OpenClawChatViewModel { let parentSessionKey = self.sessionKey let next: String do { - let created = try await self.transport.createSession( + let created = try await transport.createSession( key: requested, label: nil, parentSessionKey: parentSessionKey) @@ -788,6 +976,7 @@ public final class OpenClawChatViewModel { self.errorText = error.localizedDescription return } + self.advanceSessionGeneration() self.sessionKey = next self.onSessionChanged?(next) self.modelSelectionID = Self.defaultModelSelectionID @@ -798,7 +987,7 @@ public final class OpenClawChatViewModel { self.streamingAssistantText = nil self.clearPendingRuns(reason: nil) self.errorText = nil - await self.bootstrap() + self.startBootstrap() } private static func isUnsupportedCreateSessionError(_ error: Error) -> Bool { @@ -810,17 +999,17 @@ public final class OpenClawChatViewModel { private func performReset() async { self.isLoading = true self.errorText = nil - defer { self.isLoading = false } do { try await self.transport.resetSession(sessionKey: self.sessionKey) } catch { + self.isLoading = false self.errorText = error.localizedDescription chatUILogger.error("session reset failed \(error.localizedDescription, privacy: .public)") return } - await self.bootstrap() + self.startBootstrap() } private func performCompact() async { @@ -830,7 +1019,7 @@ public final class OpenClawChatViewModel { return } if let lastCompactAt, - Date().timeIntervalSince(lastCompactAt) < self.compactCooldown + Date().timeIntervalSince(lastCompactAt) < compactCooldown { self.errorText = "Please wait before compacting this session again." return @@ -840,13 +1029,13 @@ public final class OpenClawChatViewModel { self.isLoading = true self.errorText = nil defer { - self.isLoading = false self.isCompacting = false } do { try await self.transport.compactSession(sessionKey: self.sessionKey) } catch { + self.isLoading = false self.errorText = "Unable to compact the session. Please try again." let nsError = error as NSError chatUILogger.error( @@ -855,8 +1044,8 @@ public final class OpenClawChatViewModel { return } - self.lastCompactAt = Date() - await self.bootstrap() + lastCompactAt = Date() + self.startBootstrap() } private func performSelectThinkingLevel(_ level: String) async { @@ -943,7 +1132,7 @@ public final class OpenClawChatViewModel { } private func endModelPatch(for sessionKey: String) { - let remaining = max(0, (self.inFlightModelPatchCountsBySession[sessionKey] ?? 0) - 1) + let remaining = max(0, (inFlightModelPatchCountsBySession[sessionKey] ?? 0) - 1) if remaining == 0 { self.inFlightModelPatchCountsBySession.removeValue(forKey: sessionKey) let waiters = self.modelPatchWaitersBySession.removeValue(forKey: sessionKey) ?? [] @@ -965,7 +1154,7 @@ public final class OpenClawChatViewModel { private func syncThinkingLevelOptions() { let currentSession = self.sessions.first(where: { $0.key == self.sessionKey }) var options = self.resolvedThinkingLevelOptions(for: currentSession) - if let current = Self.normalizedThinkingLevel(self.thinkingLevel) { + if let current = Self.normalizedThinkingLevel(thinkingLevel) { options = Self.withCurrentThinkingOption(options, current: current) } self.thinkingLevelOptions = options @@ -983,7 +1172,7 @@ public final class OpenClawChatViewModel { } ?? true if defaultsMatch, - let levels = Self.normalizedThinkingLevelOptions(self.sessionDefaults?.thinkingLevels), + let levels = Self.normalizedThinkingLevelOptions(sessionDefaults?.thinkingLevels), !levels.isEmpty { return levels @@ -994,7 +1183,7 @@ public final class OpenClawChatViewModel { } if defaultsMatch, - let options = Self.thinkingOptions(from: self.sessionDefaults?.thinkingOptions), + let options = Self.thinkingOptions(from: sessionDefaults?.thinkingOptions), !options.isEmpty { return options @@ -1104,7 +1293,7 @@ public final class OpenClawChatViewModel { guard !trimmed.isEmpty else { return nil } if let provider = Self.normalizedProvider(provider) { let providerQualified = Self.providerQualifiedModelSelectionID(modelID: trimmed, provider: provider) - if let match = self.modelChoices.first(where: { + if let match = modelChoices.first(where: { $0.selectionID == providerQualified || ($0.modelID == trimmed && Self.normalizedProvider($0.provider) == provider) }) { @@ -1132,9 +1321,9 @@ public final class OpenClawChatViewModel { private func generatedNewSessionKey() -> String { let baseKey = "ios-\(UUID().uuidString.lowercased())" - guard let agentID = Self.agentID(fromSessionKey: self.sessionKey) ?? - Self.agentID(fromSessionKey: self.resolvedMainSessionKey) ?? - self.sessions.lazy.compactMap({ Self.agentID(fromSessionKey: $0.key) }).first + guard let agentID = Self.agentID(fromSessionKey: sessionKey) ?? + Self.agentID(fromSessionKey: resolvedMainSessionKey) ?? + sessions.lazy.compactMap({ Self.agentID(fromSessionKey: $0.key) }).first else { return baseKey } @@ -1169,11 +1358,12 @@ public final class OpenClawChatViewModel { } private func resolvedSessionModelIdentity(forSelectionID selectionID: String) - -> (modelID: String?, modelProvider: String?) { - guard let modelRef = self.modelRef(forSelectionID: selectionID) else { + -> (modelID: String?, modelProvider: String?) + { + guard let modelRef = modelRef(forSelectionID: selectionID) else { return (nil, nil) } - if let choice = self.modelChoices.first(where: { $0.selectionID == modelRef }) { + if let choice = modelChoices.first(where: { $0.selectionID == modelRef }) { return (choice.modelID, Self.normalizedProvider(choice.provider)) } return (modelRef, nil) @@ -1194,7 +1384,7 @@ public final class OpenClawChatViewModel { } private func updateCurrentSessionThinkingLevel(_ thinkingLevel: String?, sessionKey: String) { - guard let index = self.sessions.firstIndex(where: { $0.key == sessionKey }) else { return } + guard let index = sessions.firstIndex(where: { $0.key == sessionKey }) else { return } let current = self.sessions[index] self.sessions[index] = OpenClawChatSessionEntry( key: current.key, @@ -1227,7 +1417,7 @@ public final class OpenClawChatViewModel { sessionKey: String, syncSelection: Bool) { - if let index = self.sessions.firstIndex(where: { $0.key == sessionKey }) { + if let index = sessions.firstIndex(where: { $0.key == sessionKey }) { let current = self.sessions[index] self.sessions[index] = OpenClawChatSessionEntry( key: current.key, @@ -1283,7 +1473,8 @@ public final class OpenClawChatViewModel { case let .health(ok): self.healthOK = ok case .tick: - Task { await self.pollHealthIfNeeded(force: false) } + let context = self.currentSessionSnapshot() + Task { await self.pollHealthIfNeeded(force: false, sessionSnapshot: context) } case let .chat(chat): self.handleChatEvent(chat) case let .sessionMessage(message): @@ -1293,9 +1484,10 @@ public final class OpenClawChatViewModel { case .seqGap: self.errorText = nil self.clearPendingRuns(reason: nil) + let context = self.beginHistoryRequest() Task { - await self.refreshHistoryAfterRun() - await self.pollHealthIfNeeded(force: true) + await self.refreshHistoryAfterRun(historyRequest: context) + await self.pollHealthIfNeeded(force: true, sessionSnapshot: context.session) } } } @@ -1350,7 +1542,8 @@ public final class OpenClawChatViewModel { self.streamingAssistantText = nil self.pendingToolCallsById = [:] self.appendFinalChatMessageIfPresent(chat) - Task { await self.refreshHistoryAfterRun() } + let context = self.beginHistoryRequest() + Task { await self.refreshHistoryAfterRun(historyRequest: context) } default: break } @@ -1370,7 +1563,8 @@ public final class OpenClawChatViewModel { self.pendingToolCallsById = [:] self.streamingAssistantText = nil self.appendFinalChatMessageIfPresent(chat) - Task { await self.refreshHistoryAfterRun() } + let context = self.beginHistoryRequest() + Task { await self.refreshHistoryAfterRun(historyRequest: context) } default: break } @@ -1488,7 +1682,8 @@ public final class OpenClawChatViewModel { } self.pendingToolCallsById = [:] self.streamingAssistantText = nil - Task { await self.refreshHistoryAfterRun() } + let context = self.beginHistoryRequest() + Task { await self.refreshHistoryAfterRun(historyRequest: context) } } private static func lowercasedAgentEventString(_ value: AnyCodable?) -> String? { @@ -1522,15 +1717,19 @@ public final class OpenClawChatViewModel { return "Chat failed" } - private func armPostSendRefreshFallback(runId: String, sessionKey: String, userMessageTimestamp: Double) { + private func armPostSendRefreshFallback( + runId: String, + sessionSnapshot: SessionSnapshot, + userMessageTimestamp: Double) + { Task { [weak self] in for delayMs in Self.postSendRefreshDelaysMs { try? await Task.sleep(nanoseconds: delayMs * 1_000_000) let shouldContinue = await self?.refreshIfPending( runId: runId, - sessionKey: sessionKey, + sessionSnapshot: sessionSnapshot, after: userMessageTimestamp, - diagnostic: "chat.ui refresh fallback sessionKey=\(sessionKey) " + diagnostic: "chat.ui refresh fallback sessionKey=\(sessionSnapshot.key) " + "runId=\(runId) delayMs=\(delayMs)") guard shouldContinue == true else { return @@ -1539,36 +1738,45 @@ public final class OpenClawChatViewModel { } } - private func armRunCompletionRefresh(runId: String, sessionKey: String, userMessageTimestamp: Double) { - let timeoutMs = Int(self.pendingRunTimeoutMs) + private func armRunCompletionRefresh( + runId: String, + sessionSnapshot: SessionSnapshot, + userMessageTimestamp: Double) + { + let timeoutMs = Int(pendingRunTimeoutMs) let transport = self.transport Task { [weak self, transport] in let observedCompletion = await transport.waitForRunCompletion(runId: runId, timeoutMs: timeoutMs) guard observedCompletion else { return } _ = await self?.refreshIfPending( runId: runId, - sessionKey: sessionKey, + sessionSnapshot: sessionSnapshot, after: userMessageTimestamp, - diagnostic: "chat.ui run completion refresh sessionKey=\(sessionKey) " + diagnostic: "chat.ui run completion refresh sessionKey=\(sessionSnapshot.key) " + "runId=\(runId)") } } private func refreshIfPending( runId: String, - sessionKey: String, + sessionSnapshot: SessionSnapshot, after timestamp: Double, diagnostic: String) async -> Bool { - guard self.sessionKey == sessionKey, self.pendingRuns.contains(runId) else { + guard self.isCurrentSession(sessionSnapshot), + self.pendingRuns.contains(runId) + else { return false } guard !self.clearPendingRunIfAssistantMessagePresent(runId: runId, after: timestamp) else { return false } self.logDiagnostic(diagnostic) - await self.refreshHistoryAfterRun() - guard self.sessionKey == sessionKey, self.pendingRuns.contains(runId) else { + let historyContext = self.beginHistoryRequest(for: sessionSnapshot) + await self.refreshHistoryAfterRun(historyRequest: historyContext) + guard self.isCurrentSession(sessionSnapshot), + self.pendingRuns.contains(runId) + else { return false } return !self.clearPendingRunIfAssistantMessagePresent(runId: runId, after: timestamp) @@ -1584,13 +1792,84 @@ public final class OpenClawChatViewModel { } private func hasAssistantMessageAfterLatestUser() -> Bool { - guard let lastUserIndex = self.messages.lastIndex(where: { $0.role.lowercased() == "user" }) else { + Self.hasAssistantMessageAfterLatestUser(in: self.messages) + } + + private static func hasUnansweredLatestUser(in messages: [OpenClawChatMessage]) -> Bool { + self.latestUserTurn(in: messages) != nil && !self.hasAssistantMessageAfterLatestUser(in: messages) + } + + private static func latestUserTurn(in messages: [OpenClawChatMessage]) -> LatestUserTurn? { + guard let lastUserIndex = messages.lastIndex(where: { $0.role.lowercased() == "user" }) else { + return nil + } + guard let refreshKey = self.userRefreshIdentityKey(for: messages[lastUserIndex]) else { + return LatestUserTurn( + refreshKey: nil, + occurrence: 0, + timestamp: messages[lastUserIndex].timestamp) + } + let occurrence = messages[...lastUserIndex].reduce(into: 0) { count, message in + guard self.userRefreshIdentityKey(for: message) == refreshKey else { return } + count += 1 + } + return LatestUserTurn( + refreshKey: refreshKey, + occurrence: occurrence, + timestamp: messages[lastUserIndex].timestamp) + } + + private static func hasAnsweredUser( + _ user: LatestUserTurn, + in messages: [OpenClawChatMessage]) + -> Bool + { + guard let refreshKey = user.refreshKey else { return false } + var occurrence = 0 + var latestMatchingUserIndex: [OpenClawChatMessage].Index? + for (index, message) in messages.enumerated() { + guard self.userRefreshIdentityKey(for: message) == refreshKey else { continue } + occurrence += 1 + latestMatchingUserIndex = index + guard occurrence == user.occurrence else { continue } + let nextIndex = messages.index(after: index) + guard nextIndex < messages.endIndex else { return false } + return messages[nextIndex...].contains { message in + guard message.role.lowercased() == "assistant" else { return false } + let text = message.content.compactMap(\.text).joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + return !text.isEmpty || message.errorMessage != nil + } + } + guard let latestMatchingUserIndex, + messages.lastIndex(where: { $0.role.lowercased() == "user" }) == latestMatchingUserIndex + else { return false } - guard lastUserIndex < self.messages.index(before: self.messages.endIndex) else { + if let requestTimestamp = user.timestamp, + let latestTimestamp = messages[latestMatchingUserIndex].timestamp, + latestTimestamp < requestTimestamp + { return false } - return self.messages[self.messages.index(after: lastUserIndex)...].contains { message in + let nextIndex = messages.index(after: latestMatchingUserIndex) + guard nextIndex < messages.endIndex else { return false } + return messages[nextIndex...].contains { message in + guard message.role.lowercased() == "assistant" else { return false } + let text = message.content.compactMap(\.text).joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + return !text.isEmpty || message.errorMessage != nil + } + } + + private static func hasAssistantMessageAfterLatestUser(in messages: [OpenClawChatMessage]) -> Bool { + guard let lastUserIndex = messages.lastIndex(where: { $0.role.lowercased() == "user" }) else { + return false + } + guard lastUserIndex < messages.index(before: messages.endIndex) else { + return false + } + return messages[messages.index(after: lastUserIndex)...].contains { message in guard message.role.lowercased() == "assistant" else { return false } let text = message.content.compactMap(\.text).joined(separator: "\n") .trimmingCharacters(in: .whitespacesAndNewlines) @@ -1610,22 +1889,18 @@ public final class OpenClawChatViewModel { } } - private func refreshHistoryAfterRun() async { + @discardableResult + private func refreshHistoryAfterRun(historyRequest request: HistoryRequest? = nil) async -> Bool { + let request = request ?? self.beginHistoryRequest() do { - let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey) - self.messages = Self.reconcileRunRefreshMessages( - previous: self.messages, - incoming: Self.decodeMessages(payload.messages ?? [])) - self.prunePendingLocalUserEchoMessageIDs() - self.sessionId = payload.sessionId - if !self.prefersExplicitThinkingLevel, - let level = Self.normalizedThinkingLevel(payload.thinkingLevel) - { - self.thinkingLevel = level - self.syncThinkingLevelOptions() - } + let payload = try await transport.requestHistory(sessionKey: request.session.key) + return self.applyHistoryPayload( + payload, + for: request, + preservingOptimisticLocalMessages: true) } catch { chatUILogger.error("refresh history failed \(error.localizedDescription, privacy: .public)") + return false } } @@ -1660,7 +1935,7 @@ public final class OpenClawChatViewModel { } private func clearPendingRuns(reason: String?) { - let runIds = Array(self.pendingRuns) + let runIds = Array(pendingRuns) for runId in self.pendingRuns { self.pendingRunTimeoutTasks[runId]?.cancel() } @@ -1677,15 +1952,17 @@ public final class OpenClawChatViewModel { } } - private func pollHealthIfNeeded(force: Bool) async { - if !force, let last = self.lastHealthPollAt, Date().timeIntervalSince(last) < 10 { + private func pollHealthIfNeeded(force: Bool, sessionSnapshot: SessionSnapshot? = nil) async { + if !force, let last = lastHealthPollAt, Date().timeIntervalSince(last) < 10 { return } self.lastHealthPollAt = Date() do { - let ok = try await self.transport.requestHealth(timeoutMs: 5000) + let ok = try await transport.requestHealth(timeoutMs: 5000) + if let sessionSnapshot, !self.isCurrentSession(sessionSnapshot) { return } self.healthOK = ok } catch { + if let sessionSnapshot, !self.isCurrentSession(sessionSnapshot) { return } self.healthOK = false } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index 46b4e302f5af..c34c6bf77912 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -197,6 +197,7 @@ private struct SelectedConnectAuth { let storedToken: String? let storedScopes: [String]? let authSource: GatewayAuthSource + let suppressedDeviceTokenRetry: Bool } private enum GatewayConnectErrorCodes { @@ -442,7 +443,8 @@ public actor GatewayChannelActor { let selectedAuth = self.selectConnectAuth( role: role, includeDeviceIdentity: includeDeviceIdentity, - deviceId: identity?.deviceId) + deviceId: identity?.deviceId, + requestedScopes: requestedScopes) let scopes = self.resolveConnectScopes( role: role, requestedScopes: requestedScopes, @@ -479,7 +481,9 @@ public actor GatewayChannelActor { if !options.permissions.isEmpty { params["permissions"] = ProtoAnyCodable(options.permissions) } - if selectedAuth.authDeviceToken != nil, self.pendingDeviceTokenRetry { + if self.pendingDeviceTokenRetry, + selectedAuth.authDeviceToken != nil || selectedAuth.suppressedDeviceTokenRetry + { self.pendingDeviceTokenRetry = false } self.lastAuthSource = selectedAuth.authSource @@ -555,7 +559,8 @@ public actor GatewayChannelActor { private func selectConnectAuth( role: String, includeDeviceIdentity: Bool, - deviceId: String?) -> SelectedConnectAuth + deviceId: String?, + requestedScopes: [String]) -> SelectedConnectAuth { let explicitToken = self.token?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty let explicitBootstrapToken = @@ -566,9 +571,21 @@ public actor GatewayChannelActor { ? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role) : nil let storedToken = storedEntry?.token + let storedScopes = storedEntry?.scopes ?? [] + let requestedScopesExceedStoredToken = Self.requestedScopesExceedStoredToken( + role: role, + requestedScopes: requestedScopes, + storedToken: storedToken, + storedScopes: storedScopes) + let suppressedDeviceTokenRetry = + includeDeviceIdentity && self.pendingDeviceTokenRetry && + requestedScopesExceedStoredToken && storedToken != nil && explicitToken != nil + // Scope upgrades must be judged from the requested scopes. A stale + // device-token retry carries the old grant and is rejected before pairing repair. let shouldUseDeviceRetryToken = includeDeviceIdentity && self.pendingDeviceTokenRetry && - storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint() + !requestedScopesExceedStoredToken && storedToken != nil && explicitToken != nil && + self.isTrustedDeviceRetryEndpoint() let authToken = explicitToken ?? // A freshly scanned setup code should force the bootstrap pairing path instead of @@ -598,7 +615,90 @@ public actor GatewayChannelActor { signatureToken: authToken ?? authBootstrapToken, storedToken: storedToken, storedScopes: storedEntry?.scopes, - authSource: authSource) + authSource: authSource, + suppressedDeviceTokenRetry: suppressedDeviceTokenRetry) + } + + nonisolated static func _test_requestedScopesExceedStoredToken( + role: String, + requestedScopes: [String], + storedToken: String?, + storedScopes: [String]) -> Bool + { + self.requestedScopesExceedStoredToken( + role: role, + requestedScopes: requestedScopes, + storedToken: storedToken, + storedScopes: storedScopes) + } + + private nonisolated static func requestedScopesExceedStoredToken( + role: String, + requestedScopes: [String], + storedToken: String?, + storedScopes: [String]) -> Bool + { + storedToken != nil && !storedScopes.isEmpty && + !self.storedDeviceTokenScopesAllow( + role: role, + requestedScopes: requestedScopes, + storedScopes: storedScopes) + } + + private nonisolated static func storedDeviceTokenScopesAllow( + role: String, + requestedScopes: [String], + storedScopes: [String]) -> Bool + { + let requested = self.normalizedScopeList(requestedScopes) + if requested.isEmpty { + return true + } + let allowed = self.normalizedScopeList(storedScopes) + if allowed.isEmpty { + return false + } + let allowedSet = Set(allowed) + let normalizedRole = role.trimmingCharacters(in: .whitespacesAndNewlines) + if normalizedRole != "operator" { + let prefix = "\(normalizedRole)." + return requested.allSatisfy { scope in + scope.hasPrefix(prefix) && allowedSet.contains(scope) + } + } + return requested.allSatisfy { scope in + self.operatorScopeSatisfied(scope, granted: allowedSet) + } + } + + private nonisolated static func normalizedScopeList(_ scopes: [String]) -> [String] { + var out: [String] = [] + var seen = Set() + for scope in scopes { + let trimmed = scope.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty || seen.contains(trimmed) { + continue + } + seen.insert(trimmed) + out.append(trimmed) + } + return out + } + + private nonisolated static func operatorScopeSatisfied(_ scope: String, granted: Set) -> Bool { + if !scope.hasPrefix("operator.") { + return false + } + if granted.contains("operator.admin") { + return true + } + if scope == "operator.read" { + return granted.contains("operator.read") || granted.contains("operator.write") + } + if scope == "operator.write" { + return granted.contains("operator.write") + } + return granted.contains(scope) } private func shouldPersistBootstrapHandoffTokens() -> Bool { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index a76a0cf300f9..7940985f0251 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -46,6 +46,17 @@ public enum NodePresenceAliveReason: String, Codable, Sendable { case connect = "connect" } +public enum SessionFileKind: String, Codable, Sendable { + case modified = "modified" + case read = "read" +} + +public enum SessionFileRelevance: String, Codable, Sendable { + case modified = "modified" + case read = "read" + case mixed = "mixed" +} + public struct ConnectParams: Codable, Sendable { public let minprotocol: Int public let maxprotocol: Int @@ -595,6 +606,9 @@ public struct SendParams: Codable, Sendable { public let message: String? public let mediaurl: String? public let mediaurls: [String]? + public let buffer: String? + public let filename: String? + public let contenttype: String? public let asvoice: Bool? public let gifplayback: Bool? public let channel: String? @@ -613,6 +627,9 @@ public struct SendParams: Codable, Sendable { message: String?, mediaurl: String?, mediaurls: [String]?, + buffer: String? = nil, + filename: String? = nil, + contenttype: String? = nil, asvoice: Bool?, gifplayback: Bool?, channel: String?, @@ -630,6 +647,9 @@ public struct SendParams: Codable, Sendable { self.message = message self.mediaurl = mediaurl self.mediaurls = mediaurls + self.buffer = buffer + self.filename = filename + self.contenttype = contenttype self.asvoice = asvoice self.gifplayback = gifplayback self.channel = channel @@ -649,6 +669,9 @@ public struct SendParams: Codable, Sendable { case message case mediaurl = "mediaUrl" case mediaurls = "mediaUrls" + case buffer + case filename + case contenttype = "contentType" case asvoice = "asVoice" case gifplayback = "gifPlayback" case channel @@ -753,6 +776,7 @@ public struct AgentParams: Codable, Sendable { public let bootstrapcontextrunkind: AnyCodable? public let acpturnsource: String? public let internalruntimehandoffid: String? + public let execapprovalfollowupexpectedsessionid: String? public let internalevents: [[String: AnyCodable]]? public let inputprovenance: [String: AnyCodable]? public let suppresspromptpersistence: Bool? @@ -794,6 +818,7 @@ public struct AgentParams: Codable, Sendable { bootstrapcontextrunkind: AnyCodable?, acpturnsource: String?, internalruntimehandoffid: String?, + execapprovalfollowupexpectedsessionid: String?, internalevents: [[String: AnyCodable]]?, inputprovenance: [String: AnyCodable]?, suppresspromptpersistence: Bool?, @@ -834,6 +859,7 @@ public struct AgentParams: Codable, Sendable { self.bootstrapcontextrunkind = bootstrapcontextrunkind self.acpturnsource = acpturnsource self.internalruntimehandoffid = internalruntimehandoffid + self.execapprovalfollowupexpectedsessionid = execapprovalfollowupexpectedsessionid self.internalevents = internalevents self.inputprovenance = inputprovenance self.suppresspromptpersistence = suppresspromptpersistence @@ -876,6 +902,7 @@ public struct AgentParams: Codable, Sendable { case bootstrapcontextrunkind = "bootstrapContextRunKind" case acpturnsource = "acpTurnSource" case internalruntimehandoffid = "internalRuntimeHandoffId" + case execapprovalfollowupexpectedsessionid = "execApprovalFollowupExpectedSessionId" case internalevents = "internalEvents" case inputprovenance = "inputProvenance" case suppresspromptpersistence = "suppressPromptPersistence" @@ -966,21 +993,25 @@ public struct WakeParams: Codable, Sendable { public let mode: AnyCodable public let text: String public let sessionkey: String? + public let agentid: String? public init( mode: AnyCodable, text: String, - sessionkey: String?) + sessionkey: String?, + agentid: String? = nil) { self.mode = mode self.text = text self.sessionkey = sessionkey + self.agentid = agentid } private enum CodingKeys: String, CodingKey { case mode case text case sessionkey = "sessionKey" + case agentid = "agentId" } } @@ -1736,6 +1767,7 @@ public struct SessionsResolveParams: Codable, Sendable { public let spawnedby: String? public let includeglobal: Bool? public let includeunknown: Bool? + public let allowmissing: Bool? public init( key: String?, @@ -1744,7 +1776,8 @@ public struct SessionsResolveParams: Codable, Sendable { agentid: String? = nil, spawnedby: String?, includeglobal: Bool?, - includeunknown: Bool?) + includeunknown: Bool?, + allowmissing: Bool? = nil) { self.key = key self.sessionid = sessionid @@ -1753,6 +1786,7 @@ public struct SessionsResolveParams: Codable, Sendable { self.spawnedby = spawnedby self.includeglobal = includeglobal self.includeunknown = includeunknown + self.allowmissing = allowmissing } private enum CodingKeys: String, CodingKey { @@ -1763,6 +1797,7 @@ public struct SessionsResolveParams: Codable, Sendable { case spawnedby = "spawnedBy" case includeglobal = "includeGlobal" case includeunknown = "includeUnknown" + case allowmissing = "allowMissing" } } @@ -2054,6 +2089,204 @@ public struct SessionsCompactionRestoreResult: Codable, Sendable { } } +public struct SessionFileBrowserEntry: Codable, Sendable { + public let path: String + public let name: String + public let kind: AnyCodable + public let sessionkind: SessionFileRelevance? + public let size: Int? + public let updatedatms: Int? + + public init( + path: String, + name: String, + kind: AnyCodable, + sessionkind: SessionFileRelevance?, + size: Int?, + updatedatms: Int?) + { + self.path = path + self.name = name + self.kind = kind + self.sessionkind = sessionkind + self.size = size + self.updatedatms = updatedatms + } + + private enum CodingKeys: String, CodingKey { + case path + case name + case kind + case sessionkind = "sessionKind" + case size + case updatedatms = "updatedAtMs" + } +} + +public struct SessionFileBrowserResult: Codable, Sendable { + public let path: String + public let parentpath: String? + public let search: String? + public let entries: [SessionFileBrowserEntry] + public let truncated: Bool? + + public init( + path: String, + parentpath: String?, + search: String?, + entries: [SessionFileBrowserEntry], + truncated: Bool?) + { + self.path = path + self.parentpath = parentpath + self.search = search + self.entries = entries + self.truncated = truncated + } + + private enum CodingKeys: String, CodingKey { + case path + case parentpath = "parentPath" + case search + case entries + case truncated + } +} + +public struct SessionFileEntry: Codable, Sendable { + public let path: String + public let name: String + public let kind: SessionFileKind + public let missing: Bool + public let size: Int? + public let updatedatms: Int? + public let content: String? + + public init( + path: String, + name: String, + kind: SessionFileKind, + missing: Bool, + size: Int?, + updatedatms: Int?, + content: String?) + { + self.path = path + self.name = name + self.kind = kind + self.missing = missing + self.size = size + self.updatedatms = updatedatms + self.content = content + } + + private enum CodingKeys: String, CodingKey { + case path + case name + case kind + case missing + case size + case updatedatms = "updatedAtMs" + case content + } +} + +public struct SessionsFilesListParams: Codable, Sendable { + public let sessionkey: String + public let agentid: String? + public let path: String? + public let search: String? + + public init( + sessionkey: String, + agentid: String? = nil, + path: String?, + search: String?) + { + self.sessionkey = sessionkey + self.agentid = agentid + self.path = path + self.search = search + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case agentid = "agentId" + case path + case search + } +} + +public struct SessionsFilesListResult: Codable, Sendable { + public let sessionkey: String + public let root: String? + public let files: [SessionFileEntry] + public let browser: SessionFileBrowserResult? + + public init( + sessionkey: String, + root: String?, + files: [SessionFileEntry], + browser: SessionFileBrowserResult?) + { + self.sessionkey = sessionkey + self.root = root + self.files = files + self.browser = browser + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case root + case files + case browser + } +} + +public struct SessionsFilesGetParams: Codable, Sendable { + public let sessionkey: String + public let path: String + public let agentid: String? + + public init( + sessionkey: String, + path: String, + agentid: String? = nil) + { + self.sessionkey = sessionkey + self.path = path + self.agentid = agentid + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case path + case agentid = "agentId" + } +} + +public struct SessionsFilesGetResult: Codable, Sendable { + public let sessionkey: String + public let root: String? + public let file: SessionFileEntry + + public init( + sessionkey: String, + root: String?, + file: SessionFileEntry) + { + self.sessionkey = sessionkey + self.root = root + self.file = file + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case root + case file + } +} + public struct SessionsCreateParams: Codable, Sendable { public let key: String? public let agentid: String? @@ -2757,6 +2990,7 @@ public struct ConfigPatchParams: Codable, Sendable { public let deliverycontext: [String: AnyCodable]? public let note: String? public let restartdelayms: Int? + public let replacepaths: [String]? public init( raw: String, @@ -2764,7 +2998,8 @@ public struct ConfigPatchParams: Codable, Sendable { sessionkey: String?, deliverycontext: [String: AnyCodable]?, note: String?, - restartdelayms: Int?) + restartdelayms: Int?, + replacepaths: [String]?) { self.raw = raw self.basehash = basehash @@ -2772,6 +3007,7 @@ public struct ConfigPatchParams: Codable, Sendable { self.deliverycontext = deliverycontext self.note = note self.restartdelayms = restartdelayms + self.replacepaths = replacepaths } private enum CodingKeys: String, CodingKey { @@ -2781,6 +3017,7 @@ public struct ConfigPatchParams: Codable, Sendable { case deliverycontext = "deliveryContext" case note case restartdelayms = "restartDelayMs" + case replacepaths = "replacePaths" } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift index 762144123fd8..91950ab8000b 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -97,6 +97,8 @@ private func makeViewModel( historyResponses: [OpenClawChatHistoryPayload], sessionsResponses: [OpenClawChatSessionsListResponse] = [], modelResponses: [[OpenClawChatModelChoice]] = [], + requestHistoryHook: (@Sendable (String) async throws -> Void)? = nil, + setActiveSessionHook: (@Sendable (String) async throws -> Void)? = nil, createSessionHook: (@Sendable (String, String?) async throws -> Void)? = nil, resetSessionHook: (@Sendable (String) async throws -> Void)? = nil, compactSessionHook: (@Sendable (String) async throws -> Void)? = nil, @@ -105,6 +107,7 @@ private func makeViewModel( waitForRunCompletionHook: (@Sendable (String, Int) async -> Bool)? = nil, healthResponses: [Bool] = [true], initialThinkingLevel: String? = nil, + onSessionChanged: (@MainActor (String) -> Void)? = nil, onThinkingLevelChanged: (@MainActor @Sendable (String) -> Void)? = nil) async -> (TestChatTransport, OpenClawChatViewModel) { @@ -112,6 +115,8 @@ private func makeViewModel( historyResponses: historyResponses, sessionsResponses: sessionsResponses, modelResponses: modelResponses, + requestHistoryHook: requestHistoryHook, + setActiveSessionHook: setActiveSessionHook, createSessionHook: createSessionHook, resetSessionHook: resetSessionHook, compactSessionHook: compactSessionHook, @@ -124,6 +129,7 @@ private func makeViewModel( sessionKey: sessionKey, transport: transport, initialThinkingLevel: initialThinkingLevel, + onSessionChanged: onSessionChanged, onThinkingLevelChanged: onThinkingLevelChanged) } return (transport, vm) @@ -155,6 +161,13 @@ private func waitForLastSentRunId(_ transport: TestChatTransport) async throws - return try #require(await transport.lastSentRunId()) } +private func waitForSentRunId(after sentRunCount: Int, _ transport: TestChatTransport) async throws -> String { + try await waitUntil("transport send called") { + await transport.sentRunIds().count > sentRunCount + } + return try #require(await transport.sentRunIds().last) +} + @discardableResult private func sendMessageAndEmitFinal( transport: TestChatTransport, @@ -162,10 +175,15 @@ private func sendMessageAndEmitFinal( text: String, sessionKey: String = "main") async throws -> String { + let sentRunCount = await transport.sentRunIds().count await sendUserMessage(vm, text: text) - try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } + let runId = try await waitForSentRunId(after: sentRunCount, transport) + try await waitUntil("send is pending or refreshed") { + await MainActor.run { + vm.pendingRunCount == 1 || (!vm.isSending && vm.pendingRunCount == 0) + } + } - let runId = try await waitForLastSentRunId(transport) transport.emit( .chat( OpenClawChatEventPayload( @@ -274,6 +292,28 @@ private actor AsyncCounter { self.value += 1 return self.value } + + func current() -> Int { + self.value + } +} + +private actor SessionSubscribeGate { + private var waiters: [CheckedContinuation] = [] + + func wait() async { + await withCheckedContinuation { continuation in + self.waiters.append(continuation) + } + } + + func release() { + let waiters = self.waiters + self.waiters = [] + for waiter in waiters { + waiter.resume() + } + } } private actor TestChatTransportState { @@ -281,6 +321,7 @@ private actor TestChatTransportState { var sessionsCallCount: Int = 0 var modelsCallCount: Int = 0 var healthCallCount: Int = 0 + var activeSessionKeys: [String] = [] var createdSessionKeys: [String] = [] var createdParentSessionKeys: [String?] = [] var resetSessionKeys: [String] = [] @@ -299,6 +340,8 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor private let historyResponses: [OpenClawChatHistoryPayload] private let sessionsResponses: [OpenClawChatSessionsListResponse] private let modelResponses: [[OpenClawChatModelChoice]] + private let requestHistoryHook: (@Sendable (String) async throws -> Void)? + private let setActiveSessionHook: (@Sendable (String) async throws -> Void)? private let createSessionHook: (@Sendable (String, String?) async throws -> Void)? private let resetSessionHook: (@Sendable (String) async throws -> Void)? private let compactSessionHook: (@Sendable (String) async throws -> Void)? @@ -314,6 +357,8 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor historyResponses: [OpenClawChatHistoryPayload], sessionsResponses: [OpenClawChatSessionsListResponse] = [], modelResponses: [[OpenClawChatModelChoice]] = [], + requestHistoryHook: (@Sendable (String) async throws -> Void)? = nil, + setActiveSessionHook: (@Sendable (String) async throws -> Void)? = nil, createSessionHook: (@Sendable (String, String?) async throws -> Void)? = nil, resetSessionHook: (@Sendable (String) async throws -> Void)? = nil, compactSessionHook: (@Sendable (String) async throws -> Void)? = nil, @@ -325,6 +370,8 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor self.historyResponses = historyResponses self.sessionsResponses = sessionsResponses self.modelResponses = modelResponses + self.requestHistoryHook = requestHistoryHook + self.setActiveSessionHook = setActiveSessionHook self.createSessionHook = createSessionHook self.resetSessionHook = resetSessionHook self.compactSessionHook = compactSessionHook @@ -343,7 +390,12 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor self.stream } - func setActiveSessionKey(_: String) async throws {} + func setActiveSessionKey(_ sessionKey: String) async throws { + await self.state.activeSessionKeysAppend(sessionKey) + if let setActiveSessionHook { + try await setActiveSessionHook(sessionKey) + } + } func createSession( key: String, @@ -359,8 +411,10 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor } func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { - let idx = await self.state.historyCallCount - await self.state.setHistoryCallCount(idx + 1) + let idx = await self.state.nextHistoryCallIndex() + if let requestHistoryHook { + try await requestHistoryHook(sessionKey) + } if idx < self.historyResponses.count { return self.historyResponses[idx] } @@ -389,8 +443,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor } func listSessions(limit _: Int?) async throws -> OpenClawChatSessionsListResponse { - let idx = await self.state.sessionsCallCount - await self.state.setSessionsCallCount(idx + 1) + let idx = await self.state.nextSessionsCallIndex() if idx < self.sessionsResponses.count { return self.sessionsResponses[idx] } @@ -403,8 +456,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor } func listModels() async throws -> [OpenClawChatModelChoice] { - let idx = await self.state.modelsCallCount - await self.state.setModelsCallCount(idx + 1) + let idx = await self.state.nextModelsCallIndex() if idx < self.modelResponses.count { return self.modelResponses[idx] } @@ -440,8 +492,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor } func requestHealth(timeoutMs _: Int) async throws -> Bool { - let idx = await self.state.healthCallCount - await self.state.setHealthCallCount(idx + 1) + let idx = await self.state.nextHealthCallIndex() if idx < self.healthResponses.count { return self.healthResponses[idx] } @@ -483,6 +534,10 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor await self.state.patchedModels } + func activeSessionKeys() async -> [String] { + await self.state.activeSessionKeys + } + func patchedThinkingLevels() async -> [String] { await self.state.patchedThinkingLevels } @@ -509,20 +564,28 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor } extension TestChatTransportState { - fileprivate func setHistoryCallCount(_ v: Int) { - self.historyCallCount = v + fileprivate func nextHistoryCallIndex() -> Int { + defer { self.historyCallCount += 1 } + return self.historyCallCount } - fileprivate func setSessionsCallCount(_ v: Int) { - self.sessionsCallCount = v + fileprivate func nextSessionsCallIndex() -> Int { + defer { self.sessionsCallCount += 1 } + return self.sessionsCallCount } - fileprivate func setModelsCallCount(_ v: Int) { - self.modelsCallCount = v + fileprivate func nextModelsCallIndex() -> Int { + defer { self.modelsCallCount += 1 } + return self.modelsCallCount } - fileprivate func setHealthCallCount(_ v: Int) { - self.healthCallCount = v + fileprivate func nextHealthCallIndex() -> Int { + defer { self.healthCallCount += 1 } + return self.healthCallCount + } + + fileprivate func activeSessionKeysAppend(_ v: String) { + self.activeSessionKeys.append(v) } fileprivate func sentRunIdsAppend(_ v: String) { @@ -570,8 +633,8 @@ extension TestChatTransportState { } } -@Suite struct ChatViewModelTests { - @Test func displaysErrorMessageFallbackOnlyForAssistantErrorTurns() throws { +struct ChatViewModelTests { + @Test func `displays error message fallback only for assistant error turns`() throws { func decodeMessage(role: String, stopReason: String, contentText: String? = nil) throws -> OpenClawChatMessage { let contentJSON = contentText.map { #"[{"type":"text","text":"\#($0)"}]"# } ?? "[]" let data = """ @@ -641,7 +704,7 @@ extension TestChatTransportState { errorMessage: toolUseAssistant.errorMessage) == nil) } - @Test func streamsAssistantAndClearsOnFinal() async throws { + @Test func `streams assistant and clears on final`() async throws { let sessionId = "sess-main" let history1 = historyPayload(sessionId: sessionId) let history2 = historyPayload( @@ -686,7 +749,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.pendingToolCalls.isEmpty }) } - @Test func rendersFinalChatEventMessageWhenHistoryIsStale() async throws { + @Test func `renders final chat event message when history is stale`() async throws { let sessionId = "sess-main" let history = historyPayload(sessionId: sessionId) let (transport, vm) = await makeViewModel(historyResponses: [history, history]) @@ -719,9 +782,9 @@ extension TestChatTransportState { } } - @Test func completionWaitRefreshesHistoryAndClearsPendingRun() async throws { + @Test func `completion wait refreshes history and clears pending run`() async throws { let sessionId = "sess-main" - let now = Date().timeIntervalSince1970 * 1000 + let now = (Date().timeIntervalSince1970 * 1000) + 10000 let history1 = historyPayload(sessionId: sessionId) let history2 = historyPayload(sessionId: sessionId, messages: []) let history3 = historyPayload( @@ -755,9 +818,9 @@ extension TestChatTransportState { } } - @Test func agentLifecycleEndRefreshesHistoryAndClearsPendingRun() async throws { + @Test func `agent lifecycle end refreshes history and clears pending run`() async throws { let sessionId = "sess-main" - let now = Date().timeIntervalSince1970 * 1000 + let now = (Date().timeIntervalSince1970 * 1000) + 10000 let history1 = historyPayload(sessionId: sessionId) let history2 = historyPayload(sessionId: sessionId, messages: []) let history3 = historyPayload( @@ -792,7 +855,7 @@ extension TestChatTransportState { } } - @Test func pendingRunBlocksSecondMainSend() async throws { + @Test func `pending run blocks second main send`() async throws { let sessionId = "sess-main" let history = historyPayload(sessionId: sessionId, messages: []) let (transport, vm) = await makeViewModel(historyResponses: [history, history]) @@ -817,7 +880,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.input } == "second") } - @Test func keepsOptimisticUserMessageWhenFinalRefreshReturnsOnlyAssistantHistory() async throws { + @Test func `keeps optimistic user message when final refresh returns only assistant history`() async throws { let sessionId = "sess-main" let now = Date().timeIntervalSince1970 * 1000 let history1 = historyPayload(sessionId: sessionId) @@ -848,7 +911,7 @@ extension TestChatTransportState { } } - @Test func keepsOptimisticUserMessageWhenFinalRefreshHistoryIsTemporarilyEmpty() async throws { + @Test func `keeps optimistic user message when final refresh history is temporarily empty`() async throws { let sessionId = "sess-main" let history1 = historyPayload(sessionId: sessionId) let history2 = historyPayload(sessionId: sessionId, messages: []) @@ -870,7 +933,7 @@ extension TestChatTransportState { } } - @Test func doesNotDuplicateUserMessageWhenRefreshReturnsCanonicalTimestamp() async throws { + @Test func `does not duplicate user message when refresh returns canonical timestamp`() async throws { let sessionId = "sess-main" let now = Date().timeIntervalSince1970 * 1000 let history1 = historyPayload(sessionId: sessionId) @@ -909,7 +972,7 @@ extension TestChatTransportState { } } - @Test func preservesRepeatedOptimisticUserMessagesWithIdenticalContentDuringRefresh() async throws { + @Test func `preserves repeated optimistic user messages with identical content during refresh`() async throws { let sessionId = "sess-main" let now = Date().timeIntervalSince1970 * 1000 let history1 = historyPayload(sessionId: sessionId) @@ -932,6 +995,15 @@ extension TestChatTransportState { transport: transport, vm: vm, text: "retry") + try await waitUntil("first retry completes") { + await MainActor.run { + vm.pendingRunCount == 0 && + vm.messages.contains { message in + message.role == "assistant" && + message.content.compactMap(\.text).joined(separator: "\n") == "first answer" + } + } + } try await sendMessageAndEmitFinal( transport: transport, vm: vm, @@ -952,7 +1024,100 @@ extension TestChatTransportState { } } - @Test func acceptsCanonicalSessionKeyEventsForOwnPendingRun() async throws { + @Test func `run refresh does not resurrect old user turns omitted by bounded history`() async throws { + let sessionId = "sess-main" + let now = Date().timeIntervalSince1970 * 1000 + let oldMessages = [ + chatTextMessage(role: "user", text: "old question", timestamp: now - 2000), + chatTextMessage(role: "assistant", text: "old answer", timestamp: now - 1000), + ] + let boundedRefreshMessages = [ + chatTextMessage(role: "user", text: "current question", timestamp: now + 5000), + chatTextMessage(role: "assistant", text: "current answer", timestamp: now + 6000), + ] + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionId: sessionId, messages: oldMessages), + historyPayload(sessionId: sessionId, messages: boundedRefreshMessages), + ]) + try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) + try await sendMessageAndEmitFinal( + transport: transport, + vm: vm, + text: "current question") + + try await waitUntil("bounded refresh replaces old history") { + await MainActor.run { + let texts = vm.messages.map { message in + message.content.compactMap(\.text).joined(separator: "\n") + } + return texts.contains("current answer") && + !texts.contains("old question") && + !texts.contains("old answer") + } + } + } + + @Test @MainActor func `bounded repeated same text reply invalidates older stale refresh`() async throws { + let sessionId = "sess-main" + let staleRefreshGate = SessionSubscribeGate() + let historyCount = AsyncCounter() + let staleRefreshReleasedCount = AsyncCounter() + let now = (Date().timeIntervalSince1970 * 1000) + 10000 + let firstTurn = [ + chatTextMessage(role: "user", text: "retry", timestamp: now), + chatTextMessage(role: "assistant", text: "first answer", timestamp: now + 1), + ] + let latestBoundedTurn = [ + chatTextMessage(role: "user", text: "retry", timestamp: now + 2), + chatTextMessage(role: "assistant", text: "second answer", timestamp: now + 3), + ] + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionId: sessionId, messages: firstTurn), + historyPayload(sessionId: sessionId, messages: firstTurn), + historyPayload(sessionId: sessionId, messages: latestBoundedTurn), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await historyCount.increment() + if count == 2 { + await staleRefreshGate.wait() + _ = await staleRefreshReleasedCount.increment() + } + }) + try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) + + transport.emit(OpenClawChatTransportEvent.seqGap) + try await waitUntil("stale refresh is in flight") { + await historyCount.current() == 2 + } + + vm.input = "retry" + vm.send() + _ = try await waitForLastSentRunId(transport) + try await waitUntil("bounded second answer applies") { + await MainActor.run { + vm.sessionId == sessionId && + vm.messages.contains { message in + message.content.contains { $0.text == "second answer" } + } + } + } + + await staleRefreshGate.release() + try await waitUntil("stale refresh resumes") { + await staleRefreshReleasedCount.current() == 1 + } + + #expect(await MainActor.run { + vm.messages.contains { message in + message.content.contains { $0.text == "second answer" } + } + }) + } + + @Test func `accepts canonical session key events for own pending run`() async throws { let history1 = historyPayload() let history2 = historyPayload( messages: [ @@ -983,7 +1148,7 @@ extension TestChatTransportState { } } - @Test func surfacesAssistantErrorMessageAfterOwnRunRefresh() async throws { + @Test func `surfaces assistant error message after own run refresh`() async throws { let now = Date().timeIntervalSince1970 * 1000 let history1 = historyPayload() let history2 = historyPayload( @@ -1028,7 +1193,7 @@ extension TestChatTransportState { } } - @Test func acceptsCanonicalSessionKeyEventsForExternalRuns() async throws { + @Test func `accepts canonical session key events for external runs`() async throws { let now = Date().timeIntervalSince1970 * 1000 let history1 = historyPayload(messages: [chatTextMessage(role: "user", text: "first", timestamp: now)]) let history2 = historyPayload( @@ -1056,7 +1221,7 @@ extension TestChatTransportState { } } - @Test func appendsExternalSessionUserMessageForActiveSession() async throws { + @Test func `appends external session user message for active session`() async throws { let now = Date().timeIntervalSince1970 * 1000 let (transport, vm) = await makeViewModel( sessionKey: "agent:aiden:main", @@ -1092,7 +1257,7 @@ extension TestChatTransportState { } } - @Test func appendsGlobalSessionUserMessageForSelectedAgent() async throws { + @Test func `appends global session user message for selected agent`() async throws { let now = Date().timeIntervalSince1970 * 1000 let (transport, vm) = await makeViewModel( sessionKey: "agent:work:global", @@ -1129,7 +1294,7 @@ extension TestChatTransportState { } } - @Test func ignoresGlobalSessionUserMessageForDifferentAgent() async throws { + @Test func `ignores global session user message for different agent`() async throws { let now = Date().timeIntervalSince1970 * 1000 let (transport, vm) = await makeViewModel( sessionKey: "agent:work:global", @@ -1161,7 +1326,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.messages.isEmpty }) } - @Test func ignoresAgentMainSessionMessageForDifferentCurrentMainAlias() async throws { + @Test func `ignores agent main session message for different current main alias`() async throws { let now = Date().timeIntervalSince1970 * 1000 let (transport, vm) = await makeViewModel(historyResponses: [historyPayload()]) @@ -1190,7 +1355,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.messages.isEmpty }) } - @Test func appendsExternalSessionAssistantMessageWhileRunPending() async throws { + @Test func `appends external session assistant message while run pending`() async throws { let now = Date().timeIntervalSince1970 * 1000 let (transport, vm) = await makeViewModel(historyResponses: [historyPayload()]) @@ -1228,7 +1393,7 @@ extension TestChatTransportState { } } - @Test func dedupesGatewayEchoOfLocalUserMessage() async throws { + @Test func `dedupes gateway echo of local user message`() async throws { let (transport, vm) = await makeViewModel(historyResponses: [historyPayload()]) await MainActor.run { vm.load() } @@ -1257,19 +1422,19 @@ extension TestChatTransportState { fileName: nil, content: nil), ], - timestamp: Date().timeIntervalSince1970 * 1000 + 5_000), + timestamp: Date().timeIntervalSince1970 * 1000 + 5000), messageId: "srv-echo-1", messageSeq: 1))) try await Task.sleep(nanoseconds: 50_000_000) #expect(await MainActor.run { - vm.messages.filter { msg in + vm.messages.count(where: { msg in msg.role == "user" && msg.content.first?.text == "echo me" - }.count == 1 + }) == 1 }) } - @Test func appendsSameContentUserTranscriptWhenItIsNotLocalEcho() async throws { + @Test func `appends same content user transcript when it is not local echo`() async throws { let now = Date().timeIntervalSince1970 * 1000 let (transport, vm) = await makeViewModel( historyResponses: [ @@ -1297,20 +1462,20 @@ extension TestChatTransportState { fileName: nil, content: nil), ], - timestamp: now + 1_000), + timestamp: now + 1000), messageId: "msg-repeat-2", messageSeq: 2))) try await waitUntil("repeated user transcript appended") { await MainActor.run { - vm.messages.filter { msg in + vm.messages.count(where: { msg in msg.role == "user" && msg.content.first?.text == "repeat" - }.count == 2 + }) == 2 } } } - @Test func ignoresExternalSessionUserMessageForOtherSession() async throws { + @Test func `ignores external session user message for other session`() async throws { let now = Date().timeIntervalSince1970 * 1000 let (transport, vm) = await makeViewModel(historyResponses: [historyPayload()]) @@ -1339,7 +1504,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.messages.isEmpty }) } - @Test func preservesMessageIDsAcrossHistoryRefreshes() async throws { + @Test func `preserves message I ds across history refreshes`() async throws { let now = Date().timeIntervalSince1970 * 1000 let history1 = historyPayload(messages: [chatTextMessage(role: "user", text: "hello", timestamp: now)]) let history2 = historyPayload( @@ -1361,7 +1526,7 @@ extension TestChatTransportState { #expect(firstIdAfter == firstIdBefore) } - @Test func clearsStreamingOnExternalFinalEvent() async throws { + @Test func `clears streaming on external final event`() async throws { let sessionId = "sess-main" let history = historyPayload(sessionId: sessionId) let (transport, vm) = await makeViewModel(historyResponses: [history, history]) @@ -1381,7 +1546,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.pendingToolCalls.isEmpty }) } - @Test func seqGapClearsPendingRunsAndAutoRefreshesHistory() async throws { + @Test func `seq gap clears pending runs and auto refreshes history`() async throws { let now = Date().timeIntervalSince1970 * 1000 let history1 = historyPayload() let history2 = historyPayload(messages: [chatTextMessage( @@ -1407,7 +1572,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.errorText == nil }) } - @Test func sessionChoicesPreferMainAndRecent() async throws { + @Test func `session choices prefer main and recent`() async throws { let now = Date().timeIntervalSince1970 * 1000 let recent = now - (2 * 60 * 60 * 1000) let recentOlder = now - (5 * 60 * 60 * 1000) @@ -1433,7 +1598,7 @@ extension TestChatTransportState { #expect(keys == ["main", "recent-1", "recent-2"]) } - @Test func sessionChoicesIncludeCurrentWhenMissing() async throws { + @Test func `session choices include current when missing`() async throws { let now = Date().timeIntervalSince1970 * 1000 let recent = now - (30 * 60 * 1000) let history = historyPayload(sessionKey: "custom", sessionId: "sess-custom") @@ -1457,7 +1622,7 @@ extension TestChatTransportState { #expect(keys == ["main", "custom"]) } - @Test func sessionChoicesUseResolvedMainSessionKeyInsteadOfLiteralMain() async throws { + @Test func `session choices use resolved main session key instead of literal main`() async throws { let now = Date().timeIntervalSince1970 * 1000 let recent = now - (30 * 60 * 1000) let recentOlder = now - (90 * 60 * 1000) @@ -1505,7 +1670,7 @@ extension TestChatTransportState { #expect(keys == ["Luke’s MacBook Pro", "recent-1"]) } - @Test func sessionChoicesHideInternalOnboardingSession() async throws { + @Test func `session choices hide internal onboarding session`() async throws { let now = Date().timeIntervalSince1970 * 1000 let recent = now - (2 * 60 * 1000) let recentOlder = now - (5 * 60 * 1000) @@ -1572,7 +1737,7 @@ extension TestChatTransportState { #expect(keys == ["agent:main:main"]) } - @Test func newTriggerStartsFreshAgentSessionWithoutAdminReset() async throws { + @Test func `new trigger starts fresh agent session without admin reset`() async throws { let before = historyPayload( messages: [ chatTextMessage(role: "assistant", text: "before new", timestamp: 1), @@ -1620,7 +1785,7 @@ extension TestChatTransportState { } } - @Test func newTriggerFallsBackToResetWhenCreateSessionIsUnsupported() async throws { + @Test func `new trigger falls back to reset when create session is unsupported`() async throws { let before = historyPayload( messages: [ chatTextMessage(role: "assistant", text: "before new", timestamp: 1), @@ -1659,7 +1824,7 @@ extension TestChatTransportState { #expect(await transport.lastSentRunId() == nil) } - @Test func sendAttemptsRequestWhenCachedHealthIsStaleFalse() async throws { + @Test func `send attempts request when cached health is stale false`() async throws { let (transport, vm) = await makeViewModel( historyResponses: [historyPayload()], healthResponses: [false]) @@ -1676,7 +1841,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.errorText } == nil) } - @Test func resetTriggerResetsSessionAndReloadsHistory() async throws { + @Test func `reset trigger resets session and reloads history`() async throws { let before = historyPayload( messages: [ chatTextMessage(role: "assistant", text: "before reset", timestamp: 1), @@ -1706,7 +1871,7 @@ extension TestChatTransportState { #expect(await transport.lastSentRunId() == nil) } - @Test func compactTriggerCompactsSessionAndReloadsHistory() async throws { + @Test func `compact trigger compacts session and reloads history`() async throws { let before = historyPayload( messages: [ chatTextMessage(role: "assistant", text: "before compact", timestamp: 1), @@ -1736,7 +1901,7 @@ extension TestChatTransportState { #expect(await transport.lastSentRunId() == nil) } - @Test func compactTriggerShowsGenericErrorMessageOnFailure() async throws { + @Test func `compact trigger shows generic error message on failure`() async throws { let history = historyPayload() let (transport, vm) = await makeViewModel( historyResponses: [history], @@ -1759,7 +1924,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.errorText } == "Unable to compact the session. Please try again.") } - @Test func compactTriggerIgnoresConcurrentAndImmediateRepeatRequests() async throws { + @Test func `compact trigger ignores concurrent and immediate repeat requests`() async throws { let before = historyPayload( messages: [ chatTextMessage(role: "assistant", text: "before compact", timestamp: 1), @@ -1803,7 +1968,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.errorText } == "Please wait before compacting this session again.") } - @Test func compactTriggerAllowsImmediateRetryAfterFailure() async throws { + @Test func `compact trigger allows immediate retry after failure`() async throws { let history = historyPayload() let attemptCount = AsyncCounter() let (transport, vm) = await makeViewModel( @@ -1840,7 +2005,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.errorText } == nil) } - @Test func bootstrapsModelSelectionFromSessionAndDefaults() async throws { + @Test func `bootstraps model selection from session and defaults`() async throws { let now = Date().timeIntervalSince1970 * 1000 let history = historyPayload() let sessions = OpenClawChatSessionsListResponse( @@ -1868,7 +2033,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.defaultModelLabel } == "Default: openai/gpt-4.1-mini") } - @Test func selectingDefaultModelPatchesNilAndUpdatesSelection() async throws { + @Test func `selecting default model patches nil and updates selection`() async throws { let now = Date().timeIntervalSince1970 * 1000 let history = historyPayload() let sessions = OpenClawChatSessionsListResponse( @@ -1901,7 +2066,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.modelSelectionID } == OpenClawChatViewModel.defaultModelSelectionID) } - @Test func selectingProviderQualifiedModelDisambiguatesDuplicateModelIDs() async throws { + @Test func `selecting provider qualified model disambiguates duplicate model I ds`() async throws { let now = Date().timeIntervalSince1970 * 1000 let history = historyPayload() let sessions = OpenClawChatSessionsListResponse( @@ -1934,7 +2099,7 @@ extension TestChatTransportState { } } - @Test func slashModelIDsStayProviderQualifiedInSelectionAndPatch() async throws { + @Test func `slash model I ds stay provider qualified in selection and patch`() async throws { let now = Date().timeIntervalSince1970 * 1000 let history = historyPayload() let sessions = OpenClawChatSessionsListResponse( @@ -1967,7 +2132,7 @@ extension TestChatTransportState { } } - @Test func staleModelPatchCompletionsDoNotOverwriteNewerSelection() async throws { + @Test func `stale model patch completions do not overwrite newer selection`() async throws { let now = Date().timeIntervalSince1970 * 1000 let history = historyPayload() let sessions = OpenClawChatSessionsListResponse( @@ -2010,7 +2175,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.modelProvider } == "openai") } - @Test func sendWaitsForInFlightModelPatchToFinish() async throws { + @Test func `send waits for in flight model patch to finish`() async throws { let now = Date().timeIntervalSince1970 * 1000 let history = historyPayload() let sessions = OpenClawChatSessionsListResponse( @@ -2063,7 +2228,7 @@ extension TestChatTransportState { #expect(await transport.sentThinkingLevels() == ["off"]) } - @Test func failedLatestModelSelectionDoesNotReplayAfterOlderCompletionFinishes() async throws { + @Test func `failed latest model selection does not replay after older completion finishes`() async throws { let now = Date().timeIntervalSince1970 * 1000 let history = historyPayload() let sessions = OpenClawChatSessionsListResponse( @@ -2113,7 +2278,7 @@ extension TestChatTransportState { #expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"]) } - @Test func failedLatestModelSelectionRestoresEarlierSuccessWithoutReplay() async throws { + @Test func `failed latest model selection restores earlier success without replay`() async throws { let now = Date().timeIntervalSince1970 * 1000 let history = historyPayload() let sessions = OpenClawChatSessionsListResponse( @@ -2162,7 +2327,1043 @@ extension TestChatTransportState { #expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"]) } - @Test func switchingSessionsIgnoresLateModelPatchCompletionFromPreviousSession() async throws { + @Test @MainActor func `switch session notifies session changed callback`() async throws { + var changedSessionKeys: [String] = [] + let (_, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload(sessionKey: "other", sessionId: "sess-other"), + ], + onSessionChanged: { changedSessionKeys.append($0) }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.switchSession(to: "other") + + try await waitUntil("user switch bootstrapped target session") { + await MainActor.run { vm.sessionKey == "other" && vm.sessionId == "sess-other" } + } + #expect(changedSessionKeys == ["other"]) + } + + @Test @MainActor func `sync session does not notify session changed callback`() async throws { + var changedSessionKeys: [String] = [] + let (_, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload(sessionKey: "other", sessionId: "sess-other"), + ], + onSessionChanged: { changedSessionKeys.append($0) }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.syncSession(to: "other") + + try await waitUntil("external sync bootstrapped target session") { + await MainActor.run { vm.sessionKey == "other" && vm.sessionId == "sess-other" } + } + #expect(changedSessionKeys.isEmpty) + } + + @Test @MainActor func `refresh ignores late history from canceled bootstrap for same session`() async throws { + let staleHistoryGate = SessionSubscribeGate() + let mainHistoryCount = AsyncCounter() + let staleHistoryReleasedCount = AsyncCounter() + let (_, vm) = await makeViewModel( + historyResponses: [ + historyPayload( + sessionKey: "main", + sessionId: "sess-stale-load", + messages: [chatTextMessage(role: "assistant", text: "stale load", timestamp: 1)]), + historyPayload( + sessionKey: "main", + sessionId: "sess-current-refresh", + messages: [chatTextMessage(role: "assistant", text: "current refresh", timestamp: 2)]), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await mainHistoryCount.increment() + if count == 1 { + await staleHistoryGate.wait() + _ = await staleHistoryReleasedCount.increment() + } + }) + + vm.load() + try await waitUntil("first bootstrap history request is in flight") { + await mainHistoryCount.current() == 1 + } + + vm.refresh() + try await waitUntil("refresh bootstrap wins") { + await MainActor.run { + vm.sessionId == "sess-current-refresh" && + vm.messages.contains { message in + message.content.contains { $0.text == "current refresh" } + } + } + } + + await staleHistoryGate.release() + try await waitUntil("stale load history resumes") { + await staleHistoryReleasedCount.current() == 1 + } + + #expect(await MainActor.run { vm.sessionId } == "sess-current-refresh") + #expect(await MainActor.run { + !vm.messages.contains { message in + message.content.contains { $0.text == "stale load" } + } + }) + } + + @Test @MainActor func `manual refresh invalidates older same session event refresh`() async throws { + let staleRefreshGate = SessionSubscribeGate() + let mainHistoryCount = AsyncCounter() + let staleRefreshReleasedCount = AsyncCounter() + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-event-stale", + messages: [chatTextMessage(role: "assistant", text: "stale same-session event", timestamp: 1)]), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-manual-refresh", + messages: [chatTextMessage(role: "assistant", text: "current manual refresh", timestamp: 2)]), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await mainHistoryCount.increment() + if count == 2 { + await staleRefreshGate.wait() + _ = await staleRefreshReleasedCount.increment() + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + transport.emit(.seqGap) + try await waitUntil("same-session event refresh is in flight") { + await mainHistoryCount.current() == 2 + } + + vm.refresh() + try await waitUntil("manual refresh wins") { + await MainActor.run { + vm.sessionId == "sess-main-manual-refresh" && + vm.messages.contains { message in + message.content.contains { $0.text == "current manual refresh" } + } + } + } + + await staleRefreshGate.release() + try await waitUntil("stale same-session event refresh resumes") { + await staleRefreshReleasedCount.current() == 1 + } + + #expect(await MainActor.run { vm.sessionId } == "sess-main-manual-refresh") + #expect(await MainActor.run { + !vm.messages.contains { message in + message.content.contains { $0.text == "stale same-session event" } + } + }) + } + + @Test @MainActor func `failed newer same session refresh does not drop older successful send refresh`() async throws { + let sendRefreshGate = SessionSubscribeGate() + let mainHistoryCount = AsyncCounter() + let now = Date().timeIntervalSince1970 * 1000 + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-send-refresh", + messages: [ + chatTextMessage(role: "user", text: "hello", timestamp: now), + chatTextMessage(role: "assistant", text: "reply from older success", timestamp: now + 1), + ]), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await mainHistoryCount.increment() + if count == 2 { + await sendRefreshGate.wait() + } + if count == 3 { + throw NSError( + domain: "ChatViewModelTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "newer event refresh failed"]) + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.input = "hello" + vm.send() + let runId = try await waitForLastSentRunId(transport) + try await waitUntil("post-send refresh is in flight") { + await mainHistoryCount.current() == 2 + } + + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: runId, + sessionKey: "main", + state: "final", + message: nil, + errorMessage: nil))) + try await waitUntil("newer event refresh starts") { + await mainHistoryCount.current() == 3 + } + + await sendRefreshGate.release() + + try await waitUntil("older successful send refresh applies") { + await MainActor.run { + vm.sessionId == "sess-main-send-refresh" && + vm.messages.contains { message in + message.content.contains { $0.text == "reply from older success" } + } + } + } + } + + @Test @MainActor func `newer empty terminal refresh does not drop older assistant run refresh`() async throws { + let sendRefreshGate = SessionSubscribeGate() + let mainHistoryCount = AsyncCounter() + let now = Date().timeIntervalSince1970 * 1000 + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-send-refresh", + messages: [ + chatTextMessage(role: "user", text: "hello", timestamp: now), + chatTextMessage(role: "assistant", text: "reply from older success", timestamp: now + 1), + ]), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-terminal-empty-refresh", + messages: [chatTextMessage(role: "user", text: "hello", timestamp: now)]), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await mainHistoryCount.increment() + if count == 2 { + await sendRefreshGate.wait() + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.input = "hello" + vm.send() + let runId = try await waitForLastSentRunId(transport) + try await waitUntil("post-send refresh is in flight") { + await mainHistoryCount.current() == 2 + } + + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: runId, + sessionKey: "main", + state: "final", + message: nil, + errorMessage: nil))) + try await waitUntil("newer empty terminal refresh applies") { + await MainActor.run { + vm.sessionId == "sess-main-terminal-empty-refresh" && + vm.pendingRunCount == 0 + } + } + + await sendRefreshGate.release() + + try await waitUntil("older successful send refresh applies assistant reply") { + await MainActor.run { + vm.sessionId == "sess-main-send-refresh" && + vm.pendingRunCount == 0 && + vm.messages.contains { message in + message.content.contains { $0.text == "reply from older success" } + } + } + } + } + + @Test @MainActor func `newer user only terminal refresh after final event message does not drop older assistant run refresh`() async throws { + let sendRefreshGate = SessionSubscribeGate() + let mainHistoryCount = AsyncCounter() + let now = Date().timeIntervalSince1970 * 1000 + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-send-refresh", + messages: [ + chatTextMessage(role: "user", text: "hello", timestamp: now), + chatTextMessage(role: "assistant", text: "reply from durable history", timestamp: now + 1), + ]), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-terminal-user-only-refresh", + messages: [chatTextMessage(role: "user", text: "hello", timestamp: now)]), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await mainHistoryCount.increment() + if count == 2 { + await sendRefreshGate.wait() + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.input = "hello" + vm.send() + let runId = try await waitForLastSentRunId(transport) + try await waitUntil("post-send refresh is in flight") { + await mainHistoryCount.current() == 2 + } + + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: runId, + sessionKey: "main", + state: "final", + message: chatTextMessage( + role: "assistant", + text: "reply from final event", + timestamp: now + 0.5), + errorMessage: nil))) + try await waitUntil("newer user-only terminal refresh applies") { + await MainActor.run { + vm.sessionId == "sess-main-terminal-user-only-refresh" && + !vm.messages.contains { message in + message.content.contains { $0.text == "reply from final event" } + } + } + } + + await sendRefreshGate.release() + + try await waitUntil("older successful send refresh applies durable assistant reply") { + await MainActor.run { + vm.sessionId == "sess-main-send-refresh" && + vm.pendingRunCount == 0 && + vm.messages.contains { message in + message.content.contains { $0.text == "reply from durable history" } + } + } + } + } + + @Test @MainActor func `manual refresh user only history does not drop older assistant run refresh`() async throws { + let sendRefreshGate = SessionSubscribeGate() + let mainHistoryCount = AsyncCounter() + let now = Date().timeIntervalSince1970 * 1000 + let (_, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-send-refresh", + messages: [ + chatTextMessage(role: "user", text: "hello", timestamp: now), + chatTextMessage(role: "assistant", text: "reply from older success", timestamp: now + 1), + ]), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-manual-user-only-refresh", + messages: [chatTextMessage(role: "user", text: "hello", timestamp: now)]), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await mainHistoryCount.increment() + if count == 2 { + await sendRefreshGate.wait() + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.input = "hello" + vm.send() + try await waitUntil("post-send refresh is in flight") { + await mainHistoryCount.current() == 2 + } + + vm.refresh() + try await waitUntil("manual user-only refresh applies") { + await MainActor.run { + vm.sessionId == "sess-main-manual-user-only-refresh" && + vm.pendingRunCount == 0 + } + } + + await sendRefreshGate.release() + + try await waitUntil("older successful send refresh applies after manual refresh") { + await MainActor.run { + vm.sessionId == "sess-main-send-refresh" && + vm.messages.contains { message in + message.content.contains { $0.text == "reply from older success" } + } + } + } + } + + @Test @MainActor func `manual refresh older complete history does not drop pending user assistant run refresh`() async throws { + let sendRefreshGate = SessionSubscribeGate() + let mainHistoryCount = AsyncCounter() + let now = Date().timeIntervalSince1970 * 1000 + let olderCompleteMessages = [ + chatTextMessage(role: "user", text: "older question", timestamp: now - 2), + chatTextMessage(role: "assistant", text: "older answer", timestamp: now - 1), + ] + let (_, vm) = await makeViewModel( + historyResponses: [ + historyPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: olderCompleteMessages), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-send-refresh", + messages: olderCompleteMessages + [ + chatTextMessage(role: "user", text: "hello", timestamp: now), + chatTextMessage(role: "assistant", text: "reply from pending turn", timestamp: now + 1), + ]), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-manual-older-complete-refresh", + messages: olderCompleteMessages), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await mainHistoryCount.increment() + if count == 2 { + await sendRefreshGate.wait() + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.input = "hello" + vm.send() + try await waitUntil("post-send refresh is in flight") { + await mainHistoryCount.current() == 2 + } + + vm.refresh() + try await waitUntil("manual older complete refresh applies") { + await MainActor.run { + vm.sessionId == "sess-main-manual-older-complete-refresh" && + vm.messages.contains { message in + message.content.contains { $0.text == "older answer" } + } && + !vm.messages.contains { message in + message.content.contains { $0.text == "reply from pending turn" } + } + } + } + + await sendRefreshGate.release() + + try await waitUntil("older successful send refresh applies pending turn answer") { + await MainActor.run { + vm.sessionId == "sess-main-send-refresh" && + vm.messages.contains { message in + message.content.contains { $0.text == "reply from pending turn" } + } + } + } + } + + @Test @MainActor func `manual stale complete refresh after final event does not drop durable reply refresh`() async throws { + let sendRefreshGate = SessionSubscribeGate() + let eventRefreshGate = SessionSubscribeGate() + let mainHistoryCount = AsyncCounter() + let now = Date().timeIntervalSince1970 * 1000 + let olderCompleteMessages = [ + chatTextMessage(role: "user", text: "older question", timestamp: now - 2), + chatTextMessage(role: "assistant", text: "older answer", timestamp: now - 1), + ] + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: olderCompleteMessages), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-send-refresh", + messages: olderCompleteMessages + [ + chatTextMessage(role: "user", text: "hello", timestamp: now), + chatTextMessage(role: "assistant", text: "durable reply", timestamp: now + 1), + ]), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-event-stale-complete-refresh", + messages: olderCompleteMessages), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-manual-stale-complete-refresh", + messages: olderCompleteMessages), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await mainHistoryCount.increment() + if count == 2 { + await sendRefreshGate.wait() + } + if count == 3 { + await eventRefreshGate.wait() + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.input = "hello" + vm.send() + let runId = try await waitForLastSentRunId(transport) + try await waitUntil("post-send refresh is in flight") { + await mainHistoryCount.current() == 2 + } + + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: runId, + sessionKey: "main", + state: "final", + message: chatTextMessage(role: "assistant", text: "local final reply", timestamp: now + 0.5), + errorMessage: nil))) + try await waitUntil("local final event reply is visible") { + await MainActor.run { + vm.messages.contains { message in + message.content.contains { $0.text == "local final reply" } + } + } + } + + vm.refresh() + try await waitUntil("manual stale complete refresh applies without durable reply") { + let historyCount = await mainHistoryCount.current() + let stateMatches = await MainActor.run { + vm.sessionId == "sess-main-manual-stale-complete-refresh" && + !vm.messages.contains { message in + message.content.contains { $0.text == "durable reply" } + } + } + return historyCount == 4 && stateMatches + } + + await eventRefreshGate.release() + try await waitUntil("event stale complete refresh resumes") { + await MainActor.run { + vm.sessionId == "sess-main-event-stale-complete-refresh" + } + } + + await sendRefreshGate.release() + + try await waitUntil("older durable send refresh applies after manual stale refresh") { + await MainActor.run { + vm.sessionId == "sess-main-send-refresh" && + vm.messages.contains { message in + message.content.contains { $0.text == "durable reply" } + } + } + } + } + + @Test @MainActor func `bootstrap history does not overwrite newer same session refresh`() async throws { + let bootstrapHistoryGate = SessionSubscribeGate() + let mainHistoryCount = AsyncCounter() + let bootstrapHistoryReleasedCount = AsyncCounter() + let sessions = OpenClawChatSessionsListResponse( + ts: Date().timeIntervalSince1970 * 1000, + path: nil, + count: 1, + defaults: nil, + sessions: [sessionEntry(key: "main", updatedAt: Date().timeIntervalSince1970 * 1000)]) + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload( + sessionKey: "main", + sessionId: "sess-main-bootstrap-stale", + messages: [chatTextMessage(role: "assistant", text: "stale bootstrap", timestamp: 1)]), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-event-newer", + messages: [chatTextMessage(role: "assistant", text: "newer event refresh", timestamp: 2)]), + ], + sessionsResponses: [sessions], + modelResponses: [[modelChoice(id: "glm-5.1", name: "GLM 5.1")]], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await mainHistoryCount.increment() + if count == 1 { + await bootstrapHistoryGate.wait() + _ = await bootstrapHistoryReleasedCount.increment() + } + }) + + vm.load() + try await waitUntil("bootstrap history is in flight") { + await mainHistoryCount.current() == 1 + } + + transport.emit(.seqGap) + try await waitUntil("newer same-session refresh applies") { + await MainActor.run { + vm.sessionId == "sess-main-event-newer" && + vm.messages.contains { message in + message.content.contains { $0.text == "newer event refresh" } + } + } + } + + await bootstrapHistoryGate.release() + try await waitUntil("bootstrap history resumes") { + await bootstrapHistoryReleasedCount.current() == 1 + } + + #expect(await MainActor.run { vm.sessionId } == "sess-main-event-newer") + #expect(await MainActor.run { + !vm.messages.contains { message in + message.content.contains { $0.text == "stale bootstrap" } + } + }) + try await waitUntil("bootstrap metadata still loads") { + await MainActor.run { + vm.healthOK && + vm.sessions.contains { $0.key == "main" } && + vm.modelChoices.contains { $0.modelID == "glm-5.1" } + } + } + } + + @Test @MainActor func `stale fallback refresh keeps retrying while run remains pending`() async throws { + let staleFallbackGate = SessionSubscribeGate() + let mainHistoryCount = AsyncCounter() + let staleFallbackReleasedCount = AsyncCounter() + let now = (Date().timeIntervalSince1970 * 1000) + 10000 + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-send-refresh", + messages: [chatTextMessage(role: "user", text: "hello", timestamp: now)]), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-stale-fallback", + messages: [chatTextMessage(role: "user", text: "hello", timestamp: now)]), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-newer-empty-refresh", + messages: [chatTextMessage(role: "user", text: "hello", timestamp: now)]), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-next-fallback", + messages: [ + chatTextMessage(role: "user", text: "hello", timestamp: now), + chatTextMessage(role: "assistant", text: "reply from later fallback", timestamp: now + 1), + ]), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await mainHistoryCount.increment() + if count == 3 { + await staleFallbackGate.wait() + _ = await staleFallbackReleasedCount.increment() + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.input = "hello" + vm.send() + _ = try await waitForLastSentRunId(transport) + try await waitUntil("first fallback refresh is in flight") { + await mainHistoryCount.current() == 3 + } + + emitExternalFinal(transport: transport, runId: "external-run", sessionKey: "main") + try await waitUntil("newer empty refresh applies") { + await MainActor.run { vm.sessionId == "sess-main-newer-empty-refresh" } + } + + await staleFallbackGate.release() + try await waitUntil("stale fallback resumes") { + await staleFallbackReleasedCount.current() == 1 + } + + try await waitUntil("later fallback still runs", timeoutSeconds: 7.0) { + await mainHistoryCount.current() >= 5 + } + try await waitUntil("later fallback applies assistant reply") { + await MainActor.run { + vm.pendingRunCount == 0 && + vm.messages.contains { message in + message.content.contains { $0.text == "reply from later fallback" } + } + } + } + } + + @Test @MainActor func `stale bootstrap history does not overwrite latest session`() async throws { + let staleHistoryGate = SessionSubscribeGate() + let staleHistoryReleasedCount = AsyncCounter() + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload( + sessionKey: "other", + sessionId: "sess-other-stale", + messages: [chatTextMessage(role: "assistant", text: "stale other", timestamp: 1)]), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-current", + messages: [chatTextMessage(role: "assistant", text: "current main", timestamp: 2)]), + ], + requestHistoryHook: { sessionKey in + if sessionKey == "other" { + await staleHistoryGate.wait() + _ = await staleHistoryReleasedCount.increment() + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.syncSession(to: "other") + try await waitUntil("other session subscribe starts") { + await transport.activeSessionKeys().last == "other" + } + + vm.syncSession(to: "main") + try await waitUntil("main session wins") { + await MainActor.run { + vm.sessionKey == "main" && + vm.sessionId == "sess-main-current" && + vm.messages.contains { message in + message.content.contains { $0.text == "current main" } + } + } + } + + await staleHistoryGate.release() + try await waitUntil("stale other history resumes") { + await staleHistoryReleasedCount.current() == 1 + } + + #expect(await MainActor.run { vm.sessionId } == "sess-main-current") + #expect(await MainActor.run { + !vm.messages.contains { message in + message.content.contains { $0.text == "stale other" } + } + }) + } + + @Test @MainActor func `session switch clears old latest user before new session refreshes`() async throws { + let staleBootstrapGate = SessionSubscribeGate() + let otherHistoryCount = AsyncCounter() + let staleBootstrapReleasedCount = AsyncCounter() + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [chatTextMessage(role: "user", text: "main pending question", timestamp: 1)]), + historyPayload( + sessionKey: "other", + sessionId: "sess-other-bootstrap-stale", + messages: [chatTextMessage(role: "assistant", text: "stale other bootstrap", timestamp: 2)]), + historyPayload( + sessionKey: "other", + sessionId: "sess-other-newer-refresh", + messages: [chatTextMessage(role: "assistant", text: "newer other refresh", timestamp: 3)]), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "other" else { return } + let count = await otherHistoryCount.increment() + if count == 1 { + await staleBootstrapGate.wait() + _ = await staleBootstrapReleasedCount.increment() + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.syncSession(to: "other") + try await waitUntil("other bootstrap history is in flight") { + await otherHistoryCount.current() == 1 + } + #expect(await MainActor.run { vm.messages.isEmpty }) + + transport.emit(.seqGap) + try await waitUntil("newer other refresh applies") { + await MainActor.run { + vm.sessionKey == "other" && + vm.sessionId == "sess-other-newer-refresh" && + vm.messages.contains { message in + message.content.contains { $0.text == "newer other refresh" } + } + } + } + + await staleBootstrapGate.release() + try await waitUntil("stale other bootstrap resumes") { + await staleBootstrapReleasedCount.current() == 1 + } + + #expect(await MainActor.run { vm.sessionId } == "sess-other-newer-refresh") + #expect(await MainActor.run { + !vm.messages.contains { message in + message.content.contains { $0.text == "stale other bootstrap" } + } + }) + } + + @Test @MainActor func `stale seq gap refresh does not overwrite latest session`() async throws { + let staleRefreshGate = SessionSubscribeGate() + let mainHistoryCount = AsyncCounter() + let staleRefreshReleasedCount = AsyncCounter() + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload( + sessionKey: "main", + sessionId: "sess-main-gap-stale", + messages: [chatTextMessage(role: "assistant", text: "stale gap", timestamp: 1)]), + historyPayload( + sessionKey: "other", + sessionId: "sess-other-current", + messages: [chatTextMessage(role: "assistant", text: "current other", timestamp: 2)]), + ], + requestHistoryHook: { sessionKey in + guard sessionKey == "main" else { return } + let count = await mainHistoryCount.increment() + if count == 2 { + await staleRefreshGate.wait() + _ = await staleRefreshReleasedCount.increment() + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + transport.emit(.seqGap) + try await waitUntil("seq gap refresh is in flight") { + await mainHistoryCount.current() == 2 + } + + vm.syncSession(to: "other") + try await waitUntil("other session bootstrap wins") { + await MainActor.run { + vm.sessionKey == "other" && + vm.sessionId == "sess-other-current" && + vm.messages.contains { message in + message.content.contains { $0.text == "current other" } + } + } + } + + await staleRefreshGate.release() + try await waitUntil("stale seq gap refresh resumes") { + await staleRefreshReleasedCount.current() == 1 + } + + #expect(await MainActor.run { vm.sessionId } == "sess-other-current") + #expect(await MainActor.run { + !vm.messages.contains { message in + message.content.contains { $0.text == "stale gap" } + } + }) + } + + @Test @MainActor func `send waiting for model patch does not send after session switch`() async throws { + let modelPatchGate = SessionSubscribeGate() + let modelPatchReleasedCount = AsyncCounter() + let models = [modelChoice(id: "gpt-5.4", name: "GPT-5.4", provider: "openai")] + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload(sessionKey: "other", sessionId: "sess-other"), + ], + modelResponses: [models, models], + setSessionModelHook: { _ in + await modelPatchGate.wait() + _ = await modelPatchReleasedCount.increment() + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.selectModel("openai/gpt-5.4") + try await waitUntil("model patch is in flight") { + await transport.patchedModels() == ["openai/gpt-5.4"] + } + + vm.input = "hello before switch" + vm.send() + try await waitUntil("send is waiting for model patch") { + await MainActor.run { vm.pendingRunCount == 1 } + } + + vm.syncSession(to: "other") + try await waitUntil("session switch clears pending send") { + await MainActor.run { + vm.sessionKey == "other" && + vm.sessionId == "sess-other" && + vm.pendingRunCount == 0 + } + } + + await modelPatchGate.release() + try await waitUntil("model patch resumes") { + await modelPatchReleasedCount.current() == 1 + } + try await Task.sleep(for: .milliseconds(100)) + + #expect(await transport.sentRunIds().isEmpty) + } + + @Test @MainActor func `stale sync bootstrap restores current active session subscription`() async throws { + let staleSubscribeGate = SessionSubscribeGate() + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload(sessionKey: "other", sessionId: "sess-other"), + ], + setActiveSessionHook: { sessionKey in + if sessionKey == "other" { + await staleSubscribeGate.wait() + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.syncSession(to: "other") + try await waitUntil("stale subscribe is in flight") { + await transport.activeSessionKeys().last == "other" + } + + vm.syncSession(to: "main") + try await waitUntil("current session subscribed") { + let sessionKey = await MainActor.run { vm.sessionKey } + let activeSessionKeys = await transport.activeSessionKeys() + return sessionKey == "main" && + Array(activeSessionKeys.suffix(2)) == ["other", "main"] + } + + await staleSubscribeGate.release() + + try await waitUntil("current session resubscribed after stale subscribe") { + await Array(transport.activeSessionKeys().suffix(3)) == ["other", "main", "main"] + } + } + + @Test @MainActor func `stale subscribe failure reasserts current active session subscription`() async throws { + let staleSubscribeGate = SessionSubscribeGate() + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload(sessionKey: "main", sessionId: "sess-main"), + ], + setActiveSessionHook: { sessionKey in + if sessionKey == "other" { + await staleSubscribeGate.wait() + throw NSError( + domain: "TestChatTransport", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "stale subscribe failed after side effect"]) + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.syncSession(to: "other") + try await waitUntil("stale subscribe is in flight") { + await transport.activeSessionKeys().last == "other" + } + + vm.syncSession(to: "main") + try await waitUntil("current session subscribed") { + await Array(transport.activeSessionKeys().suffix(2)) == ["other", "main"] + } + + await staleSubscribeGate.release() + + try await waitUntil("current session resubscribed after stale subscribe failure") { + await Array(transport.activeSessionKeys().suffix(3)) == ["other", "main", "main"] + } + } + + @Test @MainActor func `stale sync repair reasserts latest active session subscription`() async throws { + let staleSubscribeGate = SessionSubscribeGate() + let staleRepairGate = SessionSubscribeGate() + let mainSubscribeCount = AsyncCounter() + let (transport, vm) = await makeViewModel( + historyResponses: [ + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload(sessionKey: "main", sessionId: "sess-main"), + historyPayload(sessionKey: "final", sessionId: "sess-final"), + ], + setActiveSessionHook: { sessionKey in + if sessionKey == "other" { + await staleSubscribeGate.wait() + } + if sessionKey == "main" { + let count = await mainSubscribeCount.increment() + if count == 3 { + await staleRepairGate.wait() + } + } + }) + + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + vm.syncSession(to: "other") + try await waitUntil("stale subscribe is in flight") { + await transport.activeSessionKeys().last == "other" + } + + vm.syncSession(to: "main") + try await waitUntil("main session subscribed") { + await Array(transport.activeSessionKeys().suffix(2)) == ["other", "main"] + } + + await staleSubscribeGate.release() + try await waitUntil("stale repair is in flight") { + await Array(transport.activeSessionKeys().suffix(3)) == ["other", "main", "main"] + } + + vm.syncSession(to: "final") + try await waitUntil("newest session subscribed") { + let sessionKey = await MainActor.run { vm.sessionKey } + let activeSessionKeys = await transport.activeSessionKeys() + return sessionKey == "final" && activeSessionKeys.last == "final" + } + + await staleRepairGate.release() + + try await waitUntil("newest session resubscribed after stale repair") { + await Array(transport.activeSessionKeys().suffix(3)) == ["main", "final", "final"] + } + } + + @Test func `switching sessions ignores late model patch completion from previous session`() async throws { let now = Date().timeIntervalSince1970 * 1000 let sessions = OpenClawChatSessionsListResponse( ts: now, @@ -2207,7 +3408,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "other" })?.model } == nil) } - @Test func lateModelCompletionDoesNotReplayCurrentSessionSelectionIntoPreviousSession() async throws { + @Test func `late model completion does not replay current session selection into previous session`() async throws { let now = Date().timeIntervalSince1970 * 1000 let initialSessions = OpenClawChatSessionsListResponse( ts: now, @@ -2279,7 +3480,7 @@ extension TestChatTransportState { #expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"]) } - @Test func explicitThinkingLevelWinsOverHistoryAndPersistsChanges() async throws { + @Test func `explicit thinking level wins over history and persists changes`() async throws { let history = OpenClawChatHistoryPayload( sessionKey: "main", sessionId: "sess-main", @@ -2308,7 +3509,7 @@ extension TestChatTransportState { #expect(await MainActor.run { callbackState.values } == ["medium"]) } - @Test func serverProvidedThinkingLevelsOutsideMenuArePreservedForSend() async throws { + @Test func `server provided thinking levels outside menu are preserved for send`() async throws { let history = OpenClawChatHistoryPayload( sessionKey: "main", sessionId: "sess-main", @@ -2326,7 +3527,7 @@ extension TestChatTransportState { } } - @Test func decodesGatewayThinkingMetadataFromSessionList() throws { + @Test func `decodes gateway thinking metadata from session list`() throws { let json = """ { "defaults": { @@ -2370,7 +3571,7 @@ extension TestChatTransportState { #expect(decoded.sessions.first?.thinkingDefault == "max") } - @Test func sessionThinkingLevelsDrivePickerOptions() async throws { + @Test func `session thinking levels drive picker options`() async throws { let history = OpenClawChatHistoryPayload( sessionKey: "main", sessionId: "sess-main", @@ -2433,7 +3634,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.thinkingLevelOptions.map(\.label) } == ["off", "adaptive", "maximum"]) } - @Test func thinkingOptionsFallbackAndCurrentUnsupportedLevelStayVisible() async throws { + @Test func `thinking options fallback and current unsupported level stay visible`() async throws { let history = OpenClawChatHistoryPayload( sessionKey: "main", sessionId: "sess-main", @@ -2481,7 +3682,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.thinkingLevelOptions.map(\.label) } == ["off", "max", "xhigh"]) } - @Test func matchingDefaultThinkingLevelsBeatLegacyRowThinkingOptions() async throws { + @Test func `matching default thinking levels beat legacy row thinking options`() async throws { let history = OpenClawChatHistoryPayload( sessionKey: "main", sessionId: "sess-main", @@ -2537,7 +3738,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.thinkingLevelOptions.map(\.id) } == ["off", "adaptive", "max"]) } - @Test func defaultThinkingLevelsDoNotLeakToDifferentSessionModel() async throws { + @Test func `default thinking levels do not leak to different session model`() async throws { let history = OpenClawChatHistoryPayload( sessionKey: "main", sessionId: "sess-main", @@ -2592,7 +3793,7 @@ extension TestChatTransportState { ["off", "minimal", "low", "medium", "high", "max"]) } - @Test func staleThinkingPatchCompletionReappliesLatestSelection() async throws { + @Test func `stale thinking patch completion reapplies latest selection`() async throws { let history = OpenClawChatHistoryPayload( sessionKey: "main", sessionId: "sess-main", @@ -2622,7 +3823,7 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.thinkingLevel } == "high") } - @Test func clearsStreamingOnExternalErrorEvent() async throws { + @Test func `clears streaming on external error event`() async throws { let sessionId = "sess-main" let history = historyPayload(sessionId: sessionId) let (transport, vm) = await makeViewModel(historyResponses: [history, history]) @@ -2646,7 +3847,7 @@ extension TestChatTransportState { try await waitUntil("streaming cleared") { await MainActor.run { vm.streamingAssistantText == nil } } } - @Test func stripsInboundMetadataFromHistoryMessages() async throws { + @Test func `strips inbound metadata from history messages`() async throws { let history = OpenClawChatHistoryPayload( sessionKey: "main", sessionId: "sess-main", @@ -2675,7 +3876,7 @@ extension TestChatTransportState { #expect(sanitized == "Hello?") } - @Test func abortRequestsDoNotClearPendingUntilAbortedEvent() async throws { + @Test func `abort requests do not clear pending until aborted event`() async throws { let sessionId = "sess-main" let history = historyPayload(sessionId: sessionId) let (transport, vm) = await makeViewModel(historyResponses: [history, history]) diff --git a/apps/swabble/Package.resolved b/apps/swabble/Package.resolved index b5cea3c5a62f..a64270d1544a 100644 --- a/apps/swabble/Package.resolved +++ b/apps/swabble/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "646c710cf04fdf9e6c6ca935f3184924db3397a816848a7f8a8a3c10a4d8e9c8", + "originHash" : "be13c4e768c90bbea0fd647d27f5900702a82c39b9affde238d77edf40c8d97c", "pins" : [ { "identity" : "commander", @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-testing", "state" : { - "revision" : "937120cbc281cf29727fdfb8734482158508b4fc", - "version" : "6.3.1" + "revision" : "70eff261d7f462cad1fff51e05bcc74aa0b0f420", + "version" : "6.3.2" } } ], diff --git a/config/knip.config.ts b/config/knip.config.ts index 1d3266388baf..f48c10088476 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -217,6 +217,16 @@ const config = { entry: ["index.js!", "scripts/postinstall.js!"], project: ["index.js!", "scripts/**/*.js!"], }, + [`${BUNDLED_PLUGIN_ROOT_DIR}/llama-cpp`]: { + entry: bundledPluginEntries, + project: ["index.ts!", "src/**/*.{js,mjs,ts}!"], + ignoreDependencies: [ + // The provider resolves node-llama-cpp from its own package at runtime + // so local embeddings use the plugin-owned native dependency. + "node-llama-cpp", + ...bundledPluginIgnoredRuntimeDependencies, + ], + }, [`${BUNDLED_PLUGIN_ROOT_DIR}/*`]: { // Bundled plugins often load their public surface via string specifiers in // `index.ts` contracts, so Knip needs these convention-based entry files. diff --git a/docker-compose.yml b/docker-compose.yml index 17800eb8a019..888454231517 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,9 +39,9 @@ services: CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-} TZ: ${OPENCLAW_TZ:-UTC} volumes: - - ${OPENCLAW_CONFIG_DIR:-${HOME:-/tmp}/.openclaw}:/home/node/.openclaw - - ${OPENCLAW_WORKSPACE_DIR:-${HOME:-/tmp}/.openclaw/workspace}:/home/node/.openclaw/workspace - - ${OPENCLAW_AUTH_PROFILE_SECRET_DIR:-${HOME:-/tmp}/.openclaw-auth-profile-secrets}:/home/node/.config/openclaw + - "${OPENCLAW_CONFIG_DIR:-${HOME:-/tmp}/.openclaw}:/home/node/.openclaw" + - "${OPENCLAW_WORKSPACE_DIR:-${HOME:-/tmp}/.openclaw/workspace}:/home/node/.openclaw/workspace" + - "${OPENCLAW_AUTH_PROFILE_SECRET_DIR:-${HOME:-/tmp}/.openclaw-auth-profile-secrets}:/home/node/.config/openclaw" ## Uncomment the lines below to enable sandbox isolation ## (agents.defaults.sandbox). Requires Docker CLI in the image ## (build with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1) or use @@ -118,9 +118,9 @@ services: CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-} TZ: ${OPENCLAW_TZ:-UTC} volumes: - - ${OPENCLAW_CONFIG_DIR:-${HOME:-/tmp}/.openclaw}:/home/node/.openclaw - - ${OPENCLAW_WORKSPACE_DIR:-${HOME:-/tmp}/.openclaw/workspace}:/home/node/.openclaw/workspace - - ${OPENCLAW_AUTH_PROFILE_SECRET_DIR:-${HOME:-/tmp}/.openclaw-auth-profile-secrets}:/home/node/.config/openclaw + - "${OPENCLAW_CONFIG_DIR:-${HOME:-/tmp}/.openclaw}:/home/node/.openclaw" + - "${OPENCLAW_WORKSPACE_DIR:-${HOME:-/tmp}/.openclaw/workspace}:/home/node/.openclaw/workspace" + - "${OPENCLAW_AUTH_PROFILE_SECRET_DIR:-${HOME:-/tmp}/.openclaw-auth-profile-secrets}:/home/node/.config/openclaw" stdin_open: true tty: true init: true diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index a06314937ba7..015b6c066d17 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -60c0700719fd2fe3f7cec4c35da10227b681d87ed1a3876ef830eb6bd80d43f2 config-baseline.json -2ed21fa4a416ac2cec55eb2b6d1b11859aa04b40bd78c6ed9f3eb45b7240261c config-baseline.core.json -0637c9bdcb9517f56049dd786563366877458d35df575328a6b80a890c8bc915 config-baseline.channel.json -e6a1d6f51f0d9c04bd92d51deebfaca8c7917dd28d7998d225c0074e0a095348 config-baseline.plugin.json +0485ba902d2afd89d2c41cde7180d0cec2900b2db6804b9f97d42b7d85cd3af5 config-baseline.json +72bb80be618406f3337eaa2560d2559a35e49bd29576de8dd4a3aec1a6a94d92 config-baseline.core.json +1218f5555541b61bd5ddcac6441f15061b44789e2471d4ffecbe3059777c55c1 config-baseline.channel.json +a14ac4261e98403d1a7e047070e6f151938444e27382b860315bd0c74fda4861 config-baseline.plugin.json diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index a12921683a3a..79f94af2078e 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -a3ab01b572937539e563aa320ad80135bc701e20fffc43c0351d799590b7a0e0 plugin-sdk-api-baseline.json -9d49587923f8fc4abb16d981bcab54acbf90a3e74ab05933761049e2da0cffe1 plugin-sdk-api-baseline.jsonl +b121079a0912b3051a9fc319a675ef920da9db23364ca0c0ccd3c9f0a05a3a49 plugin-sdk-api-baseline.json +61a0108da670e0f44ba4b861c002eb6eaa5cf63e392d4e7e7de42044cbe7d115 plugin-sdk-api-baseline.jsonl diff --git a/docs/.i18n/glossary.ar.json b/docs/.i18n/glossary.ar.json index eda82bba3b5c..11be562b96e2 100644 --- a/docs/.i18n/glossary.ar.json +++ b/docs/.i18n/glossary.ar.json @@ -35,6 +35,10 @@ "source": "Heartbeat", "target": "Heartbeat" }, + { + "source": "LINE", + "target": "LINE" + }, { "source": "Mintlify", "target": "Mintlify" diff --git a/docs/.i18n/glossary.de.json b/docs/.i18n/glossary.de.json index eda82bba3b5c..11be562b96e2 100644 --- a/docs/.i18n/glossary.de.json +++ b/docs/.i18n/glossary.de.json @@ -35,6 +35,10 @@ "source": "Heartbeat", "target": "Heartbeat" }, + { + "source": "LINE", + "target": "LINE" + }, { "source": "Mintlify", "target": "Mintlify" diff --git a/docs/.i18n/glossary.es.json b/docs/.i18n/glossary.es.json index eda82bba3b5c..11be562b96e2 100644 --- a/docs/.i18n/glossary.es.json +++ b/docs/.i18n/glossary.es.json @@ -35,6 +35,10 @@ "source": "Heartbeat", "target": "Heartbeat" }, + { + "source": "LINE", + "target": "LINE" + }, { "source": "Mintlify", "target": "Mintlify" diff --git a/docs/.i18n/glossary.fa.json b/docs/.i18n/glossary.fa.json index eda82bba3b5c..11be562b96e2 100644 --- a/docs/.i18n/glossary.fa.json +++ b/docs/.i18n/glossary.fa.json @@ -35,6 +35,10 @@ "source": "Heartbeat", "target": "Heartbeat" }, + { + "source": "LINE", + "target": "LINE" + }, { "source": "Mintlify", "target": "Mintlify" diff --git a/docs/.i18n/glossary.fr.json b/docs/.i18n/glossary.fr.json index eda82bba3b5c..11be562b96e2 100644 --- a/docs/.i18n/glossary.fr.json +++ b/docs/.i18n/glossary.fr.json @@ -35,6 +35,10 @@ "source": "Heartbeat", "target": "Heartbeat" }, + { + "source": "LINE", + "target": "LINE" + }, { "source": "Mintlify", "target": "Mintlify" diff --git a/docs/.i18n/glossary.id.json b/docs/.i18n/glossary.id.json index eda82bba3b5c..11be562b96e2 100644 --- a/docs/.i18n/glossary.id.json +++ b/docs/.i18n/glossary.id.json @@ -35,6 +35,10 @@ "source": "Heartbeat", "target": "Heartbeat" }, + { + "source": "LINE", + "target": "LINE" + }, { "source": "Mintlify", "target": "Mintlify" diff --git a/docs/.i18n/glossary.it.json b/docs/.i18n/glossary.it.json index eda82bba3b5c..11be562b96e2 100644 --- a/docs/.i18n/glossary.it.json +++ b/docs/.i18n/glossary.it.json @@ -35,6 +35,10 @@ "source": "Heartbeat", "target": "Heartbeat" }, + { + "source": "LINE", + "target": "LINE" + }, { "source": "Mintlify", "target": "Mintlify" diff --git a/docs/.i18n/glossary.ja-JP.json b/docs/.i18n/glossary.ja-JP.json index 605c14f2da77..af996efdcbcb 100644 --- a/docs/.i18n/glossary.ja-JP.json +++ b/docs/.i18n/glossary.ja-JP.json @@ -39,6 +39,10 @@ "source": "Heartbeat", "target": "Heartbeat" }, + { + "source": "LINE", + "target": "LINE" + }, { "source": "local loopback", "target": "local loopback" diff --git a/docs/.i18n/glossary.ko.json b/docs/.i18n/glossary.ko.json index eda82bba3b5c..11be562b96e2 100644 --- a/docs/.i18n/glossary.ko.json +++ b/docs/.i18n/glossary.ko.json @@ -35,6 +35,10 @@ "source": "Heartbeat", "target": "Heartbeat" }, + { + "source": "LINE", + "target": "LINE" + }, { "source": "Mintlify", "target": "Mintlify" diff --git a/docs/.i18n/glossary.nl.json b/docs/.i18n/glossary.nl.json index eda82bba3b5c..11be562b96e2 100644 --- a/docs/.i18n/glossary.nl.json +++ b/docs/.i18n/glossary.nl.json @@ -35,6 +35,10 @@ "source": "Heartbeat", "target": "Heartbeat" }, + { + "source": "LINE", + "target": "LINE" + }, { "source": "Mintlify", "target": "Mintlify" diff --git a/docs/.i18n/glossary.pl.json b/docs/.i18n/glossary.pl.json index eda82bba3b5c..11be562b96e2 100644 --- a/docs/.i18n/glossary.pl.json +++ b/docs/.i18n/glossary.pl.json @@ -35,6 +35,10 @@ "source": "Heartbeat", "target": "Heartbeat" }, + { + "source": "LINE", + "target": "LINE" + }, { "source": "Mintlify", "target": "Mintlify" diff --git a/docs/.i18n/glossary.pt-BR.json b/docs/.i18n/glossary.pt-BR.json index eda82bba3b5c..11be562b96e2 100644 --- a/docs/.i18n/glossary.pt-BR.json +++ b/docs/.i18n/glossary.pt-BR.json @@ -35,6 +35,10 @@ "source": "Heartbeat", "target": "Heartbeat" }, + { + "source": "LINE", + "target": "LINE" + }, { "source": "Mintlify", "target": "Mintlify" diff --git a/docs/.i18n/glossary.th.json b/docs/.i18n/glossary.th.json index eda82bba3b5c..11be562b96e2 100644 --- a/docs/.i18n/glossary.th.json +++ b/docs/.i18n/glossary.th.json @@ -35,6 +35,10 @@ "source": "Heartbeat", "target": "Heartbeat" }, + { + "source": "LINE", + "target": "LINE" + }, { "source": "Mintlify", "target": "Mintlify" diff --git a/docs/.i18n/glossary.tr.json b/docs/.i18n/glossary.tr.json index eda82bba3b5c..11be562b96e2 100644 --- a/docs/.i18n/glossary.tr.json +++ b/docs/.i18n/glossary.tr.json @@ -35,6 +35,10 @@ "source": "Heartbeat", "target": "Heartbeat" }, + { + "source": "LINE", + "target": "LINE" + }, { "source": "Mintlify", "target": "Mintlify" diff --git a/docs/.i18n/glossary.uk.json b/docs/.i18n/glossary.uk.json index eda82bba3b5c..11be562b96e2 100644 --- a/docs/.i18n/glossary.uk.json +++ b/docs/.i18n/glossary.uk.json @@ -35,6 +35,10 @@ "source": "Heartbeat", "target": "Heartbeat" }, + { + "source": "LINE", + "target": "LINE" + }, { "source": "Mintlify", "target": "Mintlify" diff --git a/docs/.i18n/glossary.vi.json b/docs/.i18n/glossary.vi.json index eda82bba3b5c..11be562b96e2 100644 --- a/docs/.i18n/glossary.vi.json +++ b/docs/.i18n/glossary.vi.json @@ -35,6 +35,10 @@ "source": "Heartbeat", "target": "Heartbeat" }, + { + "source": "LINE", + "target": "LINE" + }, { "source": "Mintlify", "target": "Mintlify" diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json index 94705c5f10ca..32127016fead 100644 --- a/docs/.i18n/glossary.zh-CN.json +++ b/docs/.i18n/glossary.zh-CN.json @@ -55,14 +55,6 @@ "source": "Mantis", "target": "Mantis" }, - { - "source": "OpenClaw App SDK", - "target": "OpenClaw 应用 SDK" - }, - { - "source": "OpenClaw App SDK API design", - "target": "OpenClaw 应用 SDK API 设计" - }, { "source": "Message lifecycle refactor", "target": "消息生命周期重构" @@ -263,10 +255,66 @@ "source": "Feishu", "target": "Feishu" }, + { + "source": "ClickClack", + "target": "ClickClack" + }, + { + "source": "IRC", + "target": "IRC" + }, + { + "source": "LINE", + "target": "LINE" + }, + { + "source": "Nextcloud Talk", + "target": "Nextcloud Talk" + }, + { + "source": "Nostr", + "target": "Nostr" + }, + { + "source": "QQ bot", + "target": "QQ Bot" + }, + { + "source": "SMS", + "target": "SMS" + }, + { + "source": "Synology Chat", + "target": "Synology Chat" + }, + { + "source": "Tlon", + "target": "Tlon" + }, + { + "source": "Twitch", + "target": "Twitch" + }, + { + "source": "Twilio", + "target": "Twilio" + }, { "source": "Yuanbao", "target": "腾讯元宝" }, + { + "source": "Zalo", + "target": "Zalo" + }, + { + "source": "Zalo Personal", + "target": "Zalo Personal" + }, + { + "source": "Zalo personal", + "target": "Zalo Personal" + }, { "source": "WeChat", "target": "微信" @@ -563,6 +611,10 @@ "source": "QQ Bot", "target": "QQ Bot" }, + { + "source": "QQBot", + "target": "QQ Bot" + }, { "source": "Release Policy", "target": "发布策略" @@ -731,6 +783,14 @@ "source": "OC Path plugin", "target": "OC Path 插件" }, + { + "source": "Parallel plugin", + "target": "Parallel 插件" + }, + { + "source": "Parallel search", + "target": "Parallel 搜索" + }, { "source": "CLI reference", "target": "CLI 参考" diff --git a/docs/.i18n/glossary.zh-TW.json b/docs/.i18n/glossary.zh-TW.json index 4515fe7e92fd..1c6ddd9224aa 100644 --- a/docs/.i18n/glossary.zh-TW.json +++ b/docs/.i18n/glossary.zh-TW.json @@ -11,6 +11,10 @@ "source": "ClawHub", "target": "ClawHub" }, + { + "source": "ClickClack", + "target": "ClickClack" + }, { "source": "CLI", "target": "命令列介面" @@ -35,14 +39,38 @@ "source": "Heartbeat", "target": "心跳偵測" }, + { + "source": "Feishu", + "target": "Feishu" + }, + { + "source": "IRC", + "target": "IRC" + }, + { + "source": "LINE", + "target": "LINE" + }, + { + "source": "Mattermost", + "target": "Mattermost" + }, { "source": "Mintlify", "target": "Mintlify" }, + { + "source": "Nextcloud Talk", + "target": "Nextcloud Talk" + }, { "source": "Node", "target": "節點" }, + { + "source": "Nostr", + "target": "Nostr" + }, { "source": "OpenClaw", "target": "OpenClaw" @@ -55,10 +83,30 @@ "source": "Plugin", "target": "外掛" }, + { + "source": "QQ Bot", + "target": "QQ Bot" + }, + { + "source": "QQBot", + "target": "QQ Bot" + }, + { + "source": "QQ bot", + "target": "QQ Bot" + }, + { + "source": "SMS", + "target": "SMS" + }, { "source": "Skills", "target": "Skills" }, + { + "source": "Synology Chat", + "target": "Synology Chat" + }, { "source": "Tailscale", "target": "Tailscale" @@ -67,12 +115,48 @@ "source": "TaskFlow", "target": "TaskFlow" }, + { + "source": "Tlon", + "target": "Tlon" + }, + { + "source": "Twitch", + "target": "Twitch" + }, + { + "source": "Twilio", + "target": "Twilio" + }, { "source": "TUI", "target": "終端介面" }, + { + "source": "WeChat", + "target": "微信" + }, + { + "source": "Weixin", + "target": "微信" + }, { "source": "Webhook", "target": "網路鉤子" + }, + { + "source": "Yuanbao", + "target": "騰訊元寶" + }, + { + "source": "Zalo", + "target": "Zalo" + }, + { + "source": "Zalo Personal", + "target": "Zalo Personal" + }, + { + "source": "Zalo personal", + "target": "Zalo Personal" } ] diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index 1d76cbfbcee8..9497d4887150 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -122,6 +122,33 @@ This fires ~5–6 times per month instead of 0–1 times per month. OpenClaw use +### Command payloads + +Use command payloads for deterministic scripts that should run inside the Gateway scheduler without starting a model-backed isolated agent turn. Command jobs execute on the Gateway host, capture stdout/stderr, record the run in cron history, and reuse the same `announce`, `webhook`, and `none` delivery modes as isolated jobs. + + +Command cron is an operator-admin Gateway automation surface, not an agent +`tools.exec` call. Creating, updating, removing, or manually running cron jobs +requires `operator.admin`; scheduled command runs later execute inside the +Gateway process as that admin-authored automation. Agent exec policy such as +`tools.exec.mode`, approval prompts, and per-agent tool allowlists governs +model-visible exec tools, not command cron payloads. + + +```bash +openclaw cron create "*/15 * * * *" \ + --name "Queue depth probe" \ + --command "scripts/check-queue.sh" \ + --command-cwd "/srv/app" \ + --announce \ + --channel telegram \ + --to "-1001234567890" +``` + +`--command ` stores `argv: ["sh", "-lc", ]`. Use `--command-argv '["node","scripts/report.mjs"]'` when you want exact argv execution without shell parsing. Optional `--command-env KEY=VALUE`, `--command-input`, `--timeout-seconds`, `--no-output-timeout-seconds`, and `--output-max-bytes` fields control the process environment, stdin, and output bounds. + +If stdout is non-empty, that text is the delivered result. If stdout is empty and stderr is non-empty, stderr is delivered. If both streams are present, cron delivers a small `stdout:` / `stderr:` block. A zero exit code records the run as `ok`; non-zero exit, signal, timeout, or no-output timeout records `error` and can trigger failure alerts. A command that prints only `NO_REPLY` uses the normal cron silent-token suppression and posts nothing back to chat. + ### Payload options for isolated jobs @@ -246,6 +273,17 @@ Failure notifications follow a separate destination path: --webhook "https://example.invalid/openclaw/cron" ``` + + ```bash + openclaw cron create "*/15 * * * *" \ + --name "Queue depth probe" \ + --command "scripts/check-queue.sh" \ + --command-cwd "/srv/app" \ + --announce \ + --channel telegram \ + --to "-1001234567890" + ``` + ## Webhooks @@ -432,6 +470,7 @@ Model override note: - `openclaw cron add|edit --model ...` changes the job's selected model. - If the model is allowed, that exact provider/model reaches the isolated agent run. - If it is not allowed or cannot be resolved, cron fails the run with an explicit validation error. +- API `cron.update` payload patches can set `model: null` to clear a stored job model override. - Configured fallback chains still apply because cron `--model` is a job primary, not a session `/model` override. - Payload `fallbacks` replaces configured fallbacks for that job; `fallbacks: []` disables fallback and makes the run strict. - A plain `--model` with no explicit or configured fallback list does not fall through to the agent primary as a silent extra retry target. diff --git a/docs/automation/tasks.md b/docs/automation/tasks.md index bd47d37438fa..030837633be0 100644 --- a/docs/automation/tasks.md +++ b/docs/automation/tasks.md @@ -311,7 +311,9 @@ $OPENCLAW_STATE_DIR/tasks/runs.sqlite The registry loads into memory at gateway start and syncs writes to SQLite for durability across restarts. The Gateway keeps the SQLite write-ahead log bounded by using SQLite's default -autocheckpoint threshold plus periodic and shutdown `TRUNCATE` checkpoints. +autocheckpoint threshold plus periodic `PASSIVE` checkpoints. Shutdown and +explicit maintenance checkpoints still use `TRUNCATE` so normal closes can +reclaim WAL space without making the background sweeper wait on active readers. ### Automatic maintenance diff --git a/docs/channels/broadcast-groups.md b/docs/channels/broadcast-groups.md index eafa3734fa8b..94b2903f181d 100644 --- a/docs/channels/broadcast-groups.md +++ b/docs/channels/broadcast-groups.md @@ -161,17 +161,20 @@ Control how agents process messages: A WhatsApp group or DM message arrives. - - System checks if peer ID is in `broadcast`. + + OpenClaw applies channel allowlists, group activation rules, and configured ACP binding ownership. - + + If no configured ACP binding owns the route, OpenClaw checks whether the peer ID is in `broadcast`. + + - All listed agents process the message. - Each agent has its own session key and isolated context. - Agents process in parallel (default) or sequentially. - - Normal routing applies (first matching binding). + + OpenClaw dispatches the ordinary route or the configured ACP session route selected during routing. @@ -322,7 +325,7 @@ Broadcast groups work alongside existing routing: - `GROUP_B`: agent1 AND agent2 respond (broadcast). -**Precedence:** `broadcast` takes priority over `bindings`. +**Precedence:** `broadcast` takes priority over ordinary route bindings. Configured ACP bindings (`bindings[].type="acp"`) are exclusive: when one matches, OpenClaw dispatches to the configured ACP session instead of fan-out broadcast. ## Troubleshooting @@ -343,9 +346,9 @@ Broadcast groups work alongside existing routing: - **Cause:** Peer ID might be in `bindings` but not `broadcast`. + **Cause:** Peer ID might be in ordinary route bindings but not `broadcast`, or it might match an exclusive configured ACP binding. - **Fix:** Add to broadcast config or remove from bindings. + **Fix:** Add ordinary route-bound peers to broadcast config, or remove/change the configured ACP binding if fan-out broadcast is desired. diff --git a/docs/channels/clickclack.md b/docs/channels/clickclack.md index 59dffab96430..41e5a71f8bc8 100644 --- a/docs/channels/clickclack.md +++ b/docs/channels/clickclack.md @@ -59,6 +59,14 @@ export CLICKCLACK_BOT_TOKEN="ccb_..." openclaw gateway ``` +If `plugins.allow` is a non-empty restrictive list, explicitly selecting +ClickClack in channel setup or running `openclaw plugins enable clickclack` +appends `clickclack` to that list. Onboarding installation uses the same +explicit-selection behavior. These paths do not override `plugins.deny` or a +global `plugins.enabled: false` setting. Direct `openclaw plugins install +clickclack` follows the normal plugin-install policy and also records ClickClack +in an existing allowlist. + ## Multiple bots Each account opens its own ClickClack realtime connection and uses its own bot token. diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index 716a47545dc8..f95d358a9bdd 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -42,7 +42,7 @@ Requires OpenClaw 2026.5.29 or above. Run `openclaw --version` to check. Upgrade Configure `dmPolicy` to control who can DM the bot: - `"pairing"` - unknown users receive a pairing code; approve via CLI -- `"allowlist"` - only users listed in `allowFrom` can chat (default: bot owner only) +- `"allowlist"` - only users listed in `allowFrom` can chat - `"open"` - allow public DMs only when `allowFrom` includes `"*"`; with restrictive entries, only matching users can chat - `"disabled"` - disable all DMs @@ -416,7 +416,9 @@ Enable `dynamicAgentCreation` to automatically create **isolated agent instances This is essential for public bots where you want each user to have their own private AI assistant experience. -**Account limitation**: `dynamicAgentCreation` currently works with the **default Feishu account only**. Named/multi-account setups are not yet fully supported — dynamic bindings are created without `accountId`, so messages to named accounts may still route to `agent:main`. Track progress in [Issue #42837](https://github.com/openclaw/openclaw/issues/42837). +Dynamic bindings include the normalized Feishu `accountId`, so default and named accounts route each sender to the correct dynamic agent. + +If a named account created an unscoped dynamic agent on an older release, that legacy agent still counts toward `maxAgents`. Confirm that it is not used by the default account before removing it, or temporarily increase `maxAgents`; OpenClaw cannot safely infer which account owns ambiguous legacy state. ### Quick setup @@ -447,7 +449,7 @@ This is essential for public bots where you want each user to have their own pri When a new user sends their first DM: -1. The channel generates a unique `agentId` = `feishu-{user_open_id}` +1. The channel generates a unique `agentId`: `feishu-{user_open_id}` for the default account, or a bounded account-prefixed identity digest for a named account 2. Creates a new workspace at `workspaceTemplate` path 3. Registers the agent and creates a binding for this user 4. The workspace helper ensures bootstrap files (`AGENTS.md`, `SOUL.md`, `USER.md`, etc.) on first access @@ -464,22 +466,23 @@ When a new user sends their first DM: Template variables: -- `{agentId}` - the generated agent ID (e.g., `feishu-ou_xxxxxx`) +- `{agentId}` - the generated agent ID (e.g., `feishu-ou_xxxxxx` or `feishu-support-`) - `{userId}` - the sender's Feishu open_id (e.g., `ou_xxxxxx`) ### Session scope `session.dmScope` controls how direct messages are mapped to agent sessions. This is a **global setting** that affects all channels. -| Value | Behavior | Best for | -| -------------------- | --------------------------------------------------------- | ------------------------------------------------------------------ | -| `"main"` | Each user's DM maps to their agent's main session | Single-user bots where you want `USER.md` / `SOUL.md` to auto-load | -| `"per-channel-peer"` | Each (channel + user) combination gets a separate session | Public multi-user bots needing stronger isolation | +| Value | Behavior | Best for | +| ---------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------ | +| `"main"` | Each user's DM maps to their agent's main session | Single-user bots where you want `USER.md` / `SOUL.md` to auto-load | +| `"per-channel-peer"` | Each (channel + user) combination gets a separate session | Public multi-user bots needing stronger isolation | +| `"per-account-channel-peer"` | Each (account + channel + user) combination gets a separate session | Multi-account bots needing account-level session isolation | **Tradeoff**: Using `"main"` enables automatic bootstrap file loading (`USER.md`, `SOUL.md`, `MEMORY.md`), but means all DMs across all channels share the same session key pattern. For public multi-user bots where isolation matters more than bootstrap auto-loading, consider `"per-channel-peer"` and manage bootstrap files manually. -`"per-account-channel-peer"` is not recommended with `dynamicAgentCreation` because dynamic bindings are created without `accountId`. Use it only with manual bindings. +Use `"per-account-channel-peer"` when named Feishu accounts should keep separate sessions for the same sender. Dynamic bindings preserve the account scope. ```json5 @@ -567,8 +570,8 @@ Full configuration: [Gateway configuration](/gateway/configuration) | `channels.feishu.accounts..appSecret` | App Secret | - | | `channels.feishu.accounts..domain` | Per-account domain override | `feishu` | | `channels.feishu.accounts..tts` | Per-account TTS override | `messages.tts` | -| `channels.feishu.dmPolicy` | DM policy | `allowlist` | -| `channels.feishu.allowFrom` | DM allowlist (open_id list) | [BotOwnerId] | +| `channels.feishu.dmPolicy` | DM policy | `pairing` | +| `channels.feishu.allowFrom` | DM allowlist (open_id list) | - | | `channels.feishu.groupPolicy` | Group policy | `allowlist` | | `channels.feishu.groupAllowFrom` | Group allowlist | - | | `channels.feishu.requireMention` | Require @mention in groups | `true` | diff --git a/docs/channels/groups.md b/docs/channels/groups.md index e9bdcf8964d9..1989db1f22d6 100644 --- a/docs/channels/groups.md +++ b/docs/channels/groups.md @@ -1,5 +1,5 @@ --- -summary: "Group chat behavior across surfaces (Discord/iMessage/Matrix/Microsoft Teams/Signal/Slack/Telegram/WhatsApp/Zalo)" +summary: "Group chat behavior across surfaces (Discord/iMessage/Matrix/Microsoft Teams/QQBot/Signal/Slack/Telegram/WhatsApp/Zalo)" read_when: - Changing group chat behavior or mention gating - Scoping mentionPatterns to specific group conversations @@ -7,7 +7,7 @@ title: "Groups" sidebarTitle: "Groups" --- -OpenClaw treats group chats consistently across surfaces: Discord, iMessage, Matrix, Microsoft Teams, Signal, Slack, Telegram, WhatsApp, Zalo. +OpenClaw treats group chats consistently across surfaces: Discord, iMessage, Matrix, Microsoft Teams, QQBot, Signal, Slack, Telegram, WhatsApp, Zalo. For always-on rooms that should provide quiet context unless the agent explicitly sends a visible message, see [Ambient room events](/channels/ambient-room-events). @@ -586,7 +586,7 @@ Group inbound payloads set: - `WasMentioned` (mention gating result) - Telegram forum topics also include `MessageThreadId` and `IsForum`. -The agent system prompt includes a group intro on the first turn of a new group session. It reminds the model to respond like a human, avoid Markdown tables, minimize empty lines and follow normal chat spacing, and avoid typing literal `\n` sequences. Channel-sourced group names and participant labels are rendered as fenced untrusted metadata, not inline system instructions. +The agent system prompt includes a group intro on the first turn of a new group session. It reminds the model to respond like a human, minimize empty lines and follow normal chat spacing, and avoid typing literal `\n` sequences. Non-Telegram groups also discourage Markdown tables; Telegram rich-text guidance comes from the Telegram channel prompt. Channel-sourced group names and participant labels are rendered as fenced untrusted metadata, not inline system instructions. ## iMessage specifics diff --git a/docs/channels/imessage-from-bluebubbles.md b/docs/channels/imessage-from-bluebubbles.md index b9aa8f43a357..c8888c80643c 100644 --- a/docs/channels/imessage-from-bluebubbles.md +++ b/docs/channels/imessage-from-bluebubbles.md @@ -221,22 +221,22 @@ If the gateway logs `imessage: dropping group message from chat_id=` or the ## Action parity at a glance -| Action | legacy BlueBubbles | bundled iMessage | -| ---------------------------------------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| Send text / SMS fallback | ✅ | ✅ | -| Send media (photo, video, file, voice) | ✅ | ✅ | -| Threaded reply (`reply_to_guid`) | ✅ | ✅ (closes [#51892](https://github.com/openclaw/openclaw/issues/51892)) | -| Tapback (`react`) | ✅ | ✅ | -| Edit / unsend (macOS 13+ recipients) | ✅ | ✅ | -| Send with screen effect | ✅ | ✅ (closes part of [#9394](https://github.com/openclaw/openclaw/issues/9394)) | -| Rich text bold / italic / underline / strikethrough | ✅ | ✅ (typed-run formatting via attributedBody) | -| Rename group / set group icon | ✅ | ✅ | -| Add / remove participant, leave group | ✅ | ✅ | -| Read receipts and typing indicator | ✅ | ✅ (gated on private API probe) | -| Same-sender DM coalescing | ✅ | ✅ (DM-only; opt-in via `channels.imessage.coalesceSameSenderDms`) | -| Catchup of inbound messages received while gateway is down | ✅ (webhook replay + history fetch) | ✅ (opt-in via `channels.imessage.catchup.enabled`; closes [#78649](https://github.com/openclaw/openclaw/issues/78649)) | +| Action | legacy BlueBubbles | bundled iMessage | +| --------------------------------------------------- | ----------------------------------- | ----------------------------------------------------------------------------- | +| Send text / SMS fallback | ✅ | ✅ | +| Send media (photo, video, file, voice) | ✅ | ✅ | +| Threaded reply (`reply_to_guid`) | ✅ | ✅ (closes [#51892](https://github.com/openclaw/openclaw/issues/51892)) | +| Tapback (`react`) | ✅ | ✅ | +| Edit / unsend (macOS 13+ recipients) | ✅ | ✅ | +| Send with screen effect | ✅ | ✅ (closes part of [#9394](https://github.com/openclaw/openclaw/issues/9394)) | +| Rich text bold / italic / underline / strikethrough | ✅ | ✅ (typed-run formatting via attributedBody) | +| Rename group / set group icon | ✅ | ✅ | +| Add / remove participant, leave group | ✅ | ✅ | +| Read receipts and typing indicator | ✅ | ✅ (gated on private API probe) | +| Same-sender DM coalescing | ✅ | ✅ (DM-only; opt-in via `channels.imessage.coalesceSameSenderDms`) | +| Inbound recovery after a restart | ✅ (webhook replay + history fetch) | ✅ (automatic: replay missed via since_rowid + dedupe; wider window on local) | -iMessage catchup is now available as an opt-in feature on the bundled plugin. On gateway startup, if `channels.imessage.catchup.enabled` is `true`, the gateway runs one `chats.list` + per-chat `messages.history` pass against the same JSON-RPC client used by `imsg watch`, replays each missed inbound row through the live dispatch path (allowlists, group policy, debouncer, echo cache), and persists a per-account cursor so subsequent startups pick up where they left off. See [Catching up after gateway downtime](/channels/imessage#catching-up-after-gateway-downtime) for tuning. +iMessage recovers messages missed while the gateway was down: on startup it replays from the last dispatched rowid via `imsg watch.subscribe` `since_rowid` and dedupes by GUID, while a stale-backlog age fence suppresses the Push-flush "backlog bomb". This runs over the `imsg` RPC connection, so it works for remote SSH `cliPath` setups too; local setups get a wider recovery window because they can read `chat.db`. See [Inbound recovery after a bridge or gateway restart](/channels/imessage#inbound-recovery-after-a-bridge-or-gateway-restart). ## Pairing, sessions, and ACP bindings diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index 18baa135e3f1..8a289898d339 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -9,7 +9,7 @@ title: "iMessage" For OpenClaw iMessage deployments, use `imsg` on a signed-in macOS Messages host. If your Gateway runs on Linux or Windows, point `channels.imessage.cliPath` at an SSH wrapper that runs `imsg` on the Mac. -**Gateway-downtime catchup is opt-in.** When enabled (`channels.imessage.catchup.enabled: true`), the gateway replays inbound messages that landed in `chat.db` while it was offline (crash, restart, Mac sleep) on next startup. Disabled by default — see [Catching up after gateway downtime](#catching-up-after-gateway-downtime). Closes [openclaw#78649](https://github.com/openclaw/openclaw/issues/78649). +**Inbound recovery is automatic.** After a bridge or gateway restart, iMessage replays the messages missed while it was down and suppresses the stale "backlog bomb" Apple can flush after a Push recovery, deduping so nothing is dispatched twice. There is no config to enable — see [Inbound recovery after a bridge or gateway restart](#inbound-recovery-after-a-bridge-or-gateway-restart). @@ -205,11 +205,24 @@ Treat this as a deliberate operational choice, not a default. If your threat mod The `imsg status --json` output reports `bridge_version`, `rpc_methods`, and per-method `selectors` so you can see what the current build supports before you start. -2. **Disable System Integrity Protection.** This is macOS-version-specific because the underlying Apple requirement depends on the OS and hardware: - - **macOS 10.13–10.15 (Sierra–Catalina):** disable Library Validation via Terminal, reboot to Recovery Mode, run `csrutil disable`, restart. +2. **Disable System Integrity Protection, and (on modern macOS) Library Validation.** Injecting a non-Apple helper dylib into the Apple-signed `Messages.app` needs SIP off **and** library validation relaxed. The Recovery-mode SIP step is macOS-version-specific: + - **macOS 10.13-10.15 (Sierra-Catalina):** disable Library Validation via Terminal, reboot to Recovery Mode, run `csrutil disable`, restart. - **macOS 11+ (Big Sur and later), Intel:** Recovery Mode (or Internet Recovery), `csrutil disable`, restart. - - **macOS 11+, Apple Silicon:** power-button startup sequence to enter Recovery; on recent macOS versions hold the **Left Shift** key when you click Continue, then `csrutil disable`. Virtual-machine setups follow a separate flow — take a VM snapshot first. - - **macOS 26 / Tahoe:** library-validation policies and `imagent` private-entitlement checks have tightened further; `imsg` may need an updated build to keep up. If `imsg launch` injection or specific `selectors` start returning false after a macOS major upgrade, check `imsg`'s release notes before assuming the SIP step succeeded. + - **macOS 11+, Apple Silicon:** power-button startup sequence to enter Recovery; on recent macOS versions hold the **Left Shift** key when you click Continue, then `csrutil disable`. Virtual-machine setups follow a separate flow, so take a VM snapshot first. + + **On macOS 11 and later, `csrutil disable` alone is usually not enough.** Apple still enforces library validation against `Messages.app` as a platform binary, so an adhoc-signed helper is rejected (`Library Validation failed: ... platform binary, but mapped file is not`) even with SIP off. After disabling SIP, also disable library validation and reboot: + + ```bash + sudo defaults write /Library/Preferences/com.apple.security.libraryvalidation.plist DisableLibraryValidation -bool true + ``` + + **macOS 26 (Tahoe), verified on 26.5.1:** SIP off **plus** the `DisableLibraryValidation` command above is sufficient to inject the helper across 26.0 through 26.5.x. **No boot-args are required.** The plist is the decisive factor and the most common missing step when injection fails on Tahoe: + - **With the plist:** `imsg launch` injects and `imsg status` reports `advanced_features: true`. + - **Without the plist (even with SIP off):** `imsg launch` fails with `Failed to launch: Timeout waiting for Messages.app to initialize`. AMFI rejects the adhoc helper at load, so the bridge never becomes ready and the launch times out. That timeout is the symptom most people hit on Tahoe, and the fix is the plist above, not anything more drastic. + + This was confirmed with a controlled before/after on macOS 26.5.1 (Apple Silicon): with the plist, the dylib maps into `Messages.app` and the bridge comes up; remove the plist and reboot, and `imsg launch` produces the timeout failure above with the dylib not mapped. + + If `imsg launch` injection or specific `selectors` start returning false after a macOS upgrade, this gate is the usual cause. Check your SIP and library-validation state before assuming the SIP step itself failed. If those settings are correct and the bridge still cannot inject, collect `imsg status --json` plus the `imsg launch` output and report it to the `imsg` project instead of weakening additional system-wide security controls. Follow Apple's Recovery-mode flow for your Mac to disable SIP before running `imsg launch`. @@ -641,14 +654,14 @@ When a user types a command and a URL together — e.g. `Dump https://example.co The two rows arrive at OpenClaw ~0.8-2.0 s apart on most setups. Without coalescing, the agent receives the command alone on turn 1, replies (often "send me the URL"), and only sees the URL on turn 2 — at which point the command context is already lost. This is Apple's send pipeline, not anything OpenClaw or `imsg` introduces. -`channels.imessage.coalesceSameSenderDms` opts a DM into merging consecutive same-sender rows into a single agent turn. Group chats continue to dispatch per-message so multi-user turn structure is preserved. +`channels.imessage.coalesceSameSenderDms` opts a DM into buffering consecutive same-sender rows. When `imsg` exposes the structural URL-preview marker `balloon_bundle_id: "com.apple.messages.URLBalloonProvider"` on one of the source rows, OpenClaw merges only that real split-send and keeps any other buffered rows as separate turns. On older `imsg` builds that emit no balloon metadata at all, OpenClaw cannot tell a split-send from separate sends, so it falls back to merging the bucket. That preserves the pre-metadata behavior rather than regressing `Dump ` split-sends into two turns. Group chats continue to dispatch per-message so multi-user turn structure is preserved. Enable when: - You ship skills that expect `command + payload` in one message (dump, paste, save, queue, etc.). - - Your users paste URLs, images, or long content alongside commands. + - Your users paste URLs alongside commands. - You can accept the added DM turn latency (see below). Leave disabled when: @@ -689,7 +702,8 @@ The two rows arrive at OpenClaw ~0.8-2.0 s apart on most setups. Without coalesc - - **Added latency for DM messages.** With the flag on, every DM (including standalone control commands and single-text follow-ups) waits up to the debounce window before dispatching, in case a payload row is coming. Group-chat messages keep instant dispatch. + - **Precise merging needs current `imsg` payload metadata.** When the URL row includes `balloon_bundle_id`, only that real split-send merges and other buffered rows stay separate. On older `imsg` builds that expose no balloon metadata, OpenClaw falls back to merging the buffered bucket so `Dump ` split-sends are not regressed into two turns (interim back-compat, removed once `imsg` coalesces split-sends upstream). + - **Added latency for DM messages.** With the flag on, every DM (including standalone control commands and single-text follow-ups) waits up to the debounce window before dispatching, in case a URL-preview row is coming. Group-chat messages keep instant dispatch. - **Merged output is bounded.** Merged text caps at 4000 chars with an explicit `…[truncated]` marker; attachments cap at 20; source entries cap at 10 (first-plus-latest retained beyond that). Every source GUID is tracked in `coalescedMessageGuids` for downstream telemetry. - **DM-only.** Group chats fall through to per-message dispatch so the bot stays responsive when multiple people are typing. - **Opt-in, per-channel.** Other channels (Telegram, WhatsApp, Slack, …) are unaffected. Legacy BlueBubbles configs that set `channels.bluebubbles.coalesceSameSenderDms` should migrate that value to `channels.imessage.coalesceSameSenderDms`. @@ -699,77 +713,39 @@ The two rows arrive at OpenClaw ~0.8-2.0 s apart on most setups. Without coalesc ### Scenarios and what the agent sees -| User composes | `chat.db` produces | Flag off (default) | Flag on + 2500 ms window | -| ------------------------------------------------------------------ | --------------------- | --------------------------------------- | ----------------------------------------------------------------------- | -| `Dump https://example.com` (one send) | 2 rows ~1 s apart | Two agent turns: "Dump" alone, then URL | One turn: merged text `Dump https://example.com` | -| `Save this 📎image.jpg caption` (attachment + text) | 2 rows | Two turns (attachment dropped on merge) | One turn: text + image preserved | -| `/status` (standalone command) | 1 row | Instant dispatch | **Wait up to window, then dispatch** | -| URL pasted alone | 1 row | Instant dispatch | Instant dispatch (only one entry in bucket) | -| Text + URL sent as two deliberate separate messages, minutes apart | 2 rows outside window | Two turns | Two turns (window expires between them) | -| Rapid flood (>10 small DMs inside window) | N rows | N turns | One turn, bounded output (first + latest, text/attachment caps applied) | -| Two people typing in a group chat | N rows from M senders | M+ turns (one per sender bucket) | M+ turns — group chats are not coalesced | +The "Flag on" column shows behavior on an `imsg` build that emits `balloon_bundle_id`. On older `imsg` builds that emit no balloon metadata at all, the rows below marked "Two turns" / "N turns" instead fall back to a legacy merge (one turn): OpenClaw cannot structurally tell a split-send from separate sends, so it preserves the pre-metadata merge. Precise separation activates once the build emits balloon metadata. -## Catching up after gateway downtime +| User composes | `chat.db` produces | Flag off (default) | Flag on + window (imsg emits balloon metadata) | +| ------------------------------------------------------------------ | ----------------------------------- | --------------------------------------- | ------------------------------------------------ | +| `Dump https://example.com` (one send) | 2 rows ~1 s apart | Two agent turns: "Dump" alone, then URL | One turn: merged text `Dump https://example.com` | +| `Save this 📎image.jpg caption` (attachment + text) | 2 rows without URL balloon metadata | Two turns | Two turns (legacy merge on metadata-less builds) | +| `/status` (standalone command) | 1 row | Instant dispatch | **Wait up to window, then dispatch** | +| URL pasted alone | 1 row | Instant dispatch | Wait up to window, then dispatch | +| Text + URL sent as two deliberate separate messages, minutes apart | 2 rows outside window | Two turns | Two turns (window expires between them) | +| Rapid flood (>10 small DMs inside window) | N rows without URL balloon metadata | N turns | N turns (legacy merge on metadata-less builds) | +| Two people typing in a group chat | N rows from M senders | M+ turns (one per sender bucket) | M+ turns — group chats are not coalesced | -When the gateway is offline (crash, restart, Mac sleep, machine off), `imsg watch` resumes from the current `chat.db` state once the gateway comes back up — anything that arrived during the gap is, by default, never seen. Catchup replays those messages on the next startup so the agent does not silently miss inbound traffic. +## Inbound recovery after a bridge or gateway restart -Catchup is **disabled by default**. Enable it per channel: +iMessage recovers messages missed while the gateway was down, and at the same time suppresses the stale "backlog bomb" Apple can flush after a Push recovery. The default behavior is always on, built on the inbound dedupe. -```ts -channels: { - imessage: { - catchup: { - enabled: true, // master switch (default: false) - maxAgeMinutes: 120, // skip rows older than now - 2h (default: 120, clamp 1..720) - perRunLimit: 50, // max rows replayed per startup (default: 50, clamp 1..500) - firstRunLookbackMinutes: 30, // first run with no cursor: look back 30 min (default: 30) - maxFailureRetries: 10, // give up on a wedged guid after 10 dispatch failures (default: 10) - }, - }, -} -``` +- **Replay dedupe.** Every dispatched inbound message is recorded by its Apple GUID in persistent plugin state (`imessage.inbound-dedupe`), claimed at ingestion and committed after handling (released on a transient failure so it can retry). Anything already handled is dropped instead of dispatched twice. This is what lets recovery replay aggressively without per-message bookkeeping. +- **Downtime recovery.** On startup the monitor remembers the last dispatched `chat.db` rowid (a persisted per-account cursor) and passes it to `imsg watch.subscribe` as `since_rowid`, so imsg replays the rows that landed while the gateway was down, then tails live. Replay is bounded to the most recent rows and to messages up to ~2 hours old, and the dedupe drops anything already handled. +- **Stale-backlog age fence.** Rows above the startup boundary are genuinely live; one whose send date is more than ~15 minutes older than its arrival is the Push-flush backlog and is suppressed. Replayed rows (at or below the boundary) use the wider recovery window instead, so a recently-missed message is delivered while ancient history is not. -### How it runs +Recovery works over both local and remote `cliPath` setups, because `since_rowid` replay runs over the same `imsg` RPC connection. The difference is the window: when the gateway can read `chat.db` (local), it anchors the startup rowid boundary, caps the replay span, and delivers missed messages up to a couple of hours old. Over a remote SSH `cliPath` it cannot read the database, so the replay is uncapped and every row uses the live age fence — it still recovers recently-missed messages and still suppresses old backlog, just with the narrower live window. Run the gateway on the Messages Mac for the wider recovery window. -One pass per `monitorIMessageProvider` startup, sequenced as `imsg launch` ready → `watch.subscribe` → `performIMessageCatchup` → live dispatch loop. Catchup itself uses `chats.list` + per-chat `messages.history` against the same JSON-RPC client used by `imsg watch`. Anything that arrives during the catchup pass flows through live dispatch normally; the existing inbound-dedupe cache absorbs any overlap with replayed rows. +### Operator-visible signal -Each replayed row is fed through the live dispatch path (`evaluateIMessageInbound` + `dispatchInboundMessage`), so allowlists, group policy, debouncer, echo cache, and read receipts behave identically on replayed and live messages. - -### Cursor and retry semantics - -Catchup keeps a per-account cursor in SQLite plugin state: - -```json -{ - "lastSeenMs": 1717900800000, - "lastSeenRowid": 482910, - "updatedAt": 1717900801234, - "failureRetries": { "": 1 } -} -``` - -- The cursor advances on each successful dispatch and is held when a row's dispatch throws — the next startup retries the same row from the held cursor. -- After the startup catchup query succeeds, later live-handled rows also advance the same cursor so a gateway restart does not replay messages that were already handled live. Live cursor writes do not jump past catchup failures that are still below `maxFailureRetries`. -- After `maxFailureRetries` consecutive throws against the same `guid`, catchup logs a `warn` and force-advances the cursor past the wedged message so subsequent startups can make progress. -- Already-given-up guids are skipped on sight (no dispatch attempt) on later runs and counted under `skippedGivenUp` in the run summary. -- `openclaw doctor --fix` imports legacy `/imessage/catchup/*.json` cursor files into SQLite plugin state and archives the old files. - -### Operator-visible signals +Suppressed backlog is logged at the default level, never silently dropped (the `recovery` flag shows which window applied): ``` -imessage catchup: replayed=N skippedFromMe=… skippedGivenUp=… failed=… givenUp=… fetchedCount=… -imessage catchup: giving up on guid= after failures; advancing cursor past it -imessage catchup: fetched rows across chats, capped to perRunLimit= +imessage: suppressed stale inbound backlog account= sent= recovery= ( suppressed since start) ``` -A `WARN ... capped to perRunLimit` line means a single startup did not drain the full backlog. Raise `perRunLimit` (max 500) if your gaps regularly exceed the default 50-row pass. +### Migration -### When to leave it off - -- Gateway runs continuously with watchdog auto-restart and gaps are always < a few seconds — the default of off is fine. -- DM volume is low and missed messages would not change agent behavior — the `firstRunLookbackMinutes` initial window can dispatch surprising old context on first enable. - -When you turn catchup on, the first startup with no cursor only looks back `firstRunLookbackMinutes` (30 min default), not the full `maxAgeMinutes` window — this avoids replaying a long history of pre-enable messages. +`channels.imessage.catchup.*` is deprecated — downtime recovery is now automatic and needs no config for new setups. Existing configs with `catchup.enabled: true` remain honored as a compatibility profile for the recovery replay window. Disabled catchup blocks (`enabled: false` or no `enabled: true`) are retired; `openclaw doctor --fix` removes those. ## Troubleshooting @@ -787,6 +763,31 @@ When you turn catchup on, the first startup with no cursor only looks back `firs + + First prove whether the message reached the local Mac. If `chat.db` does not change, OpenClaw cannot receive the message even when `imsg status --json` reports a healthy bridge. + +```bash +imsg chats --limit 10 --json +imsg watch --chat-id --json +sqlite3 ~/Library/Messages/chat.db \ + "select datetime(max(date)/1000000000 + 978307200, 'unixepoch', 'localtime'), max(ROWID) from message;" +``` + + If phone-sent messages create no new rows, repair the macOS Messages and Apple Push layer before changing OpenClaw config. A one-shot service refresh is often enough: + +```bash +launchctl kickstart -k system/com.apple.apsd +launchctl kickstart -k gui/$(id -u)/com.apple.CommCenter +launchctl kickstart -k gui/$(id -u)/com.apple.identityservicesd +launchctl kickstart -k gui/$(id -u)/com.apple.imagent +imsg launch +openclaw gateway restart +``` + + Send a fresh iMessage from the phone and confirm a new `chat.db` row or `imsg watch` event before debugging OpenClaw sessions. Do not run this as a periodic bridge-relaunch loop; repeated `imsg launch` plus gateway restarts during active work can interrupt deliveries and strand in-flight channel runs. + + + The default `cliPath: "imsg"` must run on the Mac signed into Messages. On Linux or Windows, set `channels.imessage.cliPath` to a wrapper script that SSHes to that Mac and runs `imsg "$@"`. diff --git a/docs/channels/matrix-migration.md b/docs/channels/matrix-migration.md index 0ca607c1f164..9170a259f891 100644 --- a/docs/channels/matrix-migration.md +++ b/docs/channels/matrix-migration.md @@ -17,6 +17,11 @@ For most users, the upgrade is in place: - runtime state stays under `~/.openclaw/matrix/` You do not need to rename config keys or reinstall the plugin under a new name. +The root `openclaw` package no longer bundles Matrix runtime code or Matrix SDK +dependencies. If `openclaw channels status` shows Matrix is configured but the +plugin is missing after an update, run `openclaw doctor --fix` or +`openclaw plugins install @openclaw/matrix`; do not install Matrix SDK packages +into the root OpenClaw package. ## What the migration does automatically diff --git a/docs/channels/qqbot.md b/docs/channels/qqbot.md index 7ee286ab21c2..11d556ae8d1e 100644 --- a/docs/channels/qqbot.md +++ b/docs/channels/qqbot.md @@ -152,7 +152,7 @@ to a group, then mention it or configure the group to run without a mention. "*": { requireMention: true, historyLimit: 50, - toolPolicy: "restricted", + tools: { deny: ["exec", "read", "write"] }, }, GROUP_OPENID: { name: "Release room", @@ -174,10 +174,13 @@ settings include: - `requireMention`: require an @mention before the bot replies. Default: `true`. - `ignoreOtherMentions`: drop messages that mention someone else but not the bot. - `historyLimit`: keep recent non-mention group messages as context for the next mentioned turn. Set `0` to disable. -- `toolPolicy`: `full`, `restricted`, or `none` for group-scoped tools. +- `tools`: allow/deny tools for the whole group. +- `toolsBySender`: per-sender group tool overrides; see [Groups](/channels/groups#groupchannel-tool-restrictions-optional). - `name`: friendly label used in logs and group context. - `prompt`: per-group behavior prompt appended to the agent context. +Old QQBot `toolPolicy` entries are retired. Run `openclaw doctor --fix` to migrate them to `tools`. + Activation modes are `mention` and `always`. `requireMention: true` maps to `mention`; `requireMention: false` maps to `always`. A session-level activation override, when present, wins over config. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 425a4fb6706d..69f4cf492687 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -311,7 +311,6 @@ curl "https://api.telegram.org/bot/getUpdates" - direct chats: preview message + `editMessageText` - groups/topics: preview message + `editMessageText` - - direct-chat tool progress: optional native `sendMessageDraft` status preview when enabled and supported Requirement: @@ -320,29 +319,10 @@ curl "https://api.telegram.org/bot/getUpdates" - `streaming.preview.toolProgress` controls whether tool/progress updates reuse the same edited preview message (default: `true` when preview streaming is active) - `streaming.preview.commandText` controls command/exec detail inside those tool-progress lines: `raw` (default, preserves released behavior) or `status` (tool label only) - `streaming.progress.commentary` (default: `false`) opts into assistant commentary/preamble text in the temporary progress draft - - legacy `channels.telegram.streamMode` and boolean `streaming` values are detected; run `openclaw doctor --fix` to migrate them to `channels.telegram.streaming.mode` + - legacy `channels.telegram.streamMode`, boolean `streaming` values, and retired native draft preview keys are detected; run `openclaw doctor --fix` to migrate them to current streaming config Tool-progress preview updates are the short status lines shown while tools run, for example command execution, file reads, planning updates, patch summaries, or Codex preamble/commentary text in Codex app-server mode. Telegram keeps these enabled by default to match released OpenClaw behavior from `v2026.4.22` and later. - Direct chats can use native Telegram drafts for these tool-progress lines without persisting tool chatter into chat history. Native drafts stop before answer text starts; final answers stay on the normal persistent delivery path. This lane is off by default and should be gated to trusted DM IDs first: - - ```json - { - "channels": { - "telegram": { - "streaming": { - "mode": "partial", - "preview": { - "toolProgress": true, - "nativeToolProgress": true, - "nativeToolProgressAllowFrom": ["123456789"] - } - } - } - } - } - ``` - To keep the edited preview for answer text but hide tool-progress lines, set: ```json @@ -420,14 +400,16 @@ curl "https://api.telegram.org/bot/getUpdates" - - Outbound text uses Telegram `parse_mode: "HTML"`. + + Outbound text uses Telegram rich messages. - - Markdown-ish text is rendered to Telegram-safe HTML. - - Supported Telegram HTML tags are preserved; unsupported HTML is escaped. - - If Telegram rejects parsed HTML, OpenClaw retries as plain text. + - Markdown text is sent as rich Markdown without converting it to HTML. + - Explicit HTML payloads are sent as rich HTML. + - Media captions still use Telegram HTML captions because rich messages do not replace captions. - Link previews are enabled by default and can be disabled with `channels.telegram.linkPreview: false`. + Long rich text is split automatically across Telegram's rich text and rich block limits. Tables over Telegram's column limit are sent as code blocks. + + Link previews are enabled by default. `channels.telegram.linkPreview: false` skips automatic entity detection for rich text. diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index b16d9bade378..b73e78758f71 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -164,7 +164,7 @@ handoff path over manual terminal capture. - Gateway owns the WhatsApp socket and reconnect loop. - The reconnect watchdog uses WhatsApp Web transport activity, not only inbound app-message volume, so a quiet linked-device session is not restarted solely because nobody has sent a message recently. A longer application-silence cap still forces a reconnect if transport frames keep arriving but no application messages are handled for the watchdog window; after a transient reconnect for a recently active session, that application-silence check uses the normal message timeout for the first recovery window. -- Baileys socket timings are explicit under `web.whatsapp.*`: `keepAliveIntervalMs` controls WhatsApp Web application pings, `connectTimeoutMs` controls the opening handshake timeout, and `defaultQueryTimeoutMs` controls Baileys query timeouts. +- Baileys socket timings are explicit under `web.whatsapp.*`: `keepAliveIntervalMs` controls WhatsApp Web application pings, `connectTimeoutMs` controls the opening handshake timeout, and `defaultQueryTimeoutMs` controls Baileys query waits plus OpenClaw's local outbound send/presence operation bound. - Outbound sends require an active WhatsApp listener for the target account. - Group sends attach native mention metadata for `@+` and `@` tokens in text and media captions when the token matches current WhatsApp participant metadata, including LID-backed groups. - Status and broadcast chats are ignored (`@status`, `@broadcast`). @@ -319,6 +319,40 @@ content and identifiers. +## Configured ACP bindings + +WhatsApp supports persistent ACP bindings with top-level `bindings[]` entries: + +```json5 +{ + bindings: [ + { + type: "acp", + agentId: "codex", + match: { + channel: "whatsapp", + accountId: "work", + peer: { kind: "direct", id: "+15555550123" }, + }, + }, + { + type: "acp", + agentId: "codex", + match: { + channel: "whatsapp", + accountId: "work", + peer: { kind: "group", id: "120363424282127706@g.us" }, + }, + }, + ], +} +``` + +- Direct chats match E.164 numbers such as `+15555550123`. +- Groups match WhatsApp group JIDs such as `120363424282127706@g.us`. +- Group allowlists, sender policy, and mention or activation gating run before OpenClaw ensures the configured ACP session exists. +- A matched configured ACP binding owns the route. WhatsApp broadcast groups do not fan out that turn to ordinary WhatsApp sessions. + ## Personal-number and self-chat behavior When the linked self number is also present in `allowFrom`, WhatsApp self-chat safeguards activate: diff --git a/docs/ci.md b/docs/ci.md index 151ce71a0628..718edcc1e000 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -116,7 +116,7 @@ Manual CI dispatches run the same job graph as normal CI but force every non-And Manual runs use a unique concurrency group so a release-candidate full suite is not cancelled by another push or PR run on the same ref. The optional `target_ref` input lets a trusted caller run that graph against a branch, tag, or full commit SHA while using the workflow file from the selected dispatch ref. ```bash -gh workflow run ci.yml --ref release/YYYY.M.D +gh workflow run ci.yml --ref release/YYYY.M.PATCH gh workflow run ci.yml --ref main -f target_ref= -f include_android=true gh workflow run full-release-validation.yml --ref main -f ref= ``` @@ -183,7 +183,7 @@ The workflow installs OCM from a pinned release and Kova from `openclaw/Kova` at - `mock-deep-profile`: CPU/heap/trace profiling for startup, gateway, and agent-turn hotspots. - `live-openai-candidate`: a real OpenAI `openai/gpt-5.5` agent turn, skipped when `OPENAI_API_KEY` is unavailable. -The mock-provider lane also runs OpenClaw-native source probes after the Kova pass: gateway boot timing and memory across default, hook, and 50-plugin startup cases; bundled plugin import RSS, repeated mock-OpenAI `channel-chat-baseline` hello loops, and CLI startup commands against the booted gateway. When the previous published mock-provider source report is available for the tested ref, the source summary compares current RSS and heap values against that baseline and marks large RSS increases as `watch`. The source probe Markdown summary lives at `source/index.md` in the report bundle, with raw JSON beside it. +The mock-provider lane also runs OpenClaw-native source probes after the Kova pass: gateway boot timing and memory across default, hook, and 50-plugin startup cases; bundled plugin import RSS, repeated mock-OpenAI `channel-chat-baseline` hello loops, CLI startup commands against the booted gateway, and the SQLite state smoke performance probe. When the previous published mock-provider source report is available for the tested ref, the source summary compares current RSS and heap values against that baseline and marks large RSS increases as `watch`. The source probe Markdown summary lives at `source/index.md` in the report bundle, with raw JSON beside it. Every lane uploads GitHub artifacts. When `CLAWGRIT_REPORTS_TOKEN` is configured, the workflow also commits `report.json`, `report.md`, bundles, `index.md`, and source-probe artifacts into `openclaw/clawgrit-reports` under `openclaw-performance//-//`. The current tested-ref pointer is written as `openclaw-performance//latest-.json`. @@ -196,17 +196,23 @@ stage matrix, exact workflow job names, profile differences, artifacts, and focused rerun handles. `OpenClaw Release Publish` is the manual mutating release workflow. Dispatch it -from `release/YYYY.M.D` or `main` after the release tag exists and after the +from `release/YYYY.M.PATCH` or `main` after the release tag exists and after the OpenClaw npm preflight has succeeded. It verifies `pnpm plugins:sync:check`, dispatches `Plugin NPM Release` for all publishable plugin packages, dispatches `Plugin ClawHub Release` for the same release SHA, and only then dispatches -`OpenClaw NPM Release` with the saved `preflight_run_id`. +`OpenClaw NPM Release` with the saved `preflight_run_id`. Stable publish also +requires an exact `windows_node_tag`; the workflow verifies the Windows source +release and compares its x64/ARM64 installers with the candidate-approved +`windows_node_installer_digests` input before any publish child, then promotes +and verifies those same pinned installer digests plus the exact companion asset +and checksum contract before publishing the GitHub release draft. ```bash gh workflow run openclaw-release-publish.yml \ - --ref release/YYYY.M.D \ - -f tag=vYYYY.M.D-beta.N \ + --ref release/YYYY.M.PATCH \ + -f tag=vYYYY.M.PATCH-beta.N \ -f preflight_run_id= \ + -f full_release_validation_run_id= \ -f npm_dist_tag=beta ``` @@ -334,7 +340,7 @@ gh workflow run package-acceptance.yml \ --ref main \ -f workflow_ref=main \ -f source=ref \ - -f package_ref=release/YYYY.M.D \ + -f package_ref=release/YYYY.M.PATCH \ -f suite_profile=package \ -f telegram_mode=mock-openai diff --git a/docs/cli/approvals.md b/docs/cli/approvals.md index 2ca14ea3b9b0..c807b1215d3e 100644 --- a/docs/cli/approvals.md +++ b/docs/cli/approvals.md @@ -27,7 +27,7 @@ Use it when you want to: - inspect the local requested policy, host approvals file, and effective merge - apply a local preset such as YOLO or deny-all -- synchronize local `tools.exec.*` and local `~/.openclaw/exec-approvals.json` +- synchronize local `tools.exec.*` and the local host approvals file Examples: @@ -83,7 +83,7 @@ Precedence is intentional: ```bash openclaw approvals set --file ./exec-approvals.json openclaw approvals set --stdin <<'EOF' -{ version: 1, defaults: { security: "full", ask: "off" } } +{ version: 1, defaults: { security: "full", ask: "off", askFallback: "full" } } EOF openclaw approvals set --node --file ./exec-approvals.json openclaw approvals set --gateway --file ./exec-approvals.json @@ -137,7 +137,8 @@ Why `tools.exec.host=gateway` in this example: - YOLO is about approvals, not routing. - If you want host exec even when a sandbox is configured, make the host choice explicit with `gateway` or `/exec host=gateway`. -This matches the current host-default YOLO behavior. Tighten it if you want approvals. +Omitted `askFallback` defaults to `deny`. Set `askFallback: "full"` +explicitly when upgrading a no-UI host that should keep never-prompt behavior. Local shortcut: @@ -182,7 +183,9 @@ Targeting notes: - `--node` uses the same resolver as `openclaw nodes` (id, name, ip, or id prefix). - `--agent` defaults to `"*"`, which applies to all agents. - The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host). -- Approvals files are stored per host at `~/.openclaw/exec-approvals.json`. +- Approvals files are stored per host in the OpenClaw state dir + (`$OPENCLAW_STATE_DIR/exec-approvals.json`, or + `~/.openclaw/exec-approvals.json` when the variable is unset). ## Related diff --git a/docs/cli/browser.md b/docs/cli/browser.md index 31cf787c65e6..10cd0e4e798b 100644 --- a/docs/cli/browser.md +++ b/docs/cli/browser.md @@ -174,7 +174,22 @@ Notes: or `--element`. - `existing-session` / `user` profiles support page screenshots and `--ref` screenshots from snapshot output, but not CSS `--element` screenshots. -- `--labels` overlays current snapshot refs on the screenshot. +- `--labels` overlays current snapshot refs on the screenshot. On + Playwright-backed profiles, it works with `--full-page` (full-page label + overlay), `--ref` (element-clip label overlay by ARIA ref), and `--element` + (element-clip label overlay by CSS selector); in element-clip modes, labels + are projected relative to the element. The response also includes an + `annotations` array with each ref's bounding box. Each item has `ref`, + `number`, `role`, optional `name`, and `box: {x, y, width, height}`; + coordinates are in the captured image's space (viewport / fullpage / + element-relative). The field is omitted when empty. + `existing-session` profiles render a chrome-mcp overlay on page screenshots + but do not use the Playwright projection helper and do not include + `annotations`; CSS `--element` screenshots are unsupported there. Without + Playwright or chrome-mcp, labeled screenshots are not available. Prior + releases ignored `--full-page`, `--ref`, and `--element` on labeled + Playwright screenshots and always returned a viewport capture; labeled + screenshots now honor those scopes. - `snapshot --urls` appends discovered link destinations to AI snapshots so agents can choose direct navigation targets instead of guessing from link text alone. @@ -194,11 +209,14 @@ openclaw browser select OptionA OptionB openclaw browser fill --fields '[{"ref":"1","value":"Ada"}]' openclaw browser wait --text "Done" openclaw browser evaluate --fn '(el) => el.textContent' --ref +openclaw browser evaluate --fn 'const title = document.title; return title;' openclaw browser evaluate --timeout-ms 30000 --fn 'async () => { await window.ready; return true; }' ``` -Use `evaluate --timeout-ms ` when the page-side function may need longer -than the default evaluate timeout. +`evaluate --fn` accepts a function source, an expression, or a statement body. +Statement bodies are wrapped as async functions, so use `return` for the value +you want back. Use `evaluate --timeout-ms ` when the page-side function may +need longer than the default evaluate timeout. Action responses return the current raw `targetId` after action-triggered page replacement when OpenClaw can prove the replacement tab. Scripts should still @@ -277,10 +295,14 @@ Use the built-in `user` profile, or create your own `existing-session` profile: openclaw browser --browser-profile user tabs openclaw browser create-profile --name chrome-live --driver existing-session openclaw browser create-profile --name brave-live --driver existing-session --user-data-dir "~/Library/Application Support/BraveSoftware/Brave-Browser" +openclaw browser create-profile --name chrome-port --driver existing-session --cdp-url http://127.0.0.1:9222 openclaw browser --browser-profile chrome-live tabs ``` -This path is host-only. For Docker, headless servers, Browserless, or other remote setups, use a CDP profile instead. +The default existing-session path is host-only Chrome MCP auto-connect. If the browser is already +running with a DevTools endpoint, pass `--cdp-url` so Chrome MCP attaches to that endpoint instead. +For Docker, Browserless, or other remote setups where Chrome MCP semantics are not needed, use a +CDP profile. Current existing-session limits: diff --git a/docs/cli/cron.md b/docs/cli/cron.md index 9a6d149e7a2d..d7a64f14989c 100644 --- a/docs/cli/cron.md +++ b/docs/cli/cron.md @@ -34,6 +34,27 @@ openclaw cron create "0 18 * * 1-5" \ --webhook "https://example.invalid/openclaw/cron" ``` +Use `--command` for deterministic shell-style jobs that should run inside OpenClaw cron without starting an isolated agent/model run: + + +Command cron jobs are admin-authored Gateway automation. Creating, editing, +removing, or manually running them requires `operator.admin`; the scheduled run +later executes in the Gateway process, not as an agent `tools.exec` tool call. +`tools.exec.*` and exec approvals still govern model-visible exec tools. + + +```bash +openclaw cron create "*/15 * * * *" \ + --name "Queue depth probe" \ + --command "scripts/check-queue.sh" \ + --command-cwd "/srv/app" \ + --announce \ + --channel telegram \ + --to "-1001234567890" +``` + +`--command ` stores `argv: ["sh", "-lc", ]`. Use `--command-argv '["node","scripts/report.mjs"]'` for exact argv execution. Command jobs capture stdout/stderr, record normal cron history, and route output through the same `announce`, `webhook`, or `none` delivery modes as isolated jobs. A command that prints only `NO_REPLY` is suppressed. + ## Sessions `--session` accepts `main`, `isolated`, `current`, or `session:`. @@ -92,6 +113,10 @@ Note: isolated cron runs treat run-level agent failures as job errors even when no reply payload is produced, so model/provider failures still increment error counters and trigger failure notifications. +Command cron jobs do not start an isolated agent turn. A zero exit code records +`ok`; non-zero exit, signal, timeout, or no-output timeout records `error` and +can trigger the same failure notification path. + If an isolated run times out before the first model request, `openclaw cron show` and `openclaw cron runs` include a phase-specific error such as `setup timed out before runner start` or @@ -252,6 +277,21 @@ openclaw cron create "0 7 * * *" \ `--light-context` applies to isolated agent-turn jobs only. For cron runs, lightweight mode keeps bootstrap context empty instead of injecting the full workspace bootstrap set. +Create a command job with exact argv, cwd, env, stdin, and output limits: + +```bash +openclaw cron create "*/30 * * * *" \ + --name "Position export" \ + --command-argv '["node","scripts/export-position.mjs"]' \ + --command-cwd "/srv/app" \ + --command-env "NODE_ENV=production" \ + --command-input '{"mode":"summary"}' \ + --timeout-seconds 120 \ + --no-output-timeout-seconds 30 \ + --output-max-bytes 65536 \ + --webhook "https://example.invalid/openclaw/cron" +``` + ## Common admin commands Manual run and inspection: diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index 28973cb69438..43ba30d7d72f 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -230,7 +230,7 @@ Notes: - Doctor removes retired `plugins.entries.codex.config.codexDynamicToolsProfile`; Codex app-server always keeps Codex-native workspace tools native. - Doctor warns when skills allowed for the default agent are unavailable in the current runtime environment because bins, env vars, config, or OS requirements are missing. `doctor --fix` can disable those unavailable skills with `skills.entries..enabled=false`; install/configure the missing requirement instead when you want to keep the skill active. - If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`). -- If legacy sandbox registry files (`~/.openclaw/sandbox/containers.json` or `~/.openclaw/sandbox/browsers.json`) are present, doctor reports them; `openclaw doctor --fix` migrates valid entries into sharded registry directories and quarantines invalid legacy files. +- If legacy sandbox registry files or shard directories are present (`~/.openclaw/sandbox/containers.json`, `~/.openclaw/sandbox/browsers.json`, `~/.openclaw/sandbox/containers/`, or `~/.openclaw/sandbox/browsers/`), doctor reports them; `openclaw doctor --fix` migrates valid entries into SQLite and quarantines invalid legacy files. - If `gateway.auth.token`/`gateway.auth.password` are SecretRef-managed and unavailable in the current command path, doctor reports a read-only warning and does not write plaintext fallback credentials. For exec-backed SecretRefs, doctor skips execution unless `--allow-exec` is present. - If channel SecretRef inspection fails in a fix path, doctor continues and reports a warning instead of exiting early. - After state-directory migrations, doctor warns when enabled default Telegram or Discord accounts depend on env fallback and `TELEGRAM_BOT_TOKEN` or `DISCORD_BOT_TOKEN` is unavailable to the doctor process. diff --git a/docs/cli/mcp.md b/docs/cli/mcp.md index 840604105600..eb2876365940 100644 --- a/docs/cli/mcp.md +++ b/docs/cli/mcp.md @@ -673,7 +673,7 @@ Launches a local child process and communicates over stdin/stdout. **Stdio env safety filter** -OpenClaw rejects interpreter-startup env keys that can alter how a stdio MCP server starts up before the first RPC, even if they appear in a server's `env` block. Blocked keys include `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHONSTARTUP`, `PYTHONPATH`, `PERL5OPT`, `RUBYOPT`, `SHELLOPTS`, `PS4`, and similar runtime-control variables. Startup rejects these with a configuration error so they cannot inject an implicit prelude, swap the interpreter, enable a debugger, or redirect runtime output against the stdio process. Ordinary credential, proxy, and server-specific env vars (`GITHUB_TOKEN`, `HTTP_PROXY`, custom `*_API_KEY`, etc.) are unaffected. +OpenClaw rejects interpreter-startup env keys that can alter how a stdio MCP server starts up before the first RPC, even if they appear in a server's `env` block. Blocked keys include `BASHOPTS`, `FPATH`, `KSH_ENV`, `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHONSTARTUP`, `PYTHONPATH`, `PERL5OPT`, `RUBYOPT`, `SHELLOPTS`, `PS4`, `TCLLIBPATH`, and similar runtime-control variables. Startup rejects these with a configuration error so they cannot inject an implicit prelude, swap the interpreter, enable a debugger, or redirect runtime output against the stdio process. Ordinary credential, proxy, and server-specific env vars (`GITHUB_TOKEN`, `HTTP_PROXY`, custom `*_API_KEY`, etc.) are unaffected. If your MCP server genuinely needs one of the blocked variables, set it on the gateway host process instead of under the stdio server's `env`. diff --git a/docs/cli/node.md b/docs/cli/node.md index 1f846d276b23..1a906473c11e 100644 --- a/docs/cli/node.md +++ b/docs/cli/node.md @@ -162,7 +162,8 @@ The node host stores its node id, token, display name, and gateway connection in `system.run` is gated by local exec approvals: -- `~/.openclaw/exec-approvals.json` +- `$OPENCLAW_STATE_DIR/exec-approvals.json`, or + `~/.openclaw/exec-approvals.json` when the variable is unset - [Exec approvals](/tools/exec-approvals) - `openclaw approvals --node ` (edit from the Gateway) diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index b21a287aab45..f0d2ceab041a 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -182,7 +182,10 @@ Interactive onboarding behavior with reference mode: ### Non-interactive Z.AI endpoint choices -`--auth-choice zai-api-key` auto-detects the best Z.AI endpoint for your key (prefers the general API with `zai/glm-5.1`). If you specifically want the GLM Coding Plan endpoints, pick `zai-coding-global` or `zai-coding-cn`. +`--auth-choice zai-api-key` auto-detects the best Z.AI endpoint and model for +your key. Coding Plan endpoints prefer `zai/glm-5.2`; general API endpoints use +`zai/glm-5.1`. To force a Coding Plan endpoint, pick `zai-coding-global` or +`zai-coding-cn`. ```bash diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 4e4928c770a0..e5645ff817a7 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -159,7 +159,7 @@ is available, then fall back to `latest`. `--dangerously-force-unsafe-install` is deprecated and is now a no-op. OpenClaw no longer runs built-in install-time dangerous-code blocking for plugin installs. - Use the shared operator-owned `security.installPolicy` surface when host-specific install policy is required. Plugin `before_install` hooks and `security.installPolicy` can still block installs. + Use the shared operator-owned `security.installPolicy` surface when host-specific install policy is required. Plugin `before_install` hooks are plugin-runtime lifecycle hooks and are not the primary policy boundary for CLI installs. If a plugin you published on ClawHub is hidden or blocked by a registry scan, use the publisher steps in [ClawHub publishing](/clawhub/publishing). `--dangerously-force-unsafe-install` does not ask ClawHub to rescan the plugin or make a blocked release public. @@ -405,7 +405,7 @@ Updates apply to tracked plugin installs in the managed plugin index and tracked - `--dangerously-force-unsafe-install` is also accepted on `plugins update` for compatibility, but it is deprecated and no longer changes plugin update behavior. Operator `security.installPolicy` and plugin `before_install` hooks can still block updates. + `--dangerously-force-unsafe-install` is also accepted on `plugins update` for compatibility, but it is deprecated and no longer changes plugin update behavior. Operator `security.installPolicy` can still block updates; plugin `before_install` hooks only apply in processes where plugin hooks are loaded. @@ -417,7 +417,7 @@ openclaw plugins inspect --runtime openclaw plugins inspect --json ``` -Inspect shows identity, load status, source, manifest capabilities, policy flags, diagnostics, install metadata, bundle capabilities, and any detected MCP or LSP server support without importing plugin runtime by default. Add `--runtime` to load the plugin module and include registered hooks, tools, commands, services, gateway methods, and HTTP routes. Runtime inspection reports missing plugin dependencies directly; installs and repairs stay in `openclaw plugins install`, `openclaw plugins update`, and `openclaw doctor --fix`. +Inspect shows identity, load status, source, manifest capabilities, policy flags, diagnostics, install metadata, bundle capabilities, and any detected MCP or LSP server support without importing plugin runtime by default. JSON output includes the plugin manifest contracts, such as `contracts.agentToolResultMiddleware` and `contracts.trustedToolPolicies`, so operators can audit trusted-surface declarations before enabling or restarting a plugin. Add `--runtime` to load the plugin module and include registered hooks, tools, commands, services, gateway methods, and HTTP routes. Runtime inspection reports missing plugin dependencies directly; installs and repairs stay in `openclaw plugins install`, `openclaw plugins update`, and `openclaw doctor --fix`. Plugin-owned CLI commands are usually installed as root `openclaw` command groups, but plugins may also register nested commands under a core parent such as `openclaw nodes`. After `inspect --runtime` shows a command under `cliCommands`, run it at the listed path; for example a plugin that registers `demo-git` can be verified with `openclaw demo-git ping`. diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index b16f1576b3ab..50f14647841e 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -166,12 +166,12 @@ Prefer `openclaw sandbox recreate` over manual backend-specific cleanup. It uses ## Registry migration -OpenClaw stores sandbox runtime metadata as one JSON shard per container/browser entry under the sandbox state directory. Older installs may still have monolithic legacy files: +OpenClaw stores sandbox runtime metadata in the shared SQLite state database. Older installs may still have legacy sandbox registry files: - `~/.openclaw/sandbox/containers.json` - `~/.openclaw/sandbox/browsers.json` -Regular sandbox runtime reads do not rewrite those files. Run `openclaw doctor --fix` to migrate valid legacy entries into the sharded registry directories. Invalid legacy files are quarantined so one bad old registry cannot hide current runtime entries. +Some upgrades may also have one JSON shard per container/browser under `~/.openclaw/sandbox/containers/` or `~/.openclaw/sandbox/browsers/`. Regular sandbox runtime reads do not rewrite those legacy sources. Run `openclaw doctor --fix` to migrate valid legacy entries into SQLite. Invalid legacy files are quarantined so one bad old registry cannot hide current runtime entries. ## Configuration diff --git a/docs/cli/status.md b/docs/cli/status.md index 3524e4ad2f04..7fc0b7c2ef05 100644 --- a/docs/cli/status.md +++ b/docs/cli/status.md @@ -26,7 +26,7 @@ Notes: - When the current session snapshot is sparse, `/status` can backfill token and cache counters from the most recent transcript usage log. Existing nonzero live values still win over transcript fallback values. - `/status` includes compact Gateway process uptime and host system uptime. - Transcript fallback can also recover the active runtime model label when the live session entry is missing it. If that transcript model differs from the selected model, status resolves the context window against the recovered runtime model instead of the selected one. -- When a session is pinned to a model that differs from the configured primary, status prints both values, the reason (`session override`), and the clear hint (`/model ` or `/reset`). The configured primary applies to new or unpinned sessions; existing pinned sessions keep their session selection until cleared. +- When a session is pinned to a model that differs from the configured primary, status prints both values, the reason (`session override`), and the clear hint (`/model default`). The configured primary applies to new or unpinned sessions; existing pinned sessions keep their session selection until cleared. - For prompt-size accounting, transcript fallback prefers the larger prompt-oriented total when session metadata is missing or smaller, so custom-provider sessions do not collapse to `0` token displays. - Output includes per-agent session stores when multiple agents are configured. - Overview includes Gateway + node host service install/runtime status when available. diff --git a/docs/cli/tui.md b/docs/cli/tui.md index a4cdd59860fb..a19fe435daef 100644 --- a/docs/cli/tui.md +++ b/docs/cli/tui.md @@ -40,6 +40,7 @@ Notes: - `--local` cannot be combined with `--url`, `--token`, or `--password`. - `tui` resolves configured gateway auth SecretRefs for token/password auth when possible (`env`/`file`/`exec` providers). - When launched from inside a configured agent workspace directory, TUI auto-selects that agent for the session key default (unless `--session` is explicitly `agent::...`). +- To show the Gateway hostname in the footer for non-local URL-backed connections, run `openclaw config set tui.footer.showRemoteHost true`. The host label is off by default and never appears for loopback or embedded local connections. - Local mode uses the embedded agent runtime directly. Most local tools work, but Gateway-only features are unavailable. - Local mode adds `/auth [provider]` inside the TUI command surface. - Plugin approval gates still apply in local mode. Tools that require approval prompt for a decision in the terminal; nothing is silently auto-approved because the Gateway is not involved. diff --git a/docs/cli/update.md b/docs/cli/update.md index 4783cd883c21..fb2ce17f2df5 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -19,6 +19,7 @@ updates happen via the package-manager flow in [Updating](/install/updating). ```bash openclaw update openclaw update status +openclaw update repair openclaw update wizard openclaw update --channel beta openclaw update --channel dev @@ -76,6 +77,36 @@ Options: - `--json`: print machine-readable status JSON. - `--timeout `: timeout for checks (default is 3s). +## `update repair` + +Rerun update finalization after the core package already changed but later +repair work did not finish cleanly. This is the supported recovery path when +`openclaw update` installed the new core package but post-core plugin sync, +managed npm plugin metadata, registry refresh, or doctor repair still needs to +converge. + +```bash +openclaw update repair +openclaw update repair --channel beta +openclaw update repair --json +``` + +Options: + +- `--channel `: persist the update channel before repair and + run plugin convergence against that channel. +- `--json`: print machine-readable finalization JSON. +- `--timeout `: timeout for repair steps (default `1800`). +- `--yes`: skip confirmation prompts. +- `--no-restart`: accepted for update command parity; repair never restarts the + Gateway. + +`openclaw update repair` runs `openclaw doctor --fix`, reloads the repaired +config and install records, syncs tracked plugins for the active update channel, +updates managed npm plugin installs, repairs missing configured plugin payloads, +refreshes the plugin registry, and writes the converged install-record metadata. +It does not install a new core package and does not restart the Gateway. + ## `update wizard` Interactive flow to pick an update channel and confirm whether to restart the Gateway @@ -99,12 +130,14 @@ install method aligned: missing or older than the current stable release. The Gateway core auto-updater (when enabled via config) launches the CLI update path -outside the live Gateway request handler. Control-plane `update.run` package-manager -updates also use a managed-service handoff instead of replacing the package tree -inside the live Gateway process. The Gateway starts a detached helper, exits, -and the helper runs the normal `openclaw update --yes --json` CLI path from -outside the Gateway process tree. If that handoff is unavailable, `update.run` -returns a structured response with the safe shell command to run manually. +outside the live Gateway request handler. Control-plane `update.run` +package-manager updates and supervised git-checkout updates also use a +managed-service handoff instead of replacing the package tree or rebuilding +`dist/` inside the live Gateway process. The Gateway starts a detached helper, +exits, and the helper runs the normal `openclaw update --yes --json` CLI path +from outside the Gateway process tree. If that handoff is unavailable, +`update.run` returns a structured response with the safe shell command to run +manually. For package-manager installs, `openclaw update` resolves the target package version before invoking the package manager. npm global installs use a staged @@ -119,29 +152,33 @@ installed OpenClaw build while leaving full plugin-command completion rebuilds t explicit `openclaw completion --write-state` runs. When a local managed Gateway service is installed and restart is enabled, -package-manager updates stop the running service before replacing the package -tree, then refresh the service metadata from the updated install, restart the -service, and verify the restarted Gateway reports the expected version before -reporting `Gateway: restarted and verified.`. On macOS, the post-update check -also verifies the LaunchAgent is loaded/running for the active profile and the -configured loopback port is healthy. If the plist is installed but launchd is -not supervising it, OpenClaw re-bootstraps the LaunchAgent automatically, then -reruns the health/version/channel readiness checks. A fresh bootstrap loads the -RunAtLoad job directly, so update recovery does not immediately `kickstart -k` -the newly spawned Gateway. If the Gateway still does not become healthy, the -command exits non-zero and prints the restart log path plus explicit restart, -reinstall, and package rollback instructions. If restart cannot run, the command -prints `Gateway: restart skipped (...)` or `Gateway: restart failed: ...` with a -manual `openclaw gateway restart` hint. With `--no-restart`, -package replacement still runs but the managed service is not stopped or -restarted, so the running Gateway may keep old code until you restart it -manually. +package-manager and git-checkout updates stop the running service before +replacing the package tree or mutating the checkout/build output. The updater +then refreshes the service metadata from the updated install, restarts the +service, and verifies the restarted Gateway before reporting +`Gateway: restarted and verified.`. Package-manager updates additionally verify +the restarted Gateway reports the expected package version; git-checkout updates +verify gateway health and service readiness after the rebuild. On macOS, the +post-update check also verifies the LaunchAgent is loaded/running for the active +profile and the configured loopback port is healthy. If the plist is installed +but launchd is not supervising it, OpenClaw re-bootstraps the LaunchAgent +automatically, then reruns the health/version/channel readiness checks. A fresh +bootstrap loads the RunAtLoad job directly, so update recovery does not +immediately `kickstart -k` the newly spawned Gateway. If the Gateway still does +not become healthy, the command exits non-zero and prints the restart log path +plus explicit restart, reinstall, and package rollback instructions. If restart +cannot run, the command prints `Gateway: restart skipped (...)` or +`Gateway: restart failed: ...` with a manual `openclaw gateway restart` hint. +With `--no-restart`, package replacement or git rebuild still runs but the +managed service is not stopped or restarted, so the running Gateway may keep old +code until you restart it manually. ### Control-plane response shape When `update.run` is invoked through the Gateway control plane on a -package-manager install, the handler reports the handoff initiation separately -from the CLI update that continues after the Gateway exits: +package-manager install or supervised git checkout, the handler reports the +handoff initiation separately from the CLI update that continues after the +Gateway exits: - `ok: true`, `result.status: "skipped"`, `result.reason: "managed-service-handoff-started"`, and @@ -150,8 +187,11 @@ from the CLI update that continues after the Gateway exits: `openclaw update --yes --json` outside the live service process. - `ok: false`, `result.reason: "managed-service-handoff-unavailable"`, and `handoff.status: "unavailable"` mean OpenClaw could not find a supervising - service boundary for a safe handoff. The response includes - `handoff.command`, the shell command to run from outside the Gateway. + service boundary and durable service identity for a safe handoff. For + example, systemd handoff requires the OpenClaw unit identity + (`OPENCLAW_SYSTEMD_UNIT`), not only ambient systemd process markers. The + response includes `handoff.command`, the shell command to run from outside the + Gateway. - `ok: false`, `result.reason: "managed-service-handoff-failed"` means the Gateway tried to create the handoff but could not spawn the detached helper. @@ -162,8 +202,8 @@ health checks complete. During the handoff, the sentinel can carry restarted Gateway keeps polling it and only fires the continuation after the CLI has verified service health and rewritten the sentinel with the final `ok` result. `openclaw status` and `openclaw status --all` show an `Update restart` -row while that sentinel is pending or failed, and `update.status` returns the -latest cached sentinel. +row while that sentinel is pending or failed, and `update.status` refreshes and +returns the latest sentinel. ## Git checkout flow @@ -218,9 +258,9 @@ If an exact pinned npm plugin update resolves to an artifact whose integrity dif -Post-update plugin sync failures that are scoped to a managed plugin and that the sync path can route around (e.g. an unreachable npm registry for a non-essential plugin) are reported as warnings after the core update succeeds. The JSON result keeps the top-level update `status: "ok"` and reports `postUpdate.plugins.status: "warning"` with `openclaw doctor --fix` and `openclaw plugins inspect --runtime --json` guidance. Unexpected updater or sync exceptions still fail the update result. Fix the plugin install or update error, then rerun `openclaw doctor --fix` or `openclaw update`. +Post-update plugin sync failures that are scoped to a managed plugin and that the sync path can route around (e.g. an unreachable npm registry for a non-essential plugin) are reported as warnings after the core update succeeds. The JSON result keeps the top-level update `status: "ok"` and reports `postUpdate.plugins.status: "warning"` with `openclaw update repair` and `openclaw plugins inspect --runtime --json` guidance. Unexpected updater or sync exceptions still fail the update result. Fix the plugin install or update error, then rerun `openclaw update repair`. -After the per-plugin sync step, `openclaw update` runs a mandatory **post-core convergence** pass before the gateway is restarted: it repairs missing configured plugin payloads, validates each _active_ tracked install record on disk, and statically verifies its `package.json` is parseable (and any explicitly-declared `main` exists). Failures from this pass — and an invalid OpenClaw config snapshot — return `postUpdate.plugins.status: "error"` and flip the top-level update `status` to `"error"`, so `openclaw update` exits non-zero and the gateway is _not_ restarted with an unverified plugin set. The error includes structured `postUpdate.plugins.warnings[].guidance` lines pointing at `openclaw doctor --fix` and `openclaw plugins inspect --runtime --json` for follow-up. Disabled plugin entries and records that are not trusted-source-linked official sync targets are skipped here, mirroring the `skipDisabledPlugins` policy used by the missing-payload check, so a stale disabled plugin record cannot block an otherwise valid update. +After the per-plugin sync step, `openclaw update` runs a mandatory **post-core convergence** pass before the gateway is restarted: it repairs missing configured plugin payloads, validates each _active_ tracked install record on disk, and statically verifies its `package.json` is parseable (and any explicitly-declared `main` exists). Failures from this pass — and an invalid OpenClaw config snapshot — return `postUpdate.plugins.status: "error"` and flip the top-level update `status` to `"error"`, so `openclaw update` exits non-zero and the gateway is _not_ restarted with an unverified plugin set. The error includes structured `postUpdate.plugins.warnings[].guidance` lines pointing at `openclaw update repair` and `openclaw plugins inspect --runtime --json` for follow-up. Disabled plugin entries and records that are not trusted-source-linked official sync targets are skipped here, mirroring the `skipDisabledPlugins` policy used by the missing-payload check, so a stale disabled plugin record cannot block an otherwise valid update. When the updated Gateway starts, plugin loading is verify-only: startup does not run package managers or mutate dependency trees. Package-manager `update.run` diff --git a/docs/cli/wiki.md b/docs/cli/wiki.md index 50901f0aa28c..37dda2de22b4 100644 --- a/docs/cli/wiki.md +++ b/docs/cli/wiki.md @@ -35,6 +35,7 @@ openclaw wiki status openclaw wiki doctor openclaw wiki init openclaw wiki ingest ./notes/alpha.md +openclaw wiki okf import ./knowledge-catalog/okf/bundles/ga4 openclaw wiki compile openclaw wiki lint openclaw wiki search "alpha" @@ -104,6 +105,31 @@ Notes: - imported source pages keep provenance in frontmatter - auto-compile can run after ingest when enabled +### `wiki okf import ` + +Import an unpacked Open Knowledge Format bundle into wiki concept pages. + +The importer reads every non-reserved `.md` concept document in the OKF +directory tree, requires a non-empty `type` field, and treats unknown OKF +`type` values as generic concepts. Reserved OKF `index.md` and `log.md` files +are not imported as concepts. + +Imported pages are flattened under `concepts/` so existing wiki compile, +search, get, digest, and dashboard flows see them immediately. The original OKF +concept ID, `type`, `resource`, `tags`, timestamp, source path, and full +frontmatter are preserved in the page frontmatter. Internal OKF markdown links +are rewritten to the generated wiki pages; broken or external links are left +unchanged. + +Examples: + +```bash +openclaw wiki okf import ./bundles/ga4 +openclaw wiki okf import ./bundles/ga4 --json +openclaw wiki search "BigQuery Table" --mode source-evidence --json +openclaw wiki get +``` + ### `wiki compile` Rebuild indexes, related blocks, dashboards, and compiled digests. @@ -233,6 +259,8 @@ These require the official `obsidian` CLI on `PATH` when - Use `wiki lint` before trusting contradictory or low-confidence content. - Use `wiki compile` after bulk imports or source changes when you want fresh dashboards and compiled digests immediately. +- Use `wiki okf import` when a data catalog, documentation export, or agent + enrichment pipeline already emits OKF markdown bundles. - Use `wiki bridge import` when bridge mode depends on newly exported memory artifacts. diff --git a/docs/concepts/active-memory.md b/docs/concepts/active-memory.md index b23108a06220..3494e23a8bf2 100644 --- a/docs/concepts/active-memory.md +++ b/docs/concepts/active-memory.md @@ -479,6 +479,9 @@ names that plugin registers. Active Memory lists those tools in the recall prompt and passes the same list to the embedded sub-agent. If none of the configured tools are available, or the memory sub-agent fails, Active Memory skips recall for that turn and the main reply continues without memory context. +For custom recall tools, non-empty model-visible tool output counts as recall +evidence unless structured result fields explicitly report an empty result or +failure. `toolsAllow` only accepts concrete memory tool names. Wildcards, `group:*` entries, and core agent tools such as `read`, `exec`, `message`, and `web_search` are ignored before the hidden memory sub-agent starts. @@ -743,7 +746,11 @@ Before v2026.5.2 the plugin silently extended your configured `timeoutMs` by an extra 30000 ms during cold-start so model warm-up, embedding-index load, and the first recall could share one larger budget. v2026.5.2 moved that grace behind an explicit `setupGraceTimeoutMs` config — your configured `timeoutMs` -is now the budget by default, unless you opt in. +is now the recall-work budget by default, unless you opt in. The blocking hook +uses two bounded phases around that budget: up to 1500 ms for session/config +preflight before recall starts, then a separate fixed 1500 ms for abort +settlement and transcript recovery after recall work stops. Neither allowance +extends model or tool execution. If you upgraded from v2026.4.x and you set `timeoutMs` to a value tuned for the old implicit-grace world (the recommended starter `timeoutMs: 15000` is one @@ -765,14 +772,16 @@ outer watchdog budgets back to the pre-v5.2 effective values: } ``` -Per the v2026.5.2 changelog: _"use the configured recall timeout as the -blocking prompt-build hook budget by default and move cold-start setup grace -behind explicit `setupGraceTimeoutMs` config, so the plugin no longer silently -extends 15000 ms configs to 45000 ms on the main lane."_ +The v2026.5.2 change removed the old implicit 30000 ms cold-start extension. +Beyond the configured recall-work budget, the hook can use up to 1500 ms for +preflight and another 1500 ms for post-recall completion. Its worst-case +blocking time is therefore `timeoutMs + setupGraceTimeoutMs + 3000` ms. The embedded recall runner uses the same effective timeout budget, so `setupGraceTimeoutMs` covers both the outer prompt-build watchdog and the inner -blocking recall run. +blocking recall run. The preflight cap covers session/config checks before that +budget begins. The post-recall allowance lets the outer hook settle abort +cleanup and read any final transcript state. For resource-tight gateways where cold-start latency is a known trade-off, lower values (5000–15000 ms) work too — the trade-off is a higher chance of diff --git a/docs/concepts/agent-loop.md b/docs/concepts/agent-loop.md index f4235ae6bedc..7adcb4403a98 100644 --- a/docs/concepts/agent-loop.md +++ b/docs/concepts/agent-loop.md @@ -97,7 +97,7 @@ These run inside the agent loop or gateway pipeline: - **`agent_end`**: inspect the final message list and run metadata after completion. - **`before_compaction` / `after_compaction`**: observe or annotate compaction cycles. - **`before_tool_call` / `after_tool_call`**: intercept tool params/results. -- **`before_install`**: inspect install context and optionally block skill or plugin installs after operator install policy runs. +- **`before_install`**: inspect staged skill or plugin install material after operator install policy runs, when plugin hooks are loaded in the current OpenClaw process. - **`tool_result_persist`**: synchronously transform tool results before they are written to an OpenClaw-owned session transcript. - **`message_received` / `message_sending` / `message_sent`**: inbound + outbound message hooks. - **`session_start` / `session_end`**: session lifecycle boundaries. @@ -109,6 +109,7 @@ Hook decision rules for outbound/tool guards: - `before_tool_call`: `{ block: false }` is a no-op and does not clear a prior block. - `before_install`: `{ block: true }` is terminal and stops lower-priority handlers. - `before_install`: `{ block: false }` is a no-op and does not clear a prior block. +- Use `security.installPolicy`, not `before_install`, for operator-owned install allow/block decisions that must cover CLI install and update paths. - `message_sending`: `{ cancel: true }` is terminal and stops lower-priority handlers. - `message_sending`: `{ cancel: false }` is a no-op and does not clear a prior cancel. diff --git a/docs/concepts/context.md b/docs/concepts/context.md index 481238ecf759..2cc4d07dedbb 100644 --- a/docs/concepts/context.md +++ b/docs/concepts/context.md @@ -21,7 +21,7 @@ Context is _not the same thing_ as "memory": memory can be stored on disk and re - `/status` → quick "how full is my window?" view + session settings. - `/context list` → what's injected + rough sizes (per file + totals). -- `/context detail` → deeper breakdown: per-file, per-tool schema sizes, per-skill entry sizes, and system prompt size. +- `/context detail` → deeper breakdown: per-file, per-tool schema sizes, per-skill entry sizes, system prompt size, and compactable transcript message counts. - `/context map` → WinDirStat-style treemap image of the current session's tracked context contributors. - `/usage tokens` → append per-reply usage footer to normal replies. - `/compact` → summarize older history into a compact entry to free window space. @@ -179,7 +179,7 @@ pluggable interface, lifecycle hooks, and configuration. - `System prompt (run)` = captured from the last embedded (tool-capable) run and persisted in the session store. - `System prompt (estimate)` = computed on the fly when no run report exists (or when running via a CLI backend that doesn't generate the report). -Either way, it reports sizes and top contributors; it does **not** dump the full system prompt or tool schemas. +Either way, it reports sizes and top contributors; it does **not** dump the full system prompt or tool schemas. In detailed mode, it also compares the session transcript with the same real-conversation message predicate used by compaction, so high prompt/cache usage is easier to distinguish from compactable conversation history. ## Related diff --git a/docs/concepts/experimental-features.md b/docs/concepts/experimental-features.md index 1b9c0acebf41..4a7da33e24e8 100644 --- a/docs/concepts/experimental-features.md +++ b/docs/concepts/experimental-features.md @@ -30,7 +30,7 @@ Treat them differently from normal config: ## Local model lean mode -`agents.defaults.experimental.localModelLean: true` is a pressure-release valve for weaker local-model setups. When it is on, OpenClaw drops three default tools — `browser`, `cron`, and `message` — from the agent's tool surface for every turn. Nothing else changes. Use `agents.list[].experimental.localModelLean` to enable or disable the same behavior for one configured agent. +`agents.defaults.experimental.localModelLean: true` is a pressure-release valve for weaker local-model setups. When it is on, OpenClaw drops three default tools — `browser`, `cron`, and `message` — from the agent's tool surface for every turn. It also defaults that run to structured Tool Search controls when `tools.toolSearch` is not explicitly configured, so larger plugin, MCP, or client tool catalogs stay behind `tool_search`, `tool_describe`, and `tool_call` instead of being dumped into the prompt. Runs that require direct `message` delivery keep that tool direct instead of enabling the lean-mode Tool Search default. Use `agents.list[].experimental.localModelLean` to enable or disable the same behavior for one configured agent. ### Why these three tools @@ -40,7 +40,7 @@ These three tools have the largest descriptions and the most parameter shapes in - The model picking the right tool vs. emitting malformed tool calls because there are too many similar-looking schemas. - The Chat Completions adapter staying inside the server's structured-output limits vs. tripping a 400 on tool-call payload size. -Removing them does not silently rewire OpenClaw — it just makes the tool list shorter. The model still has `read`, `write`, `edit`, `exec`, `apply_patch`, web search/fetch (when configured), memory, and session/agent tools available. +Removing them does not silently rewire OpenClaw — it just makes the direct tool list shorter. The model still has `read`, `write`, `edit`, `exec`, `apply_patch`, web search/fetch (when configured), memory, and session/agent tools available. Extra catalogs remain callable through Tool Search unless you explicitly set `tools.toolSearch: false`. ### When to turn it on @@ -56,6 +56,8 @@ If your backend handles the full default runtime cleanly, leave this off. Lean m Lean mode also does not replace `tools.profile`, `tools.allow`/`tools.deny`, or the model `compat.supportsTools: false` escape hatch. If you need a permanent narrower tool surface for a specific agent, prefer those stable knobs over the experimental flag. +If you already tune Tool Search globally, OpenClaw leaves that operator config alone. Set `tools.toolSearch: false` to opt out of the lean-mode Tool Search default. + ### Enable ```json5 @@ -94,7 +96,7 @@ Restart the Gateway after changing the flag, then confirm the trimmed tool list openclaw status --deep ``` -The deep status output lists the active agent tools; `browser`, `cron`, and `message` should be absent when lean mode is on. +The deep status output lists the active agent tools; `browser`, `cron`, and `message` should be absent when lean mode is on unless the current delivery mode forces direct `message` replies. ## Experimental does not mean hidden diff --git a/docs/concepts/mantis.md b/docs/concepts/mantis.md index 825184a86f55..e26f301b2c75 100644 --- a/docs/concepts/mantis.md +++ b/docs/concepts/mantis.md @@ -247,12 +247,13 @@ of only a bot-to-bot Slack transcript. evidence pipeline. It checks out the trusted candidate ref in a separate worktree, runs `pnpm openclaw qa telegram --credential-source convex --credential-role ci`, writes a `mantis-evidence.json` manifest from the -Telegram QA summary and observed-message artifact, renders the redacted -transcript HTML through a Crabbox desktop browser, generates a motion-trimmed GIF -with `crabbox media preview`, and posts the inline PR evidence comment when a PR -number is available. This lane is transcript-visual rather than logged-in -Telegram Web proof: the Telegram Bot API gives stable live message evidence, but -Telegram Web login state is not required for normal Mantis automation. +Telegram QA summary, `qa-evidence.json`, and report artifacts, renders the +redacted evidence HTML through a Crabbox desktop browser, generates a +motion-trimmed GIF with `crabbox media preview`, and posts the inline PR +evidence comment when a PR number is available. This lane is QA-evidence visual +rather than logged-in Telegram Web proof: the Telegram Bot API gives stable live +message evidence, but Telegram Web login state is not required for normal Mantis +automation. `Mantis Telegram Desktop Proof` is the agentic native Telegram Desktop before/after wrapper. A maintainer can trigger it from a PR comment with @@ -494,8 +495,8 @@ zero: - `pnpm openclaw qa discord` already runs a live Discord lane with driver and SUT bots. -- The live transport runner already writes reports and observed-message - artifacts under `.artifacts/qa-e2e/`. +- The live transport runner already writes reports, QA evidence, and + transport-specific artifacts under `.artifacts/qa-e2e/`. - Convex credential leases already provide exclusive access to shared live transport credentials. - The browser control service already supports screenshots, snapshots, diff --git a/docs/concepts/memory-builtin.md b/docs/concepts/memory-builtin.md index 40f2a2a433e2..bd4198672651 100644 --- a/docs/concepts/memory-builtin.md +++ b/docs/concepts/memory-builtin.md @@ -39,9 +39,12 @@ To set a provider explicitly: Without an embedding provider, only keyword search is available. -To force the built-in local embedding provider, install the optional -`node-llama-cpp` runtime package next to OpenClaw, then point `local.modelPath` -at a GGUF file: +To force local GGUF embeddings, install the official llama.cpp provider plugin, +then point `local.modelPath` at a GGUF file: + +```bash +openclaw plugins install @openclaw/llama-cpp-provider +``` ```json5 { @@ -67,7 +70,7 @@ at a GGUF file: | DeepInfra | `deepinfra` | Default: `BAAI/bge-m3` | | Gemini | `gemini` | Supports multimodal (image + audio) | | GitHub Copilot | `github-copilot` | Uses Copilot subscription | -| Local | `local` | Optional `node-llama-cpp` runtime | +| Local | `local` | `@openclaw/llama-cpp-provider` | | Mistral | `mistral` | | | Ollama | `ollama` | Local/self-hosted | | OpenAI | `openai` | Default: `text-embedding-3-small` | diff --git a/docs/concepts/memory-qmd.md b/docs/concepts/memory-qmd.md index f363378ea9d3..005bcfffa8ff 100644 --- a/docs/concepts/memory-qmd.md +++ b/docs/concepts/memory-qmd.md @@ -15,7 +15,7 @@ binary, and can index content beyond your workspace memory files. - **Reranking and query expansion** for better recall. - **Index extra directories** -- project docs, team notes, anything on disk. - **Index session transcripts** -- recall earlier conversations. -- **Fully local** -- runs with the optional node-llama-cpp runtime package and +- **Fully local** -- runs with the official llama.cpp provider plugin and auto-downloads GGUF models. - **Automatic fallback** -- if QMD is unavailable, OpenClaw falls back to the builtin engine seamlessly. @@ -62,10 +62,12 @@ present. `build`. Gateway startup does not initialize QMD by default, so cold boot avoids importing the memory runtime or creating the long-lived watcher before memory is first used. -- If you want a gateway-start refresh anyway, set - `memory.qmd.update.startup` to `idle` or `immediate`. The opt-in startup - refresh uses a one-shot QMD subprocess path instead of creating the full - long-lived in-process watcher. +- If you want QMD initialized at gateway start anyway, set + `memory.qmd.update.startup` to `idle` or `immediate`. With + `memory.qmd.update.onBoot: true`, startup runs the initial refresh. With + `onBoot: false`, startup skips that immediate refresh but still opens the + long-lived manager when update or embed intervals are configured, so QMD can + own its regular watcher and timers. - Searches use the configured `searchMode` (default: `search`; also supports `vsearch` and `query`). `search` is BM25-only, so OpenClaw skips semantic vector readiness probes and embedding maintenance in that mode. If a mode diff --git a/docs/concepts/memory-search.md b/docs/concepts/memory-search.md index de9fac2eb417..b7c9996f69d0 100644 --- a/docs/concepts/memory-search.md +++ b/docs/concepts/memory-search.md @@ -32,7 +32,8 @@ For multi-endpoint setups with memory-specific providers, `provider` can also be a custom `models.providers.` entry, such as `ollama-5080`, when that provider sets `api: "ollama"` or another memory embedding adapter owner. -For local embeddings with no API key, set `provider: "local"`. Source checkouts +For local embeddings with no API key, install +`@openclaw/llama-cpp-provider` and set `provider: "local"`. Source checkouts may still require native build approval: `pnpm approve-builds` then `pnpm rebuild node-llama-cpp`. @@ -76,9 +77,16 @@ flowchart LR - **BM25 keyword search** finds exact matches (IDs, error strings, config keys). -If only one path is available (no embeddings or no FTS), the other runs alone. +If only one path is available, the other runs alone. Intentional FTS-only mode +(`provider: "none"`) and automatic/default provider selection can still use +lexical ranking when embeddings are unavailable. -When embeddings are unavailable, OpenClaw still uses lexical ranking over FTS results instead of falling back to raw exact-match ordering only. That degraded mode boosts chunks with stronger query-term coverage and relevant file paths, which keeps recall useful even without `sqlite-vec` or an embedding provider. +Explicit non-local embedding providers are different. If you set +`memorySearch.provider` to a concrete remote-backed provider and that provider +is unavailable at runtime, `memory_search` reports memory as unavailable instead +of silently using FTS-only results. This keeps a broken configured semantic +provider visible. Set `provider: "none"` for deliberate FTS-only recall, or fix +the provider/auth configuration to restore semantic ranking. ## Improving search quality diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index e788ee699ae1..e58e8cfbe649 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -99,7 +99,7 @@ Official provider plugins publish their own model catalog rows. These providers - Use `params.serviceTier` when you want an explicit tier instead of the shared `/fast` toggle - Hidden OpenClaw attribution headers (`originator`, `version`, `User-Agent`) apply only on native OpenAI traffic to `api.openai.com`, not generic OpenAI-compatible proxies - Native OpenAI routes also keep Responses `store`, prompt-cache hints, and OpenAI reasoning-compat payload shaping; proxy routes do not -- `openai/gpt-5.3-codex-spark` is intentionally suppressed in OpenClaw because live OpenAI API requests reject it and the current Codex catalog does not expose it +- `openai/gpt-5.3-codex-spark` is available through ChatGPT/Codex OAuth subscription auth when your signed-in account exposes it; OpenClaw still suppresses direct OpenAI API-key and Azure API-key routes for this model because those transports reject it ```json5 { @@ -264,7 +264,7 @@ Gemini CLI JSON replies are parsed from `response`; usage falls back to `stats`, - Provider: `zai` - Auth: `ZAI_API_KEY` -- Example model: `zai/glm-5.1` +- Example model: `zai/glm-5.2` - CLI: `openclaw onboard --auth-choice zai-api-key` - Model refs use the canonical `zai/*` provider ID. - `zai-api-key` auto-detects the matching Z.AI endpoint; `zai-coding-global`, `zai-coding-cn`, `zai-global`, and `zai-cn` force a specific surface @@ -309,7 +309,7 @@ See [/providers/kilocode](/providers/kilocode) for setup details. | NVIDIA | `nvidia` | `NVIDIA_API_KEY` | `nvidia/nvidia/nemotron-3-ultra-550b-a55b` | | NovitaAI | `novita` | `NOVITA_API_KEY` | `novita/deepseek/deepseek-v3-0324` | | [Ollama Cloud](/providers/ollama-cloud) | `ollama-cloud` | `OLLAMA_API_KEY` | `ollama-cloud/kimi-k2.6` | -| OpenRouter | `openrouter` | `OPENROUTER_API_KEY` | `openrouter/auto` | +| OpenRouter | `openrouter` | OpenRouter OAuth or `OPENROUTER_API_KEY` | `openrouter/auto` | | Qianfan | `qianfan` | `QIANFAN_API_KEY` | `qianfan/deepseek-v3.2` | | Qwen Cloud | `qwen` | `QWEN_API_KEY` / `MODELSTUDIO_API_KEY` / `DASHSCOPE_API_KEY` | `qwen/qwen3.5-plus` | | [Qwen OAuth](/providers/qwen-oauth) | `qwen-oauth` | `QWEN_API_KEY` | `qwen-oauth/qwen3.5-plus` | @@ -368,6 +368,7 @@ Kimi K2 model IDs: [//]: # "moonshot-kimi-k2-model-refs:start" - `moonshot/kimi-k2.6` +- `moonshot/kimi-k2.7-code` - `moonshot/kimi-k2.5` - `moonshot/kimi-k2-thinking` - `moonshot/kimi-k2-thinking-turbo` @@ -532,7 +533,7 @@ MiniMax is configured via `models.providers` because it uses custom endpoints: See [/providers/minimax](/providers/minimax) for setup details, model options, and config snippets. -On MiniMax's Anthropic-compatible streaming path, OpenClaw disables thinking by default unless you explicitly set it, and `/fast on` rewrites `MiniMax-M2.7` to `MiniMax-M2.7-highspeed`. +On MiniMax's Anthropic-compatible streaming path, OpenClaw disables thinking by default for the M2.x family unless you explicitly set it; MiniMax-M3 (and M3.x) stays on the provider's omitted/adaptive thinking path by default. `/fast on` rewrites `MiniMax-M2.7` to `MiniMax-M2.7-highspeed`. Plugin-owned capability split: diff --git a/docs/concepts/models.md b/docs/concepts/models.md index f5058983657f..838ed095486f 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -61,7 +61,7 @@ The same `provider/model` can mean different things depending on where it came f - Configured defaults (`agents.defaults.model.primary` and agent-specific primaries) are the normal starting point and use `agents.defaults.model.fallbacks`. - Auto fallback selections are temporary recovery state. They are stored with `modelOverrideSource: "auto"` so later turns can keep using the fallback chain without probing a known-bad primary every time; OpenClaw periodically probes the original primary again, clears the auto selection when it recovers, and announces fallback/recovery transitions once per state change. - User session selections are exact. `/model`, the model picker, `session_status(model=...)`, and `sessions.patch` store `modelOverrideSource: "user"`; if that selected provider/model is unreachable, OpenClaw fails visibly instead of falling through to another configured model. -- Changing `agents.defaults.model.primary` does not rewrite existing session selections. If status says `This session is pinned to X; config primary Y will apply to new/unpinned sessions.`, switch the current session with `/model Y` or clear stale session state with `/reset`. +- Changing `agents.defaults.model.primary` does not rewrite existing session selections. If status says `This session is pinned to X; config primary Y will apply to new/unpinned sessions.`, clear the current session selection with `/model default` so it inherits the configured primary again. - Cron `--model` / payload `model` is a per-job primary. It still uses configured fallbacks unless the job supplies explicit payload `fallbacks` (use `fallbacks: []` for a strict cron run). - CLI default-model and allowlist pickers respect `models.mode: "replace"` by listing explicit `models.providers.*.models` instead of loading the full built-in catalog. - The Control UI model picker asks the Gateway for its configured model view: `agents.defaults.models` when present, including provider-wide `provider/*` entries, otherwise explicit `models.providers.*.models` plus providers with usable auth. The full built-in catalog is reserved for explicit browse views such as `models.list` with `view: "all"` or `openclaw models list --all`. @@ -188,6 +188,7 @@ You can switch models for the current session without restarting: /model list /model 3 /model openai/gpt-5.4 +/model default /model status ``` @@ -205,6 +206,7 @@ You can switch models for the current session without restarting: - If the agent is idle, the next run uses the new model right away. - If a run is already active, OpenClaw marks a live switch as pending and only restarts into the new model at a clean retry point. - If tool activity or reply output has already started, the pending switch can stay queued until a later retry opportunity or the next user turn. + - `/model default` clears the session selection and returns the session to the configured default model. - A user-selected `/model` ref is strict for that session: if the selected provider/model is unreachable, the reply fails visibly instead of silently answering from `agents.defaults.model.fallbacks`. This is different from configured defaults and cron job primaries, which can still use fallback chains. - `/model status` is the detailed view (auth candidates and, when configured, provider endpoint `baseUrl` + `api` mode). diff --git a/docs/concepts/openclaw-sdk.md b/docs/concepts/openclaw-sdk.md deleted file mode 100644 index a41ad277a048..000000000000 --- a/docs/concepts/openclaw-sdk.md +++ /dev/null @@ -1,323 +0,0 @@ ---- -summary: "Public OpenClaw App SDK for external apps, scripts, dashboards, CI jobs, and IDE extensions" -title: "OpenClaw App SDK" -sidebarTitle: "App SDK" -read_when: - - You are building an external app, script, dashboard, CI job, or IDE extension that talks to OpenClaw - - You are choosing between the App SDK and the Plugin SDK - - You are integrating with Gateway agent runs, sessions, events, approvals, models, or tools ---- - -The **OpenClaw App SDK** is the public client API for apps outside the -OpenClaw process. Use `@openclaw/sdk` when a script, dashboard, CI job, IDE -extension, or other external app wants to connect to the Gateway, start agent -runs, stream events, wait for results, cancel work, or inspect Gateway -resources. - - - The App SDK is different from the [Plugin SDK](/plugins/sdk-overview). - `@openclaw/sdk` talks to the Gateway from outside OpenClaw. - `openclaw/plugin-sdk/*` is only for plugins that run inside OpenClaw and - register providers, channels, tools, hooks, or trusted runtimes. - - -## What ships today - -`@openclaw/sdk` ships with: - -| Surface | Status | What it does | -| ------------------------- | ------- | --------------------------------------------------------------------------------- | -| `OpenClaw` | Ready | Main client entry point. Owns transport, connection, requests, and events. | -| `GatewayClientTransport` | Ready | WebSocket transport backed by the Gateway client. | -| `oc.agents` | Ready | Lists, creates, updates, deletes, and gets agent handles. | -| `Agent.run()` | Ready | Starts a Gateway `agent` run and returns a `Run`. | -| `oc.runs` | Ready | Creates, gets, waits for, cancels, and streams runs. | -| `Run.events()` | Ready | Streams normalized per-run events with replay for fast runs. | -| `Run.wait()` | Ready | Calls `agent.wait` and returns a stable `RunResult`. | -| `Run.cancel()` | Ready | Calls `sessions.abort` by run id, with session key when available. | -| `oc.sessions` | Ready | Creates, resolves, sends to, patches, compacts, and gets session handles. | -| `Session.send()` | Ready | Calls `sessions.send` and returns a `Run`. | -| `oc.tasks` | Ready | Lists, reads, and cancels Gateway task ledger entries. | -| `oc.models` | Ready | Calls `models.list` and the current `models.authStatus` status RPC. | -| `oc.tools` | Ready | Lists, scopes, and invokes Gateway tools through the policy pipeline. | -| `oc.artifacts` | Ready | Lists, gets, and downloads Gateway transcript artifacts. | -| `oc.approvals` | Ready | Lists and resolves exec approvals through Gateway approval RPCs. | -| `oc.environments` | Partial | Lists Gateway-local and node environment candidates; create/delete are not wired. | -| `oc.rawEvents()` | Ready | Exposes raw Gateway events for advanced consumers. | -| `normalizeGatewayEvent()` | Ready | Converts raw Gateway events into the stable SDK event shape. | - -The SDK also exports the core types used by those surfaces: -`AgentRunParams`, `RunResult`, `RunStatus`, `OpenClawEvent`, -`OpenClawEventType`, `GatewayEvent`, `OpenClawTransport`, -`GatewayRequestOptions`, `SessionCreateParams`, `SessionSendParams`, -`ArtifactSummary`, `ArtifactQuery`, `ArtifactsListResult`, -`ArtifactsGetResult`, `ArtifactsDownloadResult`, -`TaskSummary`, `TaskStatus`, `TasksListParams`, `TasksListResult`, -`TasksGetResult`, `TasksCancelResult`, `RuntimeSelection`, -`EnvironmentSelection`, `WorkspaceSelection`, `ApprovalMode`, and related -result types. - -## Connect to a Gateway - -Create a client with an explicit Gateway URL, or inject a custom transport for -tests and embedded app runtimes. - -```typescript -import { OpenClaw } from "@openclaw/sdk"; - -const oc = new OpenClaw({ - url: "ws://127.0.0.1:18789", - token: process.env.OPENCLAW_GATEWAY_TOKEN, - requestTimeoutMs: 30_000, -}); - -await oc.connect(); -``` - -`new OpenClaw({ gateway: "ws://..." })` is equivalent to `url`. The -`gateway: "auto"` option is accepted by the constructor, but automatic Gateway -discovery is not a separate SDK feature yet; pass `url` when the app does not -already know how to discover the Gateway. - -For tests, pass an object that implements `OpenClawTransport`: - -```typescript -const oc = new OpenClaw({ - transport: { - async request(method, params) { - return { method, params }; - }, - async *events() {}, - }, -}); -``` - -## Run an agent - -Use `oc.agents.get(id)` when the app wants an agent handle, then call -`agent.run()`. - -```typescript -const agent = await oc.agents.get("main"); - -const run = await agent.run({ - input: "Review this pull request and suggest the smallest safe fix.", - model: "openai/gpt-5.5", - sessionKey: "main", - timeoutMs: 30_000, -}); - -for await (const event of run.events()) { - const data = event.data as { delta?: unknown }; - if (event.type === "assistant.delta" && typeof data.delta === "string") { - process.stdout.write(data.delta); - } -} - -const result = await run.wait({ timeoutMs: 120_000 }); -console.log(result.status); -``` - -Provider-qualified model refs such as `openai/gpt-5.5` are split into Gateway -`provider` and `model` overrides. `timeoutMs` stays milliseconds in the SDK and -is converted to Gateway timeout seconds for the `agent` RPC. - -`run.wait()` uses the Gateway `agent.wait` RPC. A wait deadline that expires -while the run is still active returns `status: "accepted"` instead of pretending -the run itself timed out. Runtime timeouts, aborted runs, and cancelled runs are -normalized into `timed_out` or `cancelled`. - -## Create and reuse sessions - -Use sessions when the app wants durable transcript state. - -```typescript -const session = await oc.sessions.create({ - agentId: "main", - label: "release-review", -}); - -const run = await session.send("Prepare release notes from the current diff."); -await run.wait(); -``` - -`Session.send()` calls `sessions.send` and returns a `Run`. Session handles also -support: - -```typescript -await session.abort(run.id); -await session.patch({ label: "renamed-session" }); -await session.compact({ maxLines: 200 }); -``` - -## Stream events - -The SDK normalizes raw Gateway events into a stable `OpenClawEvent` envelope: - -```typescript -type OpenClawEvent = { - version: 1; - id: string; - ts: number; - type: OpenClawEventType; - runId?: string; - sessionId?: string; - sessionKey?: string; - taskId?: string; - agentId?: string; - data: unknown; - raw?: GatewayEvent; -}; -``` - -Common event types include: - -| Event type | Source Gateway event | -| --------------------- | ------------------------------------------- | -| `run.started` | `agent` lifecycle start | -| `run.completed` | `agent` lifecycle end | -| `run.failed` | `agent` lifecycle error | -| `run.cancelled` | Aborted/cancelled lifecycle end | -| `run.timed_out` | Timeout lifecycle end | -| `assistant.delta` | Assistant streaming delta | -| `assistant.message` | Assistant message | -| `thinking.delta` | Thinking or plan stream | -| `tool.call.started` | Tool/item/command start | -| `tool.call.delta` | Tool/item/command update | -| `tool.call.completed` | Tool/item/command completion | -| `tool.call.failed` | Tool/item/command failure or blocked status | -| `approval.requested` | Exec or plugin approval request | -| `approval.resolved` | Exec or plugin approval resolution | -| `session.created` | `sessions.changed` create | -| `session.updated` | `sessions.changed` update | -| `session.compacted` | `sessions.changed` compaction | -| `task.updated` | Task update events | -| `artifact.updated` | Patch stream events | -| `raw` | Any event without a stable SDK mapping yet | - -`Run.events()` filters events to one run id and replays already-seen events for -fast runs. That means the documented flow is safe: - -```typescript -const run = await agent.run("Summarize the latest session."); - -for await (const event of run.events()) { - if (event.type === "run.completed") { - break; - } -} -``` - -For app-wide streams, use `oc.events()`. For raw Gateway frames, use -`oc.rawEvents()`. - -## Models, tools, artifacts, and approvals - -Model helpers map to current Gateway methods: - -```typescript -await oc.models.list(); -await oc.models.status({ probe: false }); // calls models.authStatus -``` - -Tool helpers expose the Gateway catalog, effective tool view, and direct -Gateway tool invocation. `oc.tools.invoke()` returns a typed envelope instead -of throwing for policy or approval refusals. - -```typescript -await oc.tools.list(); -await oc.tools.effective({ sessionKey: "main" }); -await oc.tools.invoke("tool-name", { - args: { input: "value" }, - sessionKey: "main", - confirm: false, - idempotencyKey: "tool-call-1", -}); -``` - -Artifact helpers expose the Gateway artifact projection for session, run, or -task context. Each call requires one explicit `sessionKey`, `runId`, or -`taskId` scope: - -```typescript -const { artifacts } = await oc.artifacts.list({ sessionKey: "main" }); -const first = artifacts[0]; - -if (first) { - const { artifact } = await oc.artifacts.get(first.id, { sessionKey: "main" }); - const download = await oc.artifacts.download(artifact.id, { sessionKey: "main" }); - console.log(download.encoding, download.url); -} -``` - -Approval helpers use the exec approval RPCs: - -```typescript -const approvals = await oc.approvals.list(); -await oc.approvals.respond("approval-id", { decision: "approve" }); -``` - -Task helpers use the durable task ledger that also backs `openclaw tasks`: - -```typescript -const tasks = await oc.tasks.list({ status: "running", sessionKey: "agent:main:main" }); -const task = await oc.tasks.get(tasks.tasks[0].id); -await oc.tasks.cancel(task.task.id, { reason: "user stopped task" }); -``` - -Environment helpers expose read-only Gateway-local and node discovery: - -```typescript -const { environments } = await oc.environments.list(); -await oc.environments.status(environments[0].id); -``` - -## Explicitly unsupported today - -The SDK includes names for the product model we want, but it does not silently -pretend Gateway RPCs exist. These calls currently throw explicit unsupported -errors: - -```typescript -await oc.environments.create({}); -await oc.environments.delete("environment-id"); -``` - -Per-run `workspace`, `runtime`, `environment`, and `approvals` fields are typed -as future shape, but the current Gateway does not support those overrides on -the `agent` RPC. If callers pass them, the SDK throws before submitting the run -so work does not accidentally execute with default workspace, runtime, -environment, or approval behavior. - -## App SDK vs Plugin SDK - -Use the App SDK when code lives outside OpenClaw: - -- Node scripts that start or observe agent runs -- CI jobs that call a Gateway -- dashboards and admin panels -- IDE extensions -- external bridges that do not need to become channel plugins -- integration tests with fake or real Gateway transports - -Use the Plugin SDK when code runs inside OpenClaw: - -- provider plugins -- channel plugins -- tool or lifecycle hooks -- agent harness plugins -- trusted runtime helpers - -App SDK code should import from `@openclaw/sdk`. Plugin code should import from -documented `openclaw/plugin-sdk/*` subpaths. Do not mix the two contracts. - -## Related - -- [OpenClaw App SDK API design](/reference/openclaw-sdk-api-design) -- [Gateway RPC reference](/reference/rpc) -- [Agent loop](/concepts/agent-loop) -- [Agent runtimes](/concepts/agent-runtimes) -- [Sessions](/concepts/session) -- [Background tasks](/automation/tasks) -- [ACP agents](/tools/acp-agents) -- [Plugin SDK overview](/plugins/sdk-overview) diff --git a/docs/concepts/personal-agent-benchmark-pack.md b/docs/concepts/personal-agent-benchmark-pack.md index ee78313eff2c..fd5b7ca31fc2 100644 --- a/docs/concepts/personal-agent-benchmark-pack.md +++ b/docs/concepts/personal-agent-benchmark-pack.md @@ -11,7 +11,7 @@ The Personal Agent Benchmark Pack is a small repo-backed QA scenario pack for local personal assistant workflows. It is not a generic model benchmark and it does not require a new runner. The pack reuses the private QA stack described in [QA overview](/concepts/qa-e2e-automation), the synthetic -[QA channel](/channels/qa-channel), and the existing `qa/scenarios` markdown +[QA channel](/channels/qa-channel), and the existing `qa/scenarios` YAML catalog. The first pack is intentionally narrow: @@ -61,9 +61,9 @@ to inspect and file in issues. ## Extending The Pack -Add new cases under `qa/scenarios/personal/`, then add the scenario id to -`QA_PERSONAL_AGENT_SCENARIO_IDS`. Keep each case small, local, deterministic in -`mock-openai`, and focused on one personal assistant behavior. +Add new `.yaml` cases under `qa/scenarios/personal/`, then add the scenario id +to `QA_PERSONAL_AGENT_SCENARIO_IDS`. Keep each case small, local, deterministic +in `mock-openai`, and focused on one personal assistant behavior. Good follow-up candidates: diff --git a/docs/concepts/qa-e2e-automation.md b/docs/concepts/qa-e2e-automation.md index c203838194c0..00c2dbda918a 100644 --- a/docs/concepts/qa-e2e-automation.md +++ b/docs/concepts/qa-e2e-automation.md @@ -31,9 +31,9 @@ script aliases; both forms are supported. | Command | Purpose | | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `qa run` | Bundled QA self-check; writes a Markdown report. | +| `qa run` | Bundled QA self-check without `--qa-profile`; taxonomy-backed maturity profile runner with `--qa-profile smoke-ci` or `--qa-profile release`. | | `qa suite` | Run repo-backed scenarios against the QA gateway lane. Aliases: `pnpm openclaw qa suite --runner multipass` for a disposable Linux VM. | -| `qa coverage` | Print the markdown scenario-coverage inventory (`--json` for machine output). | +| `qa coverage` | Print the YAML scenario-coverage inventory (`--json` for machine output). | | `qa parity-report` | Compare two `qa-suite-summary.json` files and write the agentic parity report, or use `--runtime-axis --token-efficiency` to write Codex-vs-OpenClaw runtime parity and token-efficiency reports from one runtime-pair summary. | | `qa character-eval` | Run the character QA scenario across multiple live models with a judged report. See [Reporting](#reporting). | | `qa manual` | Run a one-off prompt against the selected provider/model lane. | @@ -48,8 +48,33 @@ script aliases; both forms are supported. | `qa telegram` | Live transport lane against a real private Telegram group. | | `qa discord` | Live transport lane against a real private Discord guild channel. | | `qa slack` | Live transport lane against a real private Slack channel. | +| `qa whatsapp` | Live transport lane against real WhatsApp Web accounts. | | `qa mantis` | Before and after verification runner for live transport bugs, with Discord status-reactions evidence, Crabbox desktop/browser smoke, and Slack-in-VNC smoke. See [Mantis](/concepts/mantis) and [Mantis Slack Desktop Runbook](/concepts/mantis-slack-desktop-runbook). | +Profile-backed `qa run` reads membership from `taxonomy.yaml`, then dispatches +the resolved scenarios through `qa suite`. `--surface` and +`--category` filter the selected profile instead of defining separate lanes. +The resulting `qa-evidence.json` includes a profile scorecard summary with +selected-category counts and missing coverage IDs; the individual evidence +entries remain the source of truth for the tests, coverage roles, artifacts, +and results: + +```bash +pnpm openclaw qa run \ + --qa-profile smoke-ci \ + --category agent-runtime-and-provider-execution.agent-turn-execution \ + --provider-mode mock-openai \ + --output-dir .artifacts/qa-e2e/smoke-ci-profile-dispatch +``` + +Use `smoke-ci` for deterministic no-live-service proof and `release` for the +Stable/LTS proof lane. When a command also needs an OpenClaw root profile, put +the root profile before the QA command: + +```bash +pnpm openclaw --profile work qa run --qa-profile smoke-ci +``` + ## Operator flow The current QA operator flow is a two-pane QA site: @@ -168,15 +193,16 @@ decision still comes from the Discord REST oracle. CI uses the same command surface in `.github/workflows/qa-live-transports-convex.yml`. Scheduled and default manual runs execute the fast Matrix profile with live frontier credentials, `--fast`, and `OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS=3000`. Manual `matrix_profile=all` fans out into the five profile shards so the exhaustive catalog can run in parallel while keeping one artifact directory per shard. -For transport-real Telegram, Discord, and Slack smoke lanes: +For transport-real Telegram, Discord, Slack, and WhatsApp smoke lanes: ```bash pnpm openclaw qa telegram pnpm openclaw qa discord pnpm openclaw qa slack +pnpm openclaw qa whatsapp ``` -They target a pre-existing real channel with two bots (driver + SUT). Required env vars, scenario lists, output artifacts, and the Convex credential pool are documented in [Telegram, Discord, and Slack QA reference](#telegram-discord-and-slack-qa-reference) below. +They target a pre-existing real channel with two bots or accounts (driver + SUT). Required env vars, scenario lists, output artifacts, and the Convex credential pool are documented in [Telegram, Discord, Slack, and WhatsApp QA reference](#telegram-discord-slack-and-whatsapp-qa-reference) below. For a full Slack desktop VM run with VNC rescue, run: @@ -276,10 +302,10 @@ coverage helpers, and scenario-selection helper from | Telegram | x | x | x | | | | | | | x | | | Discord | x | x | x | | | | | | | | x | | Slack | x | x | x | x | x | x | x | x | | | | +| WhatsApp | x | x | | x | x | x | | | x | x | | This keeps `qa-channel` as the broad product-behavior suite while Matrix, -Telegram, and future live transports share one explicit transport-contract -checklist. +Telegram, and other live transports share one explicit transport-contract checklist. For a disposable Linux VM lane without bringing Docker into the QA path, run: @@ -308,25 +334,25 @@ guest: env-based provider keys, the QA live provider config path, and `CODEX_HOME` when present. Keep `--output-dir` under the repo root so the guest can write back through the mounted workspace. -## Telegram, Discord, and Slack QA reference +## Telegram, Discord, Slack, and WhatsApp QA reference -Matrix has a [dedicated page](/concepts/qa-matrix) because of its scenario count and Docker-backed homeserver provisioning. Telegram, Discord, and Slack are smaller - a handful of scenarios each, no profile system, against pre-existing real channels - so their reference lives here. +Matrix has a [dedicated page](/concepts/qa-matrix) because of its scenario count and Docker-backed homeserver provisioning. Telegram, Discord, Slack, and WhatsApp run against pre-existing real transports, so their reference lives here. ### Shared CLI flags These lanes register through `extensions/qa-lab/src/live-transports/shared/live-transport-cli.ts` and accept the same flags: -| Flag | Default | Description | -| ------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -| `--scenario ` | - | Run only this scenario. Repeatable. | -| `--output-dir ` | `/.artifacts/qa-e2e/{telegram,discord,slack}-` | Where reports/summary/observed messages and the output log are written. Relative paths resolve against `--repo-root`. | -| `--repo-root ` | `process.cwd()` | Repository root when invoking from a neutral cwd. | -| `--sut-account ` | `sut` | Temporary account id inside the QA gateway config. | -| `--provider-mode ` | `live-frontier` | `mock-openai` or `live-frontier` (legacy `live-openai` still works). | -| `--model ` / `--alt-model ` | provider default | Primary/alternate model refs. | -| `--fast` | off | Provider fast mode where supported. | -| `--credential-source ` | `env` | See [Convex credential pool](#convex-credential-pool). | -| `--credential-role ` | `ci` in CI, `maintainer` otherwise | Role used when `--credential-source convex`. | +| Flag | Default | Description | +| ------------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `--scenario ` | - | Run only this scenario. Repeatable. | +| `--output-dir ` | `/.artifacts/qa-e2e/-` | Where reports, summaries, evidence, transport-specific artifacts, and the output log are written. Relative paths resolve against `--repo-root`. | +| `--repo-root ` | `process.cwd()` | Repository root when invoking from a neutral cwd. | +| `--sut-account ` | `sut` | Temporary account id inside the QA gateway config. | +| `--provider-mode ` | `live-frontier` | `mock-openai` or `live-frontier` (legacy `live-openai` still works). | +| `--model ` / `--alt-model ` | provider default | Primary/alternate model refs. | +| `--fast` | off | Provider fast mode where supported. | +| `--credential-source ` | `env` | See [Convex credential pool](#convex-credential-pool). | +| `--credential-role ` | `ci` in CI, `maintainer` otherwise | Role used when `--credential-source convex`. | Each lane exits non-zero on any failed scenario. `--allow-failures` writes artifacts without setting a failing exit code. @@ -344,10 +370,6 @@ Required env when `--credential-source env`: - `OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN` - `OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN` -Optional: - -- `OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT=1` keeps message bodies in observed-message artifacts (default redacts). - Scenarios (`extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts`): - `telegram-canary` @@ -372,27 +394,27 @@ The implicit default set always covers canary, mention gating, native command re Output artifacts: - `telegram-qa-report.md` -- `telegram-qa-summary.json` - includes per-reply RTT (driver send → observed SUT reply) starting with the canary. -- `telegram-qa-observed-messages.json` - bodies redacted unless `OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT=1`. +- `qa-evidence.json` - evidence entries for the live transport checks, including profile, coverage, provider, channel, artifacts, result, and RTT fields. -Package RTT comparison uses the same Telegram credential contract while keeping -its RTT sample controls on the RTT harness path: +Package Telegram runs use the same Telegram credential contract. Repeated RTT +measurement is part of the normal package Telegram live lane; the RTT +distribution is folded into `qa-evidence.json` under `result.timing` for the +selected RTT check. ```bash -pnpm rtt openclaw@beta \ - --credential-source convex \ - --credential-role maintainer \ - --samples 20 \ - --sample-timeout-ms 30000 +OPENCLAW_QA_CREDENTIAL_SOURCE=convex \ +pnpm test:docker:npm-telegram-live ``` -When `--credential-source convex` is set, the RTT Docker wrapper leases a -`kind: "telegram"` credential, exports the leased group/driver/SUT bot env into -the installed-package run, heartbeats the lease, and releases it on shutdown. -`--samples` and `--sample-timeout-ms` still feed -`OPENCLAW_NPM_TELEGRAM_WARM_SAMPLES` and -`OPENCLAW_NPM_TELEGRAM_SAMPLE_TIMEOUT_MS`, so `result.json` remains comparable -across env-backed and Convex-backed RTT runs. +When `OPENCLAW_QA_CREDENTIAL_SOURCE=convex` is set, the package live wrapper +leases a `kind: "telegram"` credential, exports the leased group/driver/SUT bot +env into the installed-package run, heartbeats the lease, and releases it on +shutdown. The package wrapper defaults to 20 RTT checks of +`telegram-mentioned-message-reply`, a 30s RTT timeout, and Convex role +`maintainer` outside CI when Convex is selected. Override +`OPENCLAW_NPM_TELEGRAM_RTT_SAMPLES`, `OPENCLAW_NPM_TELEGRAM_RTT_TIMEOUT_MS`, +or `OPENCLAW_NPM_TELEGRAM_RTT_MAX_FAILURES` to tune RTT measurement without +creating a separate RTT command or Telegram-specific summary format. ### Discord QA @@ -445,7 +467,7 @@ pnpm openclaw qa discord \ Output artifacts: - `discord-qa-report.md` -- `discord-qa-summary.json` +- `qa-evidence.json` - evidence entries for the live transport checks. - `discord-qa-observed-messages.json` - bodies redacted unless `OPENCLAW_QA_DISCORD_CAPTURE_CONTENT=1`. - `discord-qa-reaction-timelines.json` and `discord-status-reactions-tool-only-timeline.png` when the status-reaction scenario runs. @@ -493,7 +515,7 @@ Scenarios (`extensions/qa-lab/src/live-transports/slack/slack-live.runtime.ts`): Output artifacts: - `slack-qa-report.md` -- `slack-qa-summary.json` +- `qa-evidence.json` - evidence entries for the live transport checks. - `slack-qa-observed-messages.json` - bodies redacted unless `OPENCLAW_QA_SLACK_CAPTURE_CONTENT=1`. - `approval-checkpoints/` - only when Mantis sets `OPENCLAW_QA_SLACK_APPROVAL_CHECKPOINT_DIR`; contains checkpoint JSON, @@ -688,27 +710,57 @@ Required env when `--credential-source env`: Optional: -- `OPENCLAW_QA_WHATSAPP_GROUP_JID` enables `whatsapp-mention-gating`. +- `OPENCLAW_QA_WHATSAPP_GROUP_JID` enables group scenarios such as + `whatsapp-mention-gating` and `whatsapp-group-allowlist-block`. - `OPENCLAW_QA_WHATSAPP_CAPTURE_CONTENT=1` keeps message bodies in observed-message artifacts. -Scenarios (`extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.ts`): +Scenario catalog (`extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.ts`): -- `whatsapp-canary` -- `whatsapp-pairing-block` -- `whatsapp-mention-gating` -- `whatsapp-approval-exec-native` - opt-in native WhatsApp exec approval - scenario. Requests an exec approval through the gateway, verifies the - WhatsApp message has native reaction approval affordances, resolves it, and - verifies the resolved WhatsApp follow-up. -- `whatsapp-approval-plugin-native` - opt-in native WhatsApp plugin approval - scenario. Enables exec and plugin approval forwarding together, then verifies - the same pending/resolved native WhatsApp path. +- Baseline and group gating: `whatsapp-canary`, `whatsapp-pairing-block`, + `whatsapp-mention-gating`, `whatsapp-top-level-reply-shape`, + `whatsapp-restart-resume`, `whatsapp-group-allowlist-block`. +- Native commands: `whatsapp-help-command`, `whatsapp-status-command`, + `whatsapp-commands-command`, `whatsapp-tools-compact-command`, + `whatsapp-whoami-command`, `whatsapp-context-command`, + `whatsapp-native-new-command`. +- Reply and final-output behavior: `whatsapp-tool-only-usage-footer`, + `whatsapp-reply-to-message`, `whatsapp-reply-context-isolation`, + `whatsapp-reply-delivery-shape`, `whatsapp-stream-final-message-accounting`. +- Inbound media and structured messages: `whatsapp-inbound-image-caption`, + `whatsapp-audio-preflight`, `whatsapp-inbound-structured-messages`, + `whatsapp-group-audio-gating`. These send real WhatsApp image, audio, + document, location, contact, and sticker events through the driver. +- Outbound Gateway and message action coverage: + `whatsapp-outbound-media-matrix`, + `whatsapp-outbound-document-preserves-filename`, `whatsapp-outbound-poll`, + `whatsapp-message-actions`. +- Access-control coverage: `whatsapp-access-control-dm-open`, + `whatsapp-access-control-dm-disabled`, `whatsapp-access-control-group-open`, + `whatsapp-access-control-group-disabled`, `whatsapp-group-allowlist-block`. +- Native approvals: `whatsapp-approval-exec-deny-native`, + `whatsapp-approval-exec-native`, `whatsapp-approval-exec-reaction-native`, + `whatsapp-approval-plugin-native`. +- Status reactions: `whatsapp-status-reactions`. + +The catalog currently contains 35 scenarios. The `live-frontier` default lane is +kept small at 8 scenarios for fast smoke coverage. The `mock-openai` default +lane runs 29 deterministic scenarios through the real WhatsApp transport while +mocking only model output. Approval scenarios and a few heavier/blocking checks +remain explicit by scenario id. + +The WhatsApp QA driver observes structured live events (`text`, `media`, +`location`, `reaction`, and `poll`) and can actively send media, polls, +contacts, locations, and stickers. QA Lab imports that driver through the +`@openclaw/whatsapp/api.js` package surface instead of reaching into private +WhatsApp runtime files. Message content is redacted by default. Outbound +poll and upload-file coverage run through deterministic gateway `poll` and +`message.action` calls instead of model-prompt-only tool invocation. Output artifacts: - `whatsapp-qa-report.md` -- `whatsapp-qa-summary.json` +- `qa-evidence.json` - evidence entries for the live transport checks. - `whatsapp-qa-observed-messages.json` - bodies redacted unless `OPENCLAW_QA_WHATSAPP_CAPTURE_CONTENT=1`. ### Convex credential pool @@ -741,24 +793,26 @@ Operational env vars and the Convex broker endpoint contract live in [Testing Seed assets live in `qa/`: -- `qa/scenarios/index.md` -- `qa/scenarios//*.md` +- `qa/scenarios/index.yaml` +- `qa/scenarios//*.yaml` These are intentionally in git so the QA plan is visible to both humans and the agent. -`qa-lab` should stay a generic markdown runner. Each scenario markdown file is +`qa-lab` should stay a generic YAML scenario runner. Each scenario YAML file is the source of truth for one test run and should define: -- scenario metadata -- optional category, capability, lane, and risk metadata -- docs and code refs -- optional plugin requirements -- optional gateway config patch -- the executable `qa-flow` +- top-level `title` +- `scenario` metadata +- optional category, capability, lane, and risk metadata in `scenario` +- docs and code refs in `scenario` +- optional plugin requirements in `scenario` +- optional gateway config patch in `scenario` +- executable top-level `flow` for flow scenarios, or `scenario.execution.kind` / + `scenario.execution.path` for Vitest and Playwright scenarios -The reusable runtime surface that backs `qa-flow` is allowed to stay generic -and cross-cutting. For example, markdown scenarios can combine transport-side +The reusable runtime surface that backs `flow` is allowed to stay generic +and cross-cutting. For example, YAML scenarios can combine transport-side helpers with browser-side helpers that drive the embedded Control UI through the Gateway `browser.request` seam without adding a special-case runner. @@ -796,17 +850,17 @@ provider names. ## Transport adapters -`qa-lab` owns a generic transport seam for markdown QA scenarios. `qa-channel` is the first adapter on that seam, but the design target is wider: future real or synthetic channels should plug into the same suite runner instead of adding a transport-specific QA runner. +`qa-lab` owns a generic transport seam for YAML QA scenarios. `qa-channel` is the first adapter on that seam, but the design target is wider: future real or synthetic channels should plug into the same suite runner instead of adding a transport-specific QA runner. At the architecture level, the split is: - `qa-lab` owns generic scenario execution, worker concurrency, artifact writing, and reporting. - The transport adapter owns gateway config, readiness, inbound and outbound observation, transport actions, and normalized transport state. -- Markdown scenario files under `qa/scenarios/` define the test run; `qa-lab` provides the reusable runtime surface that executes them. +- YAML scenario files under `qa/scenarios/` define the test run; `qa-lab` provides the reusable runtime surface that executes them. ### Adding a channel -Adding a channel to the markdown QA system requires exactly two things: +Adding a channel to the YAML QA system requires exactly two things: 1. A transport adapter for the channel. 2. A scenario pack that exercises the channel contract. @@ -840,7 +894,7 @@ The minimum adoption bar for a new channel: 2. Implement the transport runner on the shared `qa-lab` host seam. 3. Keep transport-specific mechanics inside the runner plugin or channel harness. 4. Mount the runner as `openclaw qa ` instead of registering a competing root command. Runner plugins should declare `qaRunners` in `openclaw.plugin.json` and export a matching `qaRunnerCliRegistrations` array from `runtime-api.ts`. Keep `runtime-api.ts` light; lazy CLI and runner execution should stay behind separate entrypoints. -5. Author or adapt markdown scenarios under the themed `qa/scenarios/` directories. +5. Author or adapt YAML scenarios under the themed `qa/scenarios/` directories. 6. Use the generic scenario helpers for new scenarios. 7. Keep existing compatibility aliases working unless the repo is doing an intentional migration. @@ -883,6 +937,13 @@ The report should answer: For the inventory of available scenarios - useful when sizing follow-up work or wiring a new transport - run `pnpm openclaw qa coverage` (add `--json` for machine-readable output). When choosing focused proof for a touched behavior or file path, run `pnpm openclaw qa coverage --match `. The match report searches scenario metadata, docs refs, code refs, coverage IDs, plugins, and provider requirements, then prints matching `qa suite --scenario ...` targets. +Every `qa suite` run writes top-level `qa-evidence.json`, +`qa-suite-summary.json`, and `qa-suite-report.md` artifacts for the selected +scenario set. Scenarios that declare `execution.kind: vitest` or +`execution.kind: playwright` run the matching test path and also write +per-scenario logs. When `qa suite` is reached through +`qa run --qa-profile`, the same `qa-evidence.json` also includes the profile +scorecard summary for the selected taxonomy categories. Treat it as a discovery aid, not a gate replacement; the selected scenario still needs the right provider mode, live transport, Multipass, Testbox, or release lane for the behavior under test. For character and style checks, run the same scenario across multiple live model diff --git a/docs/concepts/streaming.md b/docs/concepts/streaming.md index c7dcc5f488c4..2c84be73e578 100644 --- a/docs/concepts/streaming.md +++ b/docs/concepts/streaming.md @@ -161,6 +161,7 @@ Telegram: - Uses `sendMessage` + `editMessageText` preview updates across DMs and group/topics. - Final text edits the active preview in place; long finals reuse that message for the first chunk and send only the remaining chunks. +- `block` mode rotates the preview into a new message at `streaming.preview.chunk.maxChars` (default 800, capped at Telegram's 4096 edit limit); other modes grow one preview up to 4096 characters. - `progress` mode keeps tool progress in an editable status draft, clears that draft at completion, and sends the final answer through normal delivery. - If the final edit fails before the completed text is confirmed, OpenClaw uses normal final delivery and cleans up the stale preview. - Preview streaming is skipped when Telegram block streaming is explicitly enabled (to avoid double-streaming). diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index 4ec95b201089..1a3a7e21d691 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -255,10 +255,11 @@ See [Date & Time](/date-time) for full behavior details. ## Skills When eligible skills exist, OpenClaw injects a compact **available skills list** -(`formatSkillsForPrompt`) that includes the **file path** for each skill. The -prompt instructs the model to use `read` to load the SKILL.md at the listed -location (workspace, managed, or bundled). If no skills are eligible, the -Skills section is omitted. +(`formatSkillsForPrompt`) that includes the **file path** and content-derived +`` marker for each skill. The prompt instructs the model to use `read` +to load the SKILL.md at the listed location (workspace, managed, or bundled), +and to re-read a skill when its `` differs from a previous turn. If no +skills are eligible, the Skills section is omitted. Native Codex turns receive this list as turn-scoped collaboration developer instructions instead of per-turn user input, except lightweight cron turns that @@ -283,6 +284,7 @@ that guidance directly in every tool description. ... ... ... + sha256:... ``` diff --git a/docs/concepts/usage-tracking.md b/docs/concepts/usage-tracking.md index 72bb60e12c5c..6cdf9ff94447 100644 --- a/docs/concepts/usage-tracking.md +++ b/docs/concepts/usage-tracking.md @@ -30,6 +30,201 @@ title: "Usage tracking" - CLI: `openclaw channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip). - macOS menu bar: "Usage" section under Context (only if available). +## Custom `/usage full` footer + +`/usage full` shows a built-in compact footer with model, reasoning, fast/slow, +context window, turn tokens, cache, and cost when those fields are available. No +template file is required. + +`messages.usageTemplate` is only for advanced custom layouts. The value is a +JSON file path (supports `~`) or an inline object, and it replaces the built-in +footer when valid: + +```json +{ + "messages": { + "usageTemplate": "~/.openclaw/usage-footer.json" + } +} +``` + +Missing or empty templates fall back to the built-in footer quietly. Unreadable +or invalid configured templates also fall back to the built-in footer and emit an +operator warning. + +Start custom templates from the built-in shape, then edit the parts you want to +change: + +```jsonc +{ + "schema": "openclaw.usageBar.v1", + "scales": { + "braille": "⠐⡀⡄⡆⡇⣇⣧⣷⣿", + "block": "░▏▎▍▌▋▊▉█", + "shade": "░▒▓█", + "moon": "🌑🌘🌗🌖🌕", + "level": "▁▂▃▄▅▆▇█", + "weather": ["🥶", "☁️", "🌥", "⛅️", "🌤", "☀️"], + "plants": ["🪾", "🍂", "🌱", "☘️", "🍀", "🌿"], + "moons6": ["🌑", "🌚", "🌘", "🌗", "🌖", "🌝"], + }, + "aliases": { + "models": { + "claude-opus-4-6": "opus46", + "claude-opus-4-8": "opus48", + "claude-sonnet-4-6": "sonnet46", + "claude-haiku-4-5": "haiku45", + "gpt-5.5": "gpt5.5", + }, + "reasoning": { + "off": "🌑", + "minimal": "🌚", + "low": "🌘", + "medium": "🌗", + "high": "🌕", + "xhigh": "🌝", + }, + }, + "output": { + "sep": "", + "default": [ + { "text": "{model.provider}{identity.emoji|🤖} {model.display_name|alias:models}" }, + { "map": "model.is_fallback", "cases": { "true": " 🔄" } }, + { "map": "model.is_override", "cases": { "true": " 📌" } }, + { "when": "model.reasoning", "text": " {model.reasoning|alias:reasoning}" }, + { "map": "state.fast_mode", "cases": { "true": " ⚡", "false": " 🐌" } }, + { + "when": "context.max_tokens", + "text": " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}", + }, + { + "when": "usage.has_split_tokens", + "text": " ↕️ {usage.input_tokens|num|?}/{usage.output_tokens|num|?}", + }, + { "when": "usage.has_total_only_tokens", "text": " ↕️ {usage.total_tokens|num}" }, + { "when": "usage.cache_hit_pct", "text": " 🗄 {usage.cache_hit_pct|pct}" }, + { "when": "cost.turn_usd", "text": " 💰{cost.turn_usd|fixed:4}" }, + ], + "surfaces": { + "discord": [ + { "text": "-# -\n" }, + { "text": "-# {model.provider}{identity.emoji|🤖} {model.display_name|alias:models}" }, + { "map": "model.is_fallback", "cases": { "true": "🔄" } }, + { "map": "model.is_override", "cases": { "true": "📌" } }, + { "when": "model.reasoning", "text": " {model.reasoning|alias:reasoning}" }, + { "map": "state.fast_mode", "cases": { "true": " ⚡️", "false": " 🐌" } }, + { + "when": "context.max_tokens", + "text": " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}", + }, + { + "when": "usage.has_split_tokens", + "text": " ↕️ {usage.input_tokens|num|?}/{usage.output_tokens|num|?}", + }, + { "when": "usage.has_total_only_tokens", "text": " ↕️ {usage.total_tokens|num}" }, + { "when": "usage.cache_hit_pct", "text": " 🗄 {usage.cache_hit_pct|pct}" }, + { "when": "cost.turn_usd", "text": " 💰{cost.turn_usd|fixed:4}" }, + ], + }, + }, +} +``` + +### Shape + +```jsonc +{ + "schema": "openclaw.usageBar.v1", + "scales": { "": "low-to-high glyphs" }, // string (1 glyph/char) or array + "aliases": { "": { "": "