Compare commits

..

713 Commits

Author SHA1 Message Date
Peter Steinberger
538d36eaaa refactor: move session metadata to SQLite (#91322)
* refactor: move session metadata to sqlite

* test: seed session stores with sqlite fixtures

* test: seed remaining session stores with sqlite fixtures

* fix: stabilize sqlite session cache freshness

* test: seed cli transcript metadata in sqlite
2026-06-07 23:17:35 -07:00
mushuiyu_xydt
b2c1de77ac fix #90452: Regression: Heartbeat exec completion still shows generic fallback text instead of actual output (#90897)
Summary:
- The PR threads heartbeat trigger state into embedded-runner payload formatting so heartbeat exec-like failures include captured error details, with a focused regression test.
- PR surface: Source +12, Tests +18. Total +30 across 3 files.
- Reproducibility: yes. Source inspection shows current main and v2026.6.1 only include raw exec details for v ... follows the generic warning path; I did not run the wall-clock heartbeat scenario in this read-only review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix #90452: Regression: Heartbeat exec completion still shows generic…

Validation:
- ClawSweeper review passed for head 85c5d6fb9f.
- Required merge gates passed before the squash merge.

Prepared head SHA: 85c5d6fb9f
Review: https://github.com/openclaw/openclaw/pull/90897#issuecomment-4638158130

Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-08 05:47:53 +00:00
Yzx
ccb9f2ca2b fix(anthropic): drop reasoning_content replay signatures (#91231)
Summary:
- The PR filters persisted OpenAI-compatible `reasoning_content` thinking placeholders from direct Anthropic replay payloads and updates the focused Anthropic provider test.
- PR surface: Source +1, Tests -4. Total -3 across 2 files.
- Reproducibility: yes. from source: current main serializes `thinkingSignature: "reasoning_content"` as a nat ... rror. The PR body also provides after-fix captured outbound payload proof for the production provider path.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(anthropic): drop reasoning_content replay signatures

Validation:
- ClawSweeper review passed for head 6eaa72f3a3.
- Required merge gates passed before the squash merge.

Prepared head SHA: 6eaa72f3a3
Review: https://github.com/openclaw/openclaw/pull/91231#issuecomment-4643786130

Co-authored-by: 宇宙熊Yzx <53250620+849261680@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-08 05:45:48 +00:00
snowzlm
a4f0e508df fix(gateway): preserve stale channel restart diagnostics (#90937)
Summary:
- This PR sanitizes status patches from aborted channel tasks in the gateway manager and adds regression tests for stale restart diagnostics.
- PR surface: Source +56, Tests +78. Total +134 across 2 files.
- Reproducibility: yes. Source inspection and the PR's before-fix regression show the sequence: non-manual sto ... while the stale task remains, then a late `connected=true` / `lastError=null` status patch on current main.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(gateway): preserve stale restart diagnostics
- PR branch already contained follow-up commit before automerge: fix(gateway): preserve stale channel restart diagnostics

Validation:
- ClawSweeper review passed for head 53b37e5073.
- Required merge gates passed before the squash merge.

Prepared head SHA: 53b37e5073
Review: https://github.com/openclaw/openclaw/pull/90937#issuecomment-4638942823

Co-authored-by: snowzlm <snowzlm@noreply.codeberg.org>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-08 05:29:11 +00:00
Mariano
b8adc11977 feat(cron): support command jobs
Add command-backed cron jobs with timeout-safe process-tree cleanup for shell wrappers. Ensures POSIX command jobs run in a killable process group, adds Windows tree cleanup fallback handling, and covers timeout cleanup behind sh -lc.
2026-06-08 12:06:16 +09:00
Marcus Castro
181238fb53 feat(whatsapp): expand live QA coverage (#90480)
* feat(whatsapp): expand qa driver message support

* feat(qa-lab): add deterministic whatsapp mock replies

* feat(qa-lab): expand whatsapp live qa scenarios

* docs(qa): document whatsapp live qa coverage
2026-06-08 00:03:23 -03:00
Yzx
4780546c12 fix(cron): preserve isolated agent turn payload message (#91230)
Summary:
- The PR changes isolated cron agent prompt construction to read agentTurn text from `job.payload.message` and adds regression coverage for malformed dispatch messages plus SQLite-rehydrated manual runs.
- PR surface: Source +8, Tests +60. Total +68 across 3 files.
- Reproducibility: yes. source-level: current main interpolates `input.message` into the isolated cron prompt, ... release report supplies operator repro evidence; I did not run it locally because this review is read-only.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(cron): preserve isolated agent turn payload message

Validation:
- ClawSweeper review passed for head 4d33607efd.
- Required merge gates passed before the squash merge.

Prepared head SHA: 4d33607efd
Review: https://github.com/openclaw/openclaw/pull/91230#issuecomment-4643779241

Co-authored-by: 宇宙熊Yzx <53250620+849261680@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-08 02:23:02 +00:00
Yzx
766c5b3d32 fix(codex): preserve native subagent completion results (#91235)
Summary:
- The branch updates the Codex plugin native subagent parser, monitor, and tests so successful null or blank c ... final result and transcript reconciliation can override early empty notifications before fallback delivery.
- PR surface: Source +92, Tests +176. Total +268 across 4 files.
- Reproducibility: yes. at source level: current main maps successful null/blank Codex completions to `(no out ... n recover final text. I did not run a live current-main Telegram/Codex reproduction in this read-only pass.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(codex): preserve native subagent completion results

Validation:
- ClawSweeper review passed for head f9270c28e7.
- Required merge gates passed before the squash merge.

Prepared head SHA: f9270c28e7
Review: https://github.com/openclaw/openclaw/pull/91235#issuecomment-4643854708

Co-authored-by: 宇宙熊Yzx <53250620+849261680@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-08 02:22:05 +00:00
Omar Shahine
9caff5f873 fix(imessage): gate split-send coalescing on imsg balloon metadata with back-compat (#90858)
Gate iMessage same-sender DM split-send coalescing on imsg's structural
`balloon_bundle_id` URL-balloon marker (openclaw/imsg#137) instead of timing/
text-shape inference, with a session capability latch and a back-compat path:

- URL-balloon marker present -> merge (precise split-send).
- Build known to emit balloon metadata (session latch) -> keep non-marker
  buckets separate (the precision win).
- Build that never emits balloon metadata (older imsg) -> preserve the legacy
  unconditional merge, so split-send users do not regress to two turns.

Never merges more than shipped main already did. Verified live end-to-end: the
patched gateway, watching a real chat.db via an imsg #137 build, merged a real
iPhone-sent `Dump <url>` split-send into one turn. Client-side removal once imsg
coalesces upstream is tracked in #91243 (openclaw/imsg#141).

Closes #90795
2026-06-07 19:14:13 -07:00
Chunyue Wang
f2530de832 fix(agents): do not refresh lastUsedAt on MCP lease release (#91124)
Summary:
- The PR removes release-time `lastUsedAt` refresh from session MCP runtime lease cleanup and adds regression tests for idle eviction after a lease expires while active.
- PR surface: Source 0, Tests +74. Total +74 across 2 files.
- Reproducibility: yes. from source inspection: current main refreshes `lastUsedAt` in the release callback, a ...  timestamp after active leases drop to zero. I did not execute the focused Vitest in this read-only review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(agents): do not refresh lastUsedAt on MCP lease release

Validation:
- ClawSweeper review passed for head c9144789fd.
- Required merge gates passed before the squash merge.

Prepared head SHA: c9144789fd
Review: https://github.com/openclaw/openclaw/pull/91124#issuecomment-4641967555

Co-authored-by: openperf <16864032@qq.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-08 02:08:28 +00:00
Yzx
75c1790b50 fix(outbound): preserve retries for budget-deferred deliveries (#91241)
Summary:
- The branch removes the budget-deferred `failDelivery` path from outbound recovery and updates the `maxRecoveryMs` regression expectation so unattempted deliveries keep retry counts at zero.
- PR surface: Source -11, Tests -1. Total -12 across 2 files.
- Reproducibility: yes. at source level: current main reaches `failDelivery` from the exhausted recovery-budge ...  in this read-only review, but the PR body also supplies terminal output showing the after-fix queue state.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(outbound): preserve retries for budget-deferred deliveries

Validation:
- ClawSweeper review passed for head aff2b9d16e.
- Required merge gates passed before the squash merge.

Prepared head SHA: aff2b9d16e
Review: https://github.com/openclaw/openclaw/pull/91241#issuecomment-4644024479

Co-authored-by: 宇宙熊Yzx <53250620+849261680@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-08 01:43:02 +00:00
Vincent Koc
b16a43597d fix(agents): guard prompt cache tool names
Make prompt-cache observability collect diagnostic tool names through guarded descriptor reads so an unreadable tool-name getter cannot abort cache tracing/debug collection. Preserve readable trimmed names and keep runtime tool registration/schema behavior strict and unchanged.
2026-06-08 10:36:50 +09:00
Vincent Koc
8b03fd1f5f fix(agents): compact lean local tool catalogs
Default localModelLean runs to compact Tool Search controls when the operator has not configured tools.toolSearch, while preserving explicit Tool Search settings and direct message-tool delivery semantics.

Verification: local focused Vitest/docs/format/lint/diff/autoreview proof; GitHub CI, CodeQL/Security High, CodeQL Critical Quality, OpenGrep PR Diff, Real behavior proof, Dependency Guard, and Workflow Sanity passed on 6153fb5ecb.

Refs https://github.com/openclaw/openclaw/issues/86599
2026-06-08 10:33:41 +09:00
Vincent Koc
3ffb3609a1 fix(codex): quarantine unreadable dynamic tools (#90022) 2026-06-08 10:30:13 +09:00
joshavant
5c5391836b fix(android): remove inert appearance palette preview 2026-06-07 17:43:21 -05:00
Pavan Kumar Gondhi
2a21de6322 fix: gate owner-only HTTP tools (#90261)
* fix: gate owner-only HTTP tools

* fix: inherit HTTP owner tool denies

* fix: use mutable HTTP owner deny policy

* fix: preserve RPC owner tool access

* docs: clarify owner-only gateway tool allowlist

---------

Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-06-07 17:26:12 -05:00
Voscko
3c73ff7689 feat(android): add theme mode selection (#90752)
* feat(android): add theme mode selection

* refine Android theme mode handling

---------

Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-06-07 17:24:57 -05:00
Jason (Json)
57e0bdaabe feat: add live provider model catalog helper
Summary:
- Add a shared live provider catalog runtime for SDK-backed providers.
- Route OpenAI, xAI, OpenCode Go, Chutes, DeepInfra, Venice, NVIDIA, and Vercel AI Gateway live model discovery through the shared helper.
- Remove duplicated provider-local live catalog caching and harden auth marker stripping, empty live-result retries, and OpenAI custom-base-url handling.

Verification:
- node scripts/run-vitest.mjs extensions/openai/openai-provider.test.ts src/plugin-sdk/provider-catalog-live-runtime.test.ts src/commands/models/list.source-plan.test.ts extensions/opencode-go/index.test.ts extensions/nvidia/provider-catalog.test.ts
- pnpm plugin-sdk:api:check
- pnpm lint --threads=8
- pnpm run lint:extensions:bundled
- pnpm run test:extensions:package-boundary:compile
- pnpm check:import-cycles
- pnpm exec oxfmt --check extensions/openai/openai-provider.ts extensions/openai/openai-provider.test.ts
- git diff --check origin/main...HEAD
- autoreview clean: no accepted/actionable findings reported
- AWS Crabbox focused remote proof: run_364680d1bff8 / cbx_2456fffafe01
- Earlier same-PR AWS Crabbox live proof: run_1f05ccab368e / cbx_7375c79fcf9b

Known proof gap:
- Final current-code true live-provider smoke was blocked by Crabbox secret hydration, documented in the PR proof comment.
2026-06-07 14:16:00 -07:00
Omar Shahine
6c35c0d965 fix(imessage): self-explaining private-API failures and dedicated send timeout (#91041)
Append imsg's own status message (SIP / library validation / macOS 26 AMFI gate)
to iMessage private-API blocked-action errors so operators see the real blocker
instead of a generic "run imsg launch". Add a dedicated 150s default timeout for
iMessage send RPCs (explicit opts and probeTimeoutMs still win) so macOS 26
bridge stalls are not aborted mid-send.

Staged mitigation: the longer wait fully activates once the companion bridge
timeout (openclaw/imsg#139) ships; on current imsg the bridge still returns at
its own 10s, so there is no regression. Diagnostics half is live-proven; the
delayed-send timeout is covered by source + unit proof + maintainer waiver.
2026-06-07 14:07:31 -07:00
Peter Steinberger
af79cd6a9d fix: preserve live Ollama catalog metadata 2026-06-07 14:00:09 -07:00
brokemac79
3b6bcbfb50 fix: make sandbox skills readable in writable sandboxes
Materializes prompt-visible skills into a protected sandbox-readable workspace for rw sandboxes, refreshes Docker/SSH/OpenShell views, and hardens stale or poisoned remote skill copies. Fixes #90410.
2026-06-07 13:47:56 -07:00
clawsweeper[bot]
e498d39bed fix(agents): prevent ReDoS in background-session name derivation (#91233)
Summary:
- The PR updates background-session command tokenization to avoid catastrophic regex backtracking and adds `deriveSessionName` regression tests for quoted and backslash-heavy commands.
- PR surface: Source 0, Tests +26. Total +26 across 2 files.
- Reproducibility: yes. with high confidence from source inspection and supplied terminal proof: current `main ...  shows before/after timing for the production helper. I did not run tests because this review is read-only.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(agents): treat backslash as literal inside single-quoted session …
- PR branch already contained follow-up commit before automerge: fix(agents): prevent ReDoS in background-session name derivation

Validation:
- ClawSweeper review passed for head 0a38952fc8.
- Required merge gates passed before the squash merge.

Prepared head SHA: 0a38952fc8
Review: https://github.com/openclaw/openclaw/pull/91233#issuecomment-4643821335

Co-authored-by: yetval <yetvald@gmail.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-07 20:30:56 +00:00
Shakker
0c33f4e078 fix: stabilize docker stats heartbeat test 2026-06-07 19:06:24 +01:00
Nimrod Gutman
47dbc675e9 feat(ios): clarify talk realtime fallback (#91201)
Merged via squash.

Prepared head SHA: b6fd32ed6e

Local prep note: pnpm build passed. pnpm check hit the npm shrinkwrap guard because @anthropic-ai/sdk@0.100.1 is no longer resolvable before 2026-05-24T20:18:43Z; the same shrinkwrap guard failure reproduces on current origin/main at 66b91d78fe, and this PR does not touch dependency manifests or lockfiles.

Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Reviewed-by: @ngutman
2026-06-07 20:21:34 +03:00
Vincent Koc
66b91d78fe fix(e2e): bound release user journey JSON artifacts 2026-06-07 12:45:43 +02:00
zenglingbiao
3753c5e2c8 fix(inbound-meta): preserve reply-context body tails
Preserve actionable tail content in long reply-context bodies before they enter prompt JSON or inline reply context formatting.

- Apply UTF-16-safe head+tail truncation to ReplyChain JSON bodies and fallback ReplyToBody JSON blocks.
- Use the same body-aware truncation for Telegram inline ReplyToBody fallback and chat_window message bodies, so those paths cannot suppress the JSON fallback and still lose the tail.
- Adds regression coverage for ReplyChain, fallback ReplyToBody, Telegram inline ReplyToBody, chat_window reply targets, and emoji-heavy heads.

Verification:
- node scripts/run-vitest.mjs src/auto-reply/reply/inbound-meta.test.ts
- node_modules/.bin/oxfmt --check --threads=1 src/auto-reply/reply/inbound-meta.ts src/auto-reply/reply/inbound-meta.test.ts
- node scripts/run-oxlint.mjs src/auto-reply/reply/inbound-meta.ts src/auto-reply/reply/inbound-meta.test.ts
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- Testbox-through-Crabbox check:changed: provider=blacksmith-testbox id=tbx_01ktgthbb5xa9d5ap58h4134s0 exit=0
- PR CI: 158/158 completed, no failures, MERGEABLE/CLEAN

Fixes #91042
2026-06-07 19:45:00 +09:00
Vincent Koc
9bafa2a2b6 fix(e2e): bound release scenario JSON artifacts 2026-06-07 12:43:33 +02:00
Vincent Koc
1703fbc2ad fix(e2e): bound browser snapshot diagnostics 2026-06-07 12:39:48 +02:00
Chunyue Wang
afcbdd7416 fix(infra/agents): session-routing guard for coalesced gateway restart continuations (#86742) (#87323)
* fix(infra/agents): session-routing guard for coalesced gateway restart continuations (#86742)

When two sessions issue gateway.restart with continuationMessage close
together, the scheduler Path B updatePendingRestartEmitHooks
unconditionally overwrote the existing pending hooks, silently dropping
the first sessions continuation and potentially routing the second
sessions continuation back to the first session (CWE-200 finding
flagged by aisle-research-bot on prior attempt #74443).

Add a session-routing guard: scheduleGatewaySigusr1Restart now accepts
an optional sessionKey and tracks the pending restarts owning session.
Coalesced callers from a different session are rejected at the hook-
update step and the new ScheduledRestart.emitHooksQueued: false field
surfaces the drop to the caller. The gateway tool propagates this as
continuationQueued: false in the tool response, matching #83370 narrow
report-only surface.

Same-session debounce/replace and legacy hookless callers behave the
same as before.

Refs #86742

* fix(infra): preserve queued restart continuation on forced bypass

* fix(infra): make forced restart hook preservation explicit

* fix(infra): guard restart continuation ownership before reschedule

* fix(infra): report hookless coalesced restarts accurately

* fix(infra): trust runtime session for restart sentinel routing

* fix(infra): preserve earlier restart reschedule semantics

* fix(agents): trust runtime session for update continuations

* fix(infra): preserve hookless forced restart continuations

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-07 03:38:58 -07:00
Vincent Koc
2b43315933 fix(tooling): bound extension boundary source reads 2026-06-07 12:36:13 +02:00
Vincent Koc
f5935bbca1 fix(e2e): cancel timed out response reads 2026-06-07 12:32:56 +02:00
liuweiqin
a1af47e5da fix(codex): surface lastToolError on degraded orphan-tool delivery
Completed turns with deliverable assistant text still synthesize failed
tool.result rows but no longer set promptError. Record lastToolError on
that degraded path and treat whitespace-only assistant items as non-
deliverable so orphan tools still fail closed.

Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit cb6fbe36c73982e1043186b983f0c03334989b34)
2026-06-07 19:27:58 +09:00
weiqinl
ed3a0241f3 fix(codex): deliver assistant reply when orphan tool.call lacks result
Keep synthesizing failed tool.result records for transcript consistency,
but skip promptError when a completed Codex turn has deliverable assistant
text so channel users still receive the composed reply.

Fixes #91067

Co-authored-by: Cursor <cursoragent@cursor.com>

(cherry picked from commit ffac77ce811eab528bcce81eec99fd8bd6c70cca)
2026-06-07 19:27:58 +09:00
Vincent Koc
b00e1b2e7b fix(diagnostics): make memory pressure logs actionable
Log warning memory pressure at WARN level, add readable RSS/heap/threshold units, threshold ratio, and a concrete operator next step while preserving raw byte fields for diagnostics consumers.

Fixes #90783
2026-06-07 19:27:29 +09:00
Vincent Koc
cfe5d24889 fix(test): bound remaining child output collectors 2026-06-07 12:25:30 +02:00
Vincent Koc
bae607b9f1 fix(test): execute docker observability proof 2026-06-07 12:22:10 +02:00
Peter Steinberger
f08ee9eb54 fix(protocol): refresh generated send params 2026-06-07 03:21:26 -07:00
Jason Yao
d1cb6cd0b5 fix(media-understanding): preserve native vision skip with imageModel fallback
Fixes #91084

(cherry picked from commit 8aa5148338)
2026-06-07 19:19:27 +09:00
Vincent Koc
8291cfc2f4 fix(test): bound child output buffers 2026-06-07 12:19:08 +02:00
Vincent Koc
bf27221753 fix(tooling): bound source scan file reads 2026-06-07 12:14:09 +02:00
Vincent Koc
88c1af0a2c fix(tooling): bound generated formatter execution 2026-06-07 12:11:21 +02:00
Vincent Koc
6a0fdea90a fix(outbound): materialize buffer-only sends
Fixes #90768

Incorporates the send-buffer materialization shape proposed in #90794 by @LiuwqGit, with maintainer fixes for dry-run, gateway delivery, byte-cap, target-validation, and downstream plugin dispatch paths.
2026-06-07 19:09:49 +09:00
Vincent Koc
85840eb10e fix(dev): align gateway smoke auth contract 2026-06-07 12:07:05 +02:00
Vincent Koc
f7f2532cac test(agents): widen overflow model mock 2026-06-07 19:05:27 +09:00
Vincent Koc
e2524e0438 fix(ci): break plugin import cycles 2026-06-07 19:03:38 +09:00
兰之
58bab0c276 fix(agents): dispatch subagent spawn in process (#90612)
* fix(agents): dispatch subagent spawn in process

* docs: update subagent gateway dispatch note

* fix(gateway): keep in-process dispatch timeout budget

* test(gateway): avoid promise executor timer returns

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-07 03:01:51 -07:00
Vincent Koc
48da8d83d9 fix(e2e): bound parallels update logs 2026-06-07 12:00:57 +02:00
Vincent Koc
363c6923a1 fix(e2e): bound web search smoke logs 2026-06-07 11:57:04 +02:00
Vincent Koc
be617fdd62 fix(e2e): bound telegram docker logs 2026-06-07 11:54:47 +02:00
Vincent Koc
8dff529587 fix(e2e): bound corrupt update logs 2026-06-07 11:52:39 +02:00
Vincent Koc
901f963f62 fix(e2e): bound cleanup smoke logs 2026-06-07 11:50:33 +02:00
Vincent Koc
cdbf6d95ac fix(e2e): bound scenario client logs 2026-06-07 11:48:05 +02:00
Vincent Koc
5d7e0b73a7 fix(e2e): bound mcp client logs 2026-06-07 11:44:55 +02:00
Peter Steinberger
bab18d567b refactor(plugin-sdk): persist dedupe state in sqlite 2026-06-07 02:41:45 -07:00
Vincent Koc
a4e78aec4b fix(test): bound group report child output 2026-06-07 11:40:47 +02:00
Vincent Koc
0f855ea71a fix(e2e): require dashboard smoke assets 2026-06-07 11:38:25 +02:00
Vincent Koc
a7d5d92989 fix(e2e): require zai fallback evidence 2026-06-07 11:33:36 +02:00
Peter Steinberger
6f2b3830f1 fix(qqbot): migrate group tool policy config (#91128)
* fix(qqbot): migrate group tool policy config

* test: stabilize changed check lanes

* style: format changed main files

* test: align CI matrix expectations
2026-06-07 02:33:06 -07:00
clawsweeper[bot]
58b68e92f2 fix(outbound): keep Discord runtime adapters resolvable (#91119)
Summary:
- The branch changes outbound channel bootstrap and resolution so delivery paths prefer send-capable runtime a ...  avoid setup-only shells for runtime delivery, retry non-send-capable bootstraps, and add regression tests.
- PR surface: Source +121, Tests +294. Total +415 across 4 files.
- Reproducibility: yes. The linked stable-release report supplies the user-visible Discord failure, and curren ... p-shell/direct-registry path that can satisfy runtime resolution before a send-capable adapter is verified.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(outbound): keep Discord runtime adapters resolvable
- PR branch already contained follow-up commit before automerge: fix(clawsweeper): reconcile automerge-openclaw-openclaw-90198 with ma…

Validation:
- ClawSweeper review passed for head 8711ada0c4.
- Required merge gates passed before the squash merge.

Prepared head SHA: 8711ada0c4
Review: https://github.com/openclaw/openclaw/pull/91119#issuecomment-4641934231

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: thewilloftheshadow
Co-authored-by: thewilloftheshadow <35580099+thewilloftheshadow@users.noreply.github.com>
2026-06-07 09:30:41 +00:00
Vincent Koc
dcba17d019 fix(e2e): stream installer session scans 2026-06-07 11:28:31 +02:00
Peter Steinberger
c2d825ae53 fix: migrate legacy agent registry schema via doctor
Move the shipped legacy shared-state agent database registry repair into doctor. Runtime now fails fast with a doctor repair hint when the old primary-key shape remains.
2026-06-07 02:23:51 -07:00
Vincent Koc
f36e54cd68 fix(e2e): require secret probe success 2026-06-07 11:22:43 +02:00
Vincent Koc
3dc6ac3802 fix(qa): fail closed on skipped suite summaries 2026-06-07 11:20:25 +02:00
Peter Steinberger
ce015cef57 refactor: store sandbox registry in sqlite
Move sandbox registry runtime state to SQLite and migrate legacy JSON registry files/directories via doctor.
2026-06-07 02:05:28 -07:00
Chunyue Wang
e06f6ffc3e fix(doctor): merge legacy codex models safely
Merge disjoint legacy openai-codex model entries into the canonical openai provider without losing safe per-model metadata, params, or models-add markers.

Unsafe provider-level defaults, auth/header/request state, and blocked normalized legacy providers are now preserved for manual cleanup with doctor preview warnings instead of being silently copied into models or repeatedly reported as auto-fixable.

Fixes #90047

Co-authored-by: openperf <16864032@qq.com>
2026-06-07 01:59:31 -07:00
Peter Steinberger
3e4b10fa1c fix: strip Google provider prefix from Gemini paths (#91125)
* fix: strip Google provider prefix from Gemini paths

* test: align qa exit code type
2026-06-07 01:49:45 -07:00
Vincent Koc
e5a9c60851 fix(e2e): bound codex live failure logs 2026-06-07 10:44:06 +02:00
Vincent Koc
677358f4a9 fix(e2e): bound telegram desktop proof logs 2026-06-07 10:42:28 +02:00
Vincent Koc
9e87d316c7 fix(e2e): bound telegram rtt mock logs 2026-06-07 10:41:07 +02:00
Vincent Koc
8cba5f7efd fix(e2e): bound upgrade survivor logs 2026-06-07 10:38:29 +02:00
Vincent Koc
440f315e83 fix(e2e): bound update channel logs 2026-06-07 10:33:14 +02:00
Vincent Koc
b9d530e292 fix(e2e): bound doctor switch logs 2026-06-07 10:31:53 +02:00
Vincent Koc
9b85b36d92 fix(qa): fail whatsapp skipped scenarios 2026-06-07 10:30:15 +02:00
Vincent Koc
9fb8d87f91 fix(e2e): bound plugin update logs 2026-06-07 10:26:59 +02:00
Peter Steinberger
248dfb22ec fix: preserve Foundry Responses reasoning replay ids
Preserve Microsoft Foundry encrypted reasoning replay item ids for Responses continuations while leaving chat-completions streams untouched.

Fixes #91033.
2026-06-07 01:24:07 -07:00
Vincent Koc
e64f2324b9 fix(dev): bound anthropic prompt log tails 2026-06-07 10:22:50 +02:00
Vincent Koc
eae4d284e7 fix(e2e): bound shared helper log output 2026-06-07 10:19:16 +02:00
Vincent Koc
3643a68e49 fix(qa): verify config after restart races 2026-06-07 10:17:06 +02:00
Vincent Koc
a58a6f63ca fix(qa): stream session transcript summaries 2026-06-07 10:14:45 +02:00
Vincent Koc
a931884eb5 fix(qa): require runtime tool failure proof 2026-06-07 10:09:38 +02:00
Peter Steinberger
7a3d24e70c refactor(memory-wiki): store import runs in sqlite (#91108) 2026-06-07 01:04:43 -07:00
Peter Steinberger
d6dffd6ef8 fix: align Xiaomi completions replay compat
Fixes #91106.

Behavior:
- Xiaomi MiMo OpenAI-compatible completions now replay assistant tool-call messages with `reasoning_content: ""` and DeepSeek-style thinking format.
- Adds a provider payload regression for the outbound Xiaomi request.
- Includes a small script fixture lint repair needed after rebasing onto current main.

Proof:
- `OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs src/llm/providers/openai-completions.test.ts src/agents/openai-completions-compat.test.ts`
- `node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.scripts.json scripts/e2e/lib/fixtures/workspace.mjs`
- `pnpm check:test-types`
- `pnpm tsgo:core`
- `pnpm exec oxfmt --check --threads=1 src/llm/providers/openai-completions.ts src/llm/providers/openai-completions.test.ts scripts/e2e/lib/fixtures/workspace.mjs`
- `git diff --check`
- `/Users/steipete/Projects/agent-skills/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- `gh pr checks 91113 --watch --fail-fast`
2026-06-07 01:00:59 -07:00
Jason (Json)
cf378e4cc8 fix(codex): preserve post-tool reasoning liveness
Preserve the Codex post-tool continuation guard for raw reasoning completions and streamed reasoning progress so valid post-tool synthesis stays on the intended completion watchdog instead of falling through to terminal idle behavior.

Verified with focused Codex watchdog tests, test typecheck, scripts lint, autoreview, and CI run 27086637988.

Thanks @fuller-stack-dev.

Co-authored-by: FullerStackDev <263060202+fuller-stack-dev@users.noreply.github.com>
2026-06-07 00:57:14 -07:00
Vincent Koc
589ea28dab test(gateway): smoke real websocket client 2026-06-07 09:46:00 +02:00
Vincent Koc
451765ad27 fix(e2e): require live tool result proof 2026-06-07 09:44:35 +02:00
Vincent Koc
4f9f7e20d4 fix(test): bound otel collector output 2026-06-07 09:42:15 +02:00
Dallin Romney
ebabf5022f perf(qqbot): narrow tool discovery cold load (#90780)
* perf: narrow qqbot tool discovery load

* fix(qqbot): load bridge entries through sidecars
2026-06-07 00:41:11 -07:00
Vincent Koc
1de4a3e9ea fix(test): stream group report logs 2026-06-07 09:40:46 +02:00
Vincent Koc
ea3a915cb5 fix(e2e): bound plugin fixture logs 2026-06-07 09:34:36 +02:00
Vincent Koc
ef52798254 fix(e2e): require tool-search session proof 2026-06-07 09:31:21 +02:00
Vincent Koc
78f2af9ac9 fix(e2e): bound workspace fixture output 2026-06-07 09:27:18 +02:00
Christine Yan
22276e6de0 fix(lmstudio): preserve wizard prompter binding
Bind LM Studio wizard prompter callbacks before storing them so class-backed gateway setup sessions keep their receiver and no longer crash when selecting LM Studio.

Thanks @christineyan4.

Co-authored-by: Christine Yan <christine.yan4@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-07 00:26:39 -07:00
Chunyue Wang
697d2d040c fix(agents): block message send loops with volatile delivery ids
Fixes #89090.

Release note: prevent repeated visible message sends from bypassing loop blocking when delivery results include fresh message, file, poll, receipt, run, idempotency, or timestamp fields. Normalizes send-like result hashing for the core message tool, sessions_send, and provider-docked messaging tools while preserving stable routing and outcome facts.

Verification:
- node scripts/run-vitest.mjs src/agents/tool-loop-detection.test.ts src/agents/tools/message-tool.test.ts
- .agents/skills/autoreview/scripts/autoreview --mode local --parallel-tests "node scripts/run-vitest.mjs src/agents/tool-loop-detection.test.ts src/agents/tools/message-tool.test.ts"
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main --parallel-tests "node scripts/run-vitest.mjs src/agents/tool-loop-detection.test.ts src/agents/tools/message-tool.test.ts"
- gh pr checks 89109 --watch --interval 30

Co-authored-by: openperf <16864032@qq.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-07 00:24:58 -07:00
Vincent Koc
0a2cad7e68 fix(e2e): bound live plugin transcript scans 2026-06-07 09:24:31 +02:00
Vincent Koc
0bf487e4cb fix(e2e): bound kitchen sink fixture logs 2026-06-07 09:22:36 +02:00
Vincent Koc
a3ab0e2534 fix(e2e): stream kitchen sink log scans 2026-06-07 09:20:45 +02:00
Vincent Koc
ab33fe33d1 fix(e2e): invoke kitchen sink image job 2026-06-07 09:18:22 +02:00
Yzx
054312672a fix(llm): preserve LM Studio Responses tool arguments
Preserve streamed Responses tool-call argument deltas when the final done event omits or sends empty arguments, fixing LM Studio argument-bearing tools from arriving as `{}`.

Fixes #90585.

Thanks @849261680.
2026-06-07 00:13:26 -07:00
Vincent Koc
c9f884fb28 fix(e2e): restrict degraded runtime readiness 2026-06-07 09:11:36 +02:00
Vincent Koc
f8db47e340 fix(e2e): verify bundled plugin source roots 2026-06-07 09:07:27 +02:00
Vincent Koc
cd1a90b310 fix(e2e): verify kitchen sink inspect-all 2026-06-07 09:03:48 +02:00
Vincent Koc
fff3b15fd7 fix(e2e): bound kitchen sink failure logs 2026-06-07 09:01:52 +02:00
Yuval Dinodia
bb27cbd46d fix(agents): decode xai and venice tool-call arguments exactly once
Decode HTML-entity escaped xAI and Venice tool-call arguments through the shared core compat path exactly once, preventing literal entities such as &amp; from being over-decoded before tool execution and transcript persistence. Removes xAI's duplicate provider-local decoder and keeps regression coverage for the shared core wrapper, xAI stream wrapper, and Venice compat path. Thanks @yetval for the fix.
2026-06-06 23:59:01 -07:00
Vincent Koc
8cb018e1f7 fix(e2e): require strict survivor readiness 2026-06-07 08:58:53 +02:00
Peter Steinberger
0566b96927 refactor(matrix): store crypto sidecars in sqlite (#91100) 2026-06-06 23:57:10 -07:00
Vincent Koc
b38e7105ec fix(e2e): bound parallels log version reads 2026-06-07 08:51:41 +02:00
Vincent Koc
6f35f96274 fix(dev): lazy-load telegram pairing smoke 2026-06-07 08:48:40 +02:00
Vincent Koc
f7aea2ad33 fix(e2e): report skipped secret proofs 2026-06-07 08:44:26 +02:00
Andi Liao
97d68b6902 fix(google): handle compressed Vertex ADC token responses
Decode Google Vertex authorized_user ADC OAuth token refresh responses from bytes so gzip-compressed token payloads still expose access_token. Adds a regression test for the compressed token response path while preserving plain JSON handling and the custom fetch seam.

Proof: OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs extensions/google/transport-stream.test.ts; pnpm exec oxfmt --check extensions/google/vertex-adc.ts extensions/google/transport-stream.test.ts; pnpm tsgo:extensions; git diff --check origin/main...HEAD; autoreview --mode branch --base origin/main. PR CI check-test-types failure was reproduced on current origin/main 607bbe4f5c and is unrelated to this two-file Google provider change.

Thanks @liaoandi for the fix and live Google Vertex ADC proof.
2026-06-06 23:42:35 -07:00
Vincent Koc
2fe7b5e8c9 fix(dev): harden smoke log diagnostics 2026-06-07 08:42:16 +02:00
Dallin Romney
a77d0fdd97 fix(test): type overflow resolver mock (#91098) 2026-06-06 23:40:37 -07:00
Vincent Koc
607bbe4f5c fix(dev): validate ios node smoke payloads 2026-06-07 08:36:06 +02:00
Vincent Koc
cd9c643dc6 fix(e2e): require causal telegram rtt canary 2026-06-07 08:25:11 +02:00
Vincent Koc
6bfd47af38 fix(e2e): clean interrupted docker harness runs 2026-06-07 08:17:16 +02:00
Peter Steinberger
08ae0e6d29 refactor: store Zalo hosted media in plugin state
Move Zalo hosted outbound media metadata and expiry into plugin state, add SDK chunked hosted media storage, and keep CI/type/lint gates green after rebase.
2026-06-06 22:56:48 -07:00
Vinayaka Jyothi
443ac732a1 fix(minimax): keep thinking active for M3
Fix MiniMax-M3 Anthropic-compatible requests so OpenClaw no longer sends the disabled-thinking payload that makes M3 return empty content. M3 defaults now stay on MiniMax's omitted/adaptive thinking path, explicit `/think off` is still respected, and MiniMax-M2.x keeps the disabled-thinking default that prevents reasoning_content leaks.

Also wires the MiniMax thinking policy through bundled provider-policy loading so pre-runtime and configless embedded-agent paths resolve the same defaults.

Thanks @IamVNIE for the live MiniMax API repro and initial patch.
2026-06-06 22:56:17 -07:00
Vincent Koc
0880fd94c6 fix(e2e): preserve dist during bun restore 2026-06-07 07:54:05 +02:00
Vincent Koc
ab41e25b2c fix(dev): require telegram final message id 2026-06-07 07:48:13 +02:00
Vincent Koc
03ae553ecd fix(e2e): bound telegram package logs 2026-06-07 07:46:26 +02:00
Vincent Koc
d943d36677 fix(e2e): require cron cleanup success status 2026-06-07 07:43:33 +02:00
Vincent Koc
b6c45a9301 fix(e2e): require web search success marker 2026-06-07 07:42:24 +02:00
Vincent Koc
5ab3104a15 test(e2e): cover openwebui chat smoke 2026-06-07 07:38:04 +02:00
Vincent Koc
d59d3f87b0 fix(e2e): bound bundled lifecycle log output 2026-06-07 07:36:51 +02:00
Vincent Koc
d801bb5be0 fix(e2e): report resource breaches on failed runs 2026-06-07 07:34:21 +02:00
Vincent Koc
3597cfc7bc fix(qa): allow gauntlet observations to fail 2026-06-07 07:31:56 +02:00
Vincent Koc
6590f764b5 fix(e2e): bound docker failure log printing 2026-06-07 07:29:35 +02:00
Vincent Koc
0b0893aa21 fix(e2e): bound kitchen sink log traversal 2026-06-07 07:25:46 +02:00
Vincent Koc
251bd61e22 fix(e2e): reject kitchen sink rpc error envelopes 2026-06-07 07:23:33 +02:00
Vincent Koc
db24e8e76b fix(e2e): validate bundled runtime health smoke 2026-06-07 07:21:34 +02:00
Vincent Koc
22466240a5 fix(e2e): run openwebui release chat smoke 2026-06-07 07:18:56 +02:00
Peter Steinberger
e8348c0dc8 refactor(matrix): store sync cache in sqlite
Move Matrix sync cache state into plugin SQLite storage, with startup and doctor migrations for readable legacy bot-storage.json files.\n\nVerification: focused Matrix and QA tests passed locally; focused touched-file oxlint and git diff --check passed; autoreview clean. CI failures are current main/unrelated: lint/type/madge/gateway-watch issues outside the Matrix diff.
2026-06-06 22:17:41 -07:00
Vincent Koc
690a04f81e fix(e2e): require gateway network health payload 2026-06-07 07:16:19 +02:00
Vincent Koc
ed7f259ce7 fix(e2e): bound onboard log polling reads 2026-06-07 07:14:37 +02:00
Vincent Koc
892dbb5ebb fix(dev): require gateway smoke health payload 2026-06-07 07:11:21 +02:00
Vincent Koc
7e7ea0fed1 fix(qa): validate rpc rtt smoke payloads 2026-06-07 07:09:01 +02:00
Vincent Koc
fa614d0907 fix(qa): tighten kitchen sink rpc proof 2026-06-07 07:03:43 +02:00
Vincent Koc
1d2bebbb41 fix(mac): scope restart app cleanup 2026-06-07 06:58:46 +02:00
Vincent Koc
ab645aca31 fix(test): require enabled live shard proof 2026-06-07 06:47:02 +02:00
Vincent Koc
1f0cf074cf fix(mac): scope build-and-run cleanup 2026-06-07 06:35:44 +02:00
Vincent Koc
03f1bf9a4d fix(qa-lab): fail missing parity tool results 2026-06-07 06:25:28 +02:00
Vincent Koc
0e1d2b3ef4 fix(qa-lab): require tool evidence in parity metrics 2026-06-07 06:20:16 +02:00
Vincent Koc
9b1c0dac68 fix(e2e): bound codex live assertion reads 2026-06-07 06:15:42 +02:00
Vincent Koc
cfaae7761d fix(e2e): fail lifecycle resource spikes 2026-06-07 06:11:15 +02:00
Vincent Koc
a372429a96 fix(e2e): cap docker harness resources 2026-06-07 06:06:12 +02:00
Vincent Koc
a737826320 fix(qa-lab): require live runtime tool evidence 2026-06-07 05:57:37 +02:00
Peter Steinberger
f4098e64e4 docs(config): document reasoning content compat flag 2026-06-07 04:52:37 +01:00
Krasimir Kralev
c45295cc33 fix(config): honor reasoning content compat flag
Allow custom OpenAI-compatible providers to opt into the existing DeepSeek assistant reasoning replay contract through persisted model compat config. Closes #89660.
2026-06-06 20:50:50 -07:00
Vincent Koc
e7b09fba37 fix(e2e): guard openwebui docker resources 2026-06-07 05:48:01 +02:00
wsyjh8
a1f1895b1b fix(config): allow thinkingLevelMap in persisted model schema
Allow persisted provider model entries to carry strict thinkingLevelMap values so Microsoft Foundry Entra onboarding can save generated reasoning model config. Closes #91011.
2026-06-06 20:44:15 -07:00
Mukunda Rao Katta
78135c3a29 fix(agents): suppress DeepSeek thinking for Foundry aliases
Fix Microsoft Foundry DeepSeek V4 alias providers by suppressing the DeepSeek-native `thinking` fallback and stripping DeepSeek replay fields on Foundry/non-native compat paths while preserving native DeepSeek and OpenRouter/Together reasoning controls.

Verified with focused embedded-runner tests, formatting/diff checks, autoreview, passing real-behavior proof gate, CI embedded-agent shard, issue #90520 reporter live A/B proof, and a local attempted gateway probe blocked before provider dispatch by model allowlist.

Known red CI lanes are unrelated to the touched files and documented in the pre-merge PR comment.

Fixes #90520
2026-06-06 20:37:52 -07:00
Vincent Koc
f94d3b1d8c fix(e2e): scan gateway readiness logs incrementally 2026-06-07 05:36:12 +02:00
Vincent Koc
586cf18b8d fix(e2e): bound docker log printing 2026-06-07 05:30:00 +02:00
Vincent Koc
c7a9dc1cc2 fix(qa-lab): link runtime tool output to planned calls 2026-06-07 05:27:34 +02:00
Omar Shahine
203dee9033 docs(imessage): clarify macOS library validation setup
Clarify that modern macOS iMessage private-API injection needs SIP disabled plus Library Validation relaxed, and document the verified macOS 26.5.1 Tahoe behavior without publishing unsupported AMFI boot-args guidance.\n\nVerification:\n- pnpm docs:list\n- git diff --check\n- pnpm check:docs\n\nThanks @omarshahine!
2026-06-06 20:20:57 -07:00
Vincent Koc
480c9a97b6 fix(e2e): tighten kitchen sink rpc assertions 2026-06-07 05:15:12 +02:00
Vincent Koc
cfeed10b01 fix(e2e): bound live plugin agent output 2026-06-07 05:09:31 +02:00
Peter Steinberger
3006b85db0 fix(openrouter): reconcile streamed generation cost
Fix OpenRouter streamed billing reconciliation by replacing the streamed estimated cost with the provider generation metadata total when the final streamed response includes a response id.

Verified with focused OpenRouter tests, full OpenRouter extension tests, formatting/diff checks, autoreview, official OpenRouter generation metadata docs, and a live OpenRouter API stream plus delayed generation lookup. Remaining CI failures were inspected and are unrelated existing failures outside the OpenRouter surface.

Fixes #68066
2026-06-06 20:06:57 -07:00
Peter Steinberger
a4236bd6fa refactor(memory-wiki): store source sync state in sqlite
* refactor(memory-wiki): store source sync state in sqlite

* fix(memory-wiki): satisfy source sync migration lint
2026-06-06 20:04:27 -07:00
Vincent Koc
ec55179504 fix(parallels): stream host command logs 2026-06-07 05:00:48 +02:00
Vincent Koc
416008dd10 fix(qa): reject empty gauntlet pass evidence 2026-06-07 04:56:32 +02:00
Vincent Koc
f55433bf31 fix(qa-lab): require runtime tool output evidence 2026-06-07 04:52:10 +02:00
Vincent Koc
f8f53de45a fix(e2e): bound kitchen sink log scans 2026-06-07 04:47:23 +02:00
Vincent Koc
6c9e7de04d fix(e2e): bound plugin assertion log reads 2026-06-07 04:42:15 +02:00
Vincent Koc
bcfd7164de fix(e2e): bound plugin update log assertions 2026-06-07 04:40:38 +02:00
Vincent Koc
e32707458d fix(e2e): bound secret configure pty output 2026-06-07 04:37:41 +02:00
Vincent Koc
7f7614276b fix(gateway): timeout stalled mcp bodies 2026-06-07 04:32:33 +02:00
Vincent Koc
c40d2c45bf fix(e2e): honor kitchen sink output caps 2026-06-07 04:26:55 +02:00
Vincent Koc
4911615e72 fix(e2e): bound credential payload chunks 2026-06-07 04:24:35 +02:00
Vincent Koc
326c4e0e35 fix(e2e): bound secret resolver stdin 2026-06-07 04:19:16 +02:00
Vincent Koc
06e8a74473 fix(apns): bound response body capture 2026-06-07 04:12:59 +02:00
Vincent Koc
801df108f0 fix(cli): bound exec approvals stdin 2026-06-07 04:08:13 +02:00
Vincent Koc
51b64b8198 fix(proxy): stream debug capture bodies 2026-06-07 04:06:26 +02:00
Vincent Koc
b804d20da7 refactor(test): share channel contract file discovery 2026-06-07 03:59:03 +02:00
Vincent Koc
2b7d7841d2 fix(test): bound prompt capture bodies 2026-06-07 03:55:48 +02:00
Peter Steinberger
0551af92b0 fix(gemini): accept empty grounding metadata
Fixes #88528.

Gemini web_search now accepts successful Google Search grounding responses that include candidate text and an empty `groundingMetadata` object without `groundingChunks`, returning wrapped content with `citations: []` instead of throwing `Gemini API error: malformed JSON response`.

Proof: live direct Gemini API reproduced the empty-grounding response shape; live OpenClaw provider failed before and succeeded after; `node scripts/run-vitest.mjs extensions/google/web-search-provider.test.ts`; `pnpm lint:web-search-provider-boundaries`; targeted oxfmt check; `git diff --check`; autoreview clean.

CI note: admin bypass used for unrelated failures in memory-core/device-pair/scripts, an existing core architecture cycle, and gateway-watch; PR diff touched only the two Gemini files.
2026-06-06 18:54:18 -07:00
Vincent Koc
344db67a00 fix(test): flush prompt probe gateway logs 2026-06-07 03:52:45 +02:00
Vincent Koc
08e3846470 fix(test): close otel receiver sockets 2026-06-07 03:49:48 +02:00
Vincent Koc
dbde27af4b fix(test): flush static artifact logs 2026-06-07 03:46:44 +02:00
alkor2000
2bd1c7b1c9 fix(vertex): route eu/us multi-region to .rep.googleapis.com host
Fixes #89891.

Route Google Vertex `eu` and `us` multi-region locations to the REP hosts used by `@google/genai`, and keep native Vertex endpoint trust exact to those two hosts.

Verification before merge:
- Live 1Password-backed GCP service-account probe: `eu-aiplatform.googleapis.com` returned Google HTML 404; `aiplatform.eu.rep.googleapis.com` reached Vertex JSON `PERMISSION_DENIED` with the same token.
- `node scripts/run-vitest.mjs src/agents/provider-attribution.test.ts extensions/google/vertex-multi-region-host.test.ts extensions/google/api.test.ts` passed.
- `git diff --check` passed.
- `autoreview --mode branch --base origin/main` clean.
- Real behavior proof passed on latest head.
- ClawSweeper re-review: ready for maintainer review, proof sufficient.

CI note: merged with maintainer approval despite red CI because the failures were unrelated to this PR and reproduced on untouched paths: `extensions/acpx/doctor-contract-api.ts`, `extensions/device-pair/notify.ts`, script lint, and existing architecture/gateway-watch checks.

Co-authored-by: alkor2000 <200923177@qq.com>
2026-06-06 18:39:49 -07:00
Peter Steinberger
3f5e001844 fix: store memory-core dreams state in sqlite (#91056) 2026-06-06 18:38:45 -07:00
Vincent Koc
1222f7a6bc fix(test): wait for cross os command logs 2026-06-07 03:35:54 +02:00
Vincent Koc
c4bc366a4c fix(test): clean up codex bind startup failures 2026-06-07 03:31:12 +02:00
Vincent Koc
50437d02c1 fix(test): clean up codex harness startup failures 2026-06-07 03:28:56 +02:00
Vincent Koc
61bb7d5523 fix(test): clean up cli backend live startup failures 2026-06-07 03:27:37 +02:00
Vincent Koc
3060ebf052 fix(test): restore tool search gateway e2e env 2026-06-07 03:24:24 +02:00
Vincent Koc
ecec1b9a59 fix(test): clean up acp live startup failures 2026-06-07 03:21:46 +02:00
Vincent Koc
7f885d5a39 fix(test): restore mcp code mode e2e env 2026-06-07 03:17:46 +02:00
Vincent Koc
3a6696951e fix(test): close rpc rtt websocket on failure 2026-06-07 03:14:57 +02:00
Vincent Koc
1d371eb5ae fix(qa): stop queued gateway rpc calls 2026-06-07 03:12:03 +02:00
Vincent Koc
e75d7cda8f fix(test): fail live gateway startup skips 2026-06-07 03:09:19 +02:00
Vincent Koc
919befbbb6 fix(qa): gate character eval on suite summary 2026-06-07 03:04:53 +02:00
Vincent Koc
d034e9698a fix(qa): trust parity scenario rows for metrics 2026-06-07 02:59:19 +02:00
Vincent Koc
51848de462 fix(test): gate package telegram on summary failures 2026-06-07 02:57:24 +02:00
Vincent Koc
e12141fa9f fix(qa): gate live transport exits on summaries 2026-06-07 02:53:50 +02:00
Vincent Koc
6d2566682a fix(qa): fail suite on summary scenario failures 2026-06-07 02:49:02 +02:00
Vincent Koc
154ee9fd23 fix(test): require source summary scenario evidence 2026-06-07 02:43:27 +02:00
Vincent Koc
0a37f797f2 fix(test): validate gauntlet qa summary counts 2026-06-07 02:41:39 +02:00
Vincent Koc
b45e3028e0 fix(test): require kitchen sink install rss proof 2026-06-07 02:38:58 +02:00
Matt H
983b65b0e0 feat(parallel): add free Parallel Search MCP as the zero-config default web_search provider (#90849)
* feat(parallel): add free Parallel Search MCP as the zero-config default web_search provider

Registers two Parallel web_search providers in the parallel plugin:
- parallel-free: keyless, always the free hosted Search MCP (search.parallel.ai/mcp);
  the zero-config default (autoDetectOrder 76) so web_search works with no key.
- parallel: the existing paid v1 REST API (requires PARALLEL_API_KEY).

Shared query/result normalization lives in parallel-search-normalize.ts (used by both
transports); a minimal Streamable-HTTP JSON-RPC client (parallel-mcp-search.runtime.ts)
backs the free path. UI brands the tool-call chip 'Parallel Web Search' on the free path
via a searchTransport marker; setup default mirrors runtime auto-detect.

* chore(parallel): register parallel-free in doctor legacy-web-search owners

parallel-free is a bundled web_search provider, so add it to the doctor's
exhaustive BUNDLED_LEGACY_WEB_SEARCH_OWNERS map (owned by the parallel plugin)
and the NON_MIGRATED set — it has no legacy tools.web.search.* shape, so this is
a no-op for migration, matching paid parallel/tavily. Keeps the registry
complete. (Spotted by diffing the earlier local WIP branch.)

* docs(parallel): restore concise frontmatter summary

* docs(parallel): clearer, professional copy; drop v1 REST jargon and UI-label claim

- Frame the two providers as Parallel Search (Free) vs paid Parallel Search;
  remove internal 'v1 REST API' wording.
- Remove conversational/overstated phrasing ('out of the box for everyone').
- Remove the 'labeled Parallel Web Search in the UI' claim (only renders in the
  Control UI, not the TUI). Scope the searchTransport code comment accordingly.

* revert(parallel): drop the "Parallel Web Search" tool-call branding

The label only rendered in the Control UI, never the TUI (a separate renderer
via src/agents/tool-display.ts). Extending it would put provider-specific
labeling into a shared/core display path, against the plugin-agnostic-core rule.

Reverts the Control-UI labelOverride wiring and removes the now-orphaned
searchTransport marker from the free provider's result. The result still carries
provider: "parallel-free".

* fix(parallel): cap free Search MCP session_id at its 100-char tools/list contract

The free parallel-free provider reused the paid ParallelSearchSchema, whose
session_id allows 1000 chars, but the live Search MCP tools/list schema caps
session_id at 100. Parameterize normalizeParallelSessionId(value, maxLength);
the free path passes 100 (paid keeps 1000) and advertises the tighter bound in
its own ParallelFreeSearchSchema. An over-limit caller id is dropped and a
fresh in-contract id is minted. Updates tests and docs accordingly.
2026-06-06 17:36:28 -07:00
Vincent Koc
8516f37563 fix(scripts): reject loose run env kill delay 2026-06-07 02:35:08 +02:00
Vincent Koc
e28dd6dd6e fix(test): reject loose startup memory limits 2026-06-07 02:33:15 +02:00
Vincent Koc
b32d769069 fix(test): require live shard file evidence 2026-06-07 02:30:18 +02:00
Vincent Koc
824d5d44cd fix(test): require advisory cli provider skips 2026-06-07 02:23:27 +02:00
Vincent Koc
e12136a7bb fix(test): require strict codex models proof 2026-06-07 02:21:23 +02:00
Vincent Koc
1ce84627e8 fix(test): reject loose release journey limits 2026-06-07 02:11:49 +02:00
Vincent Koc
4dae3b3071 fix(ci): require kova partial evidence 2026-06-07 02:10:23 +02:00
Vincent Koc
5378cf527e fix(test): require live model successes 2026-06-07 02:08:40 +02:00
Vincent Koc
ba46d00589 fix(test): fail secret proof signal exits 2026-06-07 02:06:21 +02:00
Vincent Koc
b6cbb4b861 fix(test): fail acp bind auth errors 2026-06-07 02:04:18 +02:00
Vincent Koc
cb5c513d58 fix(test): fail acp bind missing transcript 2026-06-07 02:02:12 +02:00
Vincent Koc
029e8f0153 fix(test): retry cli backend codex timeouts 2026-06-07 02:00:12 +02:00
Peter Steinberger
05c3325b0a fix: store acpx process state in sqlite
Move ACPX gateway identity and live process leases into SQLite-backed plugin state. Add doctor migration for legacy runtime state and preserve process cleanup identity checks across the storage move.
2026-06-06 16:49:47 -07:00
Vincent Koc
157da3621a fix(gateway): close slow direct response consumers 2026-06-07 01:45:47 +02:00
Vincent Koc
ee7cafafeb fix(test): retry codex harness live timeouts 2026-06-07 01:40:44 +02:00
Vincent Koc
172c3f6064 fix(gateway): classify mcp json-rpc failures 2026-06-07 01:35:40 +02:00
Vincent Koc
46e12e7aff fix(gateway): cap mcp loopback tool cache 2026-06-07 01:27:55 +02:00
Vincent Koc
5f7cfd6451 fix(test): require perf budget source 2026-06-07 01:24:10 +02:00
Vincent Koc
bb0384e884 fix(test): require gateway smoke history evidence 2026-06-07 01:21:49 +02:00
Vincent Koc
e74d98bd65 fix(ci): fail release qa verifier closed 2026-06-07 01:17:58 +02:00
Vincent Koc
2accfeedc7 fix(test): require resource evidence in perf scripts 2026-06-07 01:11:24 +02:00
Peter Steinberger
ba447d5afc fix: store device-pair notify state in sqlite
Move Device Pair notify subscribers and delivery dedupe state into SQLite-backed plugin state. Add doctor migration for legacy notify subscribers; request-id delivery state is cache-only and rebuilt.
2026-06-06 16:10:16 -07:00
Vincent Koc
76435679f5 fix(test): require live runtime parity evidence 2026-06-07 01:05:44 +02:00
Vincent Koc
f5c345b3fe fix(test): require native live shard proof 2026-06-07 00:57:51 +02:00
Vincent Koc
369793d9ab fix(test): require telegram rtt samples 2026-06-07 00:54:13 +02:00
Vincent Koc
a9706ddef2 fix(ci): require live kova evidence 2026-06-07 00:48:54 +02:00
Vincent Koc
9564ee25b2 fix(test): reject failed agent reply markers 2026-06-07 00:45:00 +02:00
Vincent Koc
c3dca12274 fix(test): ignore error reply payload markers 2026-06-07 00:36:02 +02:00
Vincent Koc
441a73c492 fix(ci): require docker e2e summaries 2026-06-07 00:35:03 +02:00
Vincent Koc
c575b9782e fix(test): require group report evidence 2026-06-07 00:31:52 +02:00
Vincent Koc
8ac2ffde2a fix(ci): require source performance artifacts 2026-06-07 00:27:48 +02:00
Vincent Koc
d5ef040e65 fix(test): require live media providers 2026-06-07 00:25:05 +02:00
Vincent Koc
84bcae95a0 fix(ci): fail closed on partial kova reports 2026-06-07 00:17:20 +02:00
Vincent Koc
84275d6608 fix(test): forward kitchen rpc env knobs 2026-06-07 00:13:02 +02:00
Vincent Koc
2876906da5 fix(ci): require kova resource metrics 2026-06-07 00:10:37 +02:00
Vincent Koc
368f687735 fix(test): require tool search execution proof 2026-06-07 00:07:07 +02:00
Vincent Koc
1b6bc2ef7d fix(test): require native web search proof 2026-06-07 00:02:29 +02:00
Vincent Koc
2102166f86 fix(test): normalize docker stats heartbeat 2026-06-06 23:59:33 +02:00
Vincent Koc
f7b6ee48fd fix(test): require chat tool-call-only proof 2026-06-06 23:53:08 +02:00
Vincent Koc
a5fcab9ff8 fix(test): reject malformed plugin uninstall config 2026-06-06 23:50:30 +02:00
Vincent Koc
39e27c8276 fix(test): require lifecycle uninstall config proof 2026-06-06 23:46:46 +02:00
Vincent Koc
a78234a5d3 fix(test): validate kitchen sink rpc outputs 2026-06-06 23:45:00 +02:00
Vincent Koc
1f6eabb09f fix(test): require live tool transcript evidence 2026-06-06 23:41:52 +02:00
Shakker
caae4c9109 test: manage update startup env 2026-06-06 22:10:40 +01:00
Shakker
c6bbb55fb5 fix: scope llm api key env 2026-06-06 22:10:01 +01:00
Vincent Koc
7f4ddf62ea fix(test): validate auth profile env refs 2026-06-06 23:09:43 +02:00
Shakker
f6b6cf6d6c test: manage chat reply media state 2026-06-06 22:08:30 +01:00
joshp123
5d5bc5c84d Revert "Fix talk config secret resolution"
This reverts commit 4500f02fe6.
2026-06-06 23:07:03 +02:00
Shakker
de4ef48323 fix: manage skill autocapture state 2026-06-06 22:04:17 +01:00
Shakker
133585d97f test: wrap install download state fixture 2026-06-06 22:03:49 +01:00
Vincent Koc
a33077d9c6 fix(test): reject missing numeric flag values 2026-06-06 23:01:21 +02:00
Shakker
78f67fa85f fix: manage diagnostic session state 2026-06-06 21:59:54 +01:00
Shakker
27406dc6fb test: scope logging config env 2026-06-06 21:59:24 +01:00
joshp123
4500f02fe6 Fix talk config secret resolution 2026-06-06 22:58:55 +02:00
Shakker
86792c0319 fix: manage gateway skills state fixtures 2026-06-06 21:58:04 +01:00
Vincent Koc
a3e969101c fix(perf): reject missing cpu scenario values 2026-06-06 22:57:19 +02:00
Vincent Koc
5b88ddfb99 fix(perf): reject ambiguous changed bench args 2026-06-06 22:55:56 +02:00
Vincent Koc
c5f40275f5 fix(rpc): reject missing rtt option values 2026-06-06 22:54:07 +02:00
Shakker
4a46da7499 test: scope subagent resume state env 2026-06-06 21:53:29 +01:00
Vincent Koc
ba5fa16907 fix(perf): require group report numeric values 2026-06-06 22:52:29 +02:00
Shakker
ca40b3cdc6 test: manage workshop state fixtures 2026-06-06 21:51:01 +01:00
Vincent Koc
5ecfee04f8 fix(ci): reject ambiguous run timing args 2026-06-06 22:50:49 +02:00
Vincent Koc
125b0fc279 fix(ci): reject unknown kova summary flags 2026-06-06 22:49:29 +02:00
Shakker
aa9c5209fc test: restore ssh sandbox env snapshots 2026-06-06 21:49:10 +01:00
Vincent Koc
a470daad12 fix(test): require docker timing limit values 2026-06-06 22:48:19 +02:00
Shakker
f86dd6c0af test: scope session read media env 2026-06-06 21:47:39 +01:00
Vincent Koc
fbcd27e258 fix(perf): require source summary path values 2026-06-06 22:47:00 +02:00
Vincent Koc
8ff0a20744 fix(docs): require sync provenance values 2026-06-06 22:43:04 +02:00
Vincent Koc
bdc317f4a6 fix(release): require validation dispatch values 2026-06-06 22:39:58 +02:00
Vincent Koc
1c0d7c8a57 fix(package): require docker package option values 2026-06-06 22:36:47 +02:00
Vincent Koc
b6b50d893c fix(security): require docker attestation platform values 2026-06-06 22:33:56 +02:00
Vincent Koc
b5b73bd362 fix(plugin): require package manifest run target 2026-06-06 22:31:42 +02:00
Vincent Koc
3f3b757e50 fix(plugin): require runtime build package targets 2026-06-06 22:29:12 +02:00
Vincent Koc
bd7f65d445 fix(package): require package root values 2026-06-06 22:26:53 +02:00
Vincent Koc
b8f5950fe3 fix(ci): reject missing merge diff refs 2026-06-06 22:25:06 +02:00
Vincent Koc
f47f32db46 fix(docs): reject missing glossary diff refs 2026-06-06 22:23:09 +02:00
Vincent Koc
1549172816 fix(ci): reject missing changed scope refs 2026-06-06 22:21:14 +02:00
Vincent Koc
9f5fc45593 fix(security): require audit severity values 2026-06-06 22:19:18 +02:00
Vincent Koc
bedb3e61c6 fix(report): require ownership markdown path 2026-06-06 22:18:03 +02:00
Vincent Koc
c2af0475fe fix(test): require vitest profile output dir 2026-06-06 22:16:53 +02:00
Vincent Koc
66a1cfb7be fix(ci): reject missing release metadata refs 2026-06-06 22:15:34 +02:00
Vincent Koc
457c76964d fix(test): reject missing report artifact paths 2026-06-06 22:13:37 +02:00
Vincent Koc
4b19f820e1 fix(test): reject missing dependency evidence values 2026-06-06 22:12:08 +02:00
Vincent Koc
4bce355318 fix(test): reject missing group report values 2026-06-06 22:09:33 +02:00
Vincent Koc
53044e8717 fix(report): reject missing dependency report paths 2026-06-06 22:07:31 +02:00
Vincent Koc
98c45aa8b5 fix(test): reject missing env mutation roots 2026-06-06 22:05:23 +02:00
Vincent Koc
71f8b9c41e fix(rtt): reject missing path option values 2026-06-06 22:03:50 +02:00
Vincent Koc
59a8137b04 fix(docs): reject missing mdx report paths 2026-06-06 22:02:20 +02:00
Vincent Koc
a791636160 fix(test): reject missing startup memory paths 2026-06-06 22:00:38 +02:00
Vincent Koc
e3d402427c fix(test): reject zero startup RSS samples 2026-06-06 21:59:09 +02:00
Vincent Koc
f5a7f613ee fix(release): use monthly patch versions
Switch release train handling to YYYY.M.PATCH monthly patch numbering, preserve pre-transition compatibility, and pin the June 2026 stable/beta floor at 2026.6.5 after the published beta.

Verification:
- node scripts/run-vitest.mjs run test/appcast.test.ts test/release-check.test.ts test/scripts/package-mac-app.test.ts test/scripts/package-mac-dist.test.ts test/openclaw-npm-release-check.test.ts test/npm-publish-plan.test.ts src/infra/npm-registry-spec.test.ts src/infra/clawhub.test.ts src/plugins/clawhub.test.ts test/plugin-npm-release.test.ts test/scripts/ios-version.test.ts test/scripts/ios-pin-version.test.ts
- node --import tsx scripts/plugin-npm-release-check.ts --base-ref origin/main --head-ref HEAD
- node --import tsx scripts/plugin-clawhub-release-check.ts --base-ref origin/main --head-ref HEAD
- git diff --check origin/main...HEAD
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main --no-web-search
2026-06-06 12:26:32 -07:00
Vincent Koc
15cb26e6cb fix(ci): reject empty timing job payloads 2026-06-06 21:22:55 +02:00
Vincent Koc
6ce5182522 fix(perf): reject empty cpu profiles 2026-06-06 21:21:17 +02:00
Vincent Koc
3f18c71ac4 fix(test): require restart bench resource evidence 2026-06-06 21:18:58 +02:00
Vincent Koc
cc2ed8dbf6 fix(test): require passing Telegram evidence reports 2026-06-06 21:14:51 +02:00
Vincent Koc
dfd52e72b3 fix(test): reject empty Kova summaries 2026-06-06 21:13:08 +02:00
Vincent Koc
e1e203ce11 fix(test): require perf budget report evidence 2026-06-06 21:11:05 +02:00
Vincent Koc
8086c44043 fix(mac): fail release packaging without Swift compat lib 2026-06-06 21:06:32 +02:00
Vincent Koc
e974d98811 fix(test): reject missing gateway bench flag values 2026-06-06 21:03:45 +02:00
Vincent Koc
c6ee13529f fix(test): avoid zero rpc rtt artifacts 2026-06-06 21:01:44 +02:00
Vincent Koc
7cd7a4f438 fix(test): preserve kitchen sink log scans 2026-06-06 20:59:58 +02:00
Vincent Koc
f2677d55ec fix(test): require startup bench report overlap 2026-06-06 20:53:08 +02:00
Omar Shahine
cd806101cd fix(imessage): send TTS audio as voice messages (#90853)
Merged via squash.

Prepared head SHA: 258d2d73f3

Reviewed-by: @omarshahine
2026-06-06 11:49:50 -07:00
Vincent Koc
c4b64de017 fix(test): reject zero RSS resource samples 2026-06-06 20:47:51 +02:00
Vincent Koc
bf5e0e9f10 fix(test): parse RPC RTT iteration counts strictly 2026-06-06 20:45:20 +02:00
Vincent Koc
4b2e3656af fix(test): require Vitest JSON reports 2026-06-06 20:43:28 +02:00
Vincent Koc
d5f5cb2430 fix(test): require gateway CPU startup reports 2026-06-06 20:41:31 +02:00
Vincent Koc
77f8b16716 fix(test): fail startup bench missing RSS samples 2026-06-06 20:39:07 +02:00
Vincent Koc
a1ffaafc12 fix(test): reject timed-out startup bench reports 2026-06-06 20:37:29 +02:00
Vincent Koc
46c000a34f fix(test): fail extension memory import failures 2026-06-06 20:35:27 +02:00
Marvinthebored
1af55bc665 fix(agents): stabilize user-turn serialization across turns to preserve prompt cache (#90811)
Merged via squash.

Prepared head SHA: 3572122df7
Co-authored-by: Marvinthebored <262704729+Marvinthebored@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-06-06 11:17:33 -07:00
Vincent Koc
59365959d3 fix(test): fail startup bench timed-out samples 2026-06-06 20:03:47 +02:00
Vincent Koc
198002b579 fix(test): report mac changed-bench RSS correctly 2026-06-06 20:02:30 +02:00
Vincent Koc
559d723a13 fix(test): anchor startup memory checks to repo root 2026-06-06 20:00:41 +02:00
Vincent Koc
44fbe63bcf fix(mac): derive app build metadata from repo root 2026-06-06 19:57:52 +02:00
Vincent Koc
7056222288 fix(mac): fail closed on invalid release build floors 2026-06-06 19:55:22 +02:00
Vincent Koc
c13cf91787 fix(mac): fail closed when packaging app shutdown stalls 2026-06-06 19:39:37 +02:00
Vincent Koc
b8956b6a56 fix(mac): fail closed when restart cleanup stalls 2026-06-06 19:37:45 +02:00
Vincent Koc
4d142b185e fix(mac): scope restart process cleanup 2026-06-06 19:31:19 +02:00
Vincent Koc
96c5d33d2b fix(agents): read inbound media refs 2026-06-06 10:28:46 -07:00
Vincent Koc
a9ef3adeb3 fix(test): remove stale deadcode allowlist entries 2026-06-06 19:25:22 +02:00
Vincent Koc
e1d18e5d02 fix(ci): surface advisory release QA failures 2026-06-06 19:20:29 +02:00
Vincent Koc
c682919808 fix(gateway): notify session changes from goal commands 2026-06-06 10:11:04 -07:00
Vincent Koc
6324abbe53 fix(test): reject malformed group reports 2026-06-06 19:03:09 +02:00
Vincent Koc
eac192c170 fix(test): fail unready gateway watch runs 2026-06-06 18:54:01 +02:00
Vincent Koc
6f2cb53fc4 fix(test): fail stale Crabbox broker auth 2026-06-06 18:46:28 +02:00
Joseph Krug
daab68efc8 fix(plugins): load memory embedding provider owners at startup
Gateway startup now includes plugin owners for explicit memorySearch.provider and memorySearch.fallback values, including custom models.providers API owners and generic embedding provider contracts.

Sentinel and disabled paths keep existing startup behavior for auto, local, none, disabled memory search, and disabled memory slots.

Adds post-runtime-load diagnostics for configured memory embedding providers that remain unregistered.

Closes #89651

Co-authored-by: Joseph Krug <5925937+joeykrug@users.noreply.github.com>
2026-06-07 00:44:06 +08:00
Vincent Koc
0b591acd77 fix(test): fail skipped explicit live media suites 2026-06-06 18:41:08 +02:00
the sun gif man
47cfacbb87 docs: improve plugin inventory layout (#90922)
Summary:
- The branch changes plugin inventory generation from wide Markdown tables to per-plugin list entries, shorten ... nerated plugin reference landing page, routes Parallel to its setup page, and updates zh-CN glossary terms.
- PR surface: Docs +9, Other +20. Total +29 across 4 files.
- Reproducibility: not applicable. this is a docs layout PR rather than a reproducible runtime bug. Current ma ... and the PR body plus prior review discussion documents before/after screenshot proof for the layout change.

Automerge notes:
- PR branch already contained follow-up commit before automerge: docs: improve plugin inventory layout

Validation:
- ClawSweeper review passed for head c94b7a4bbc.
- Required merge gates passed before the squash merge.

Prepared head SHA: c94b7a4bbc
Review: https://github.com/openclaw/openclaw/pull/90922#issuecomment-4638524853

Co-authored-by: joshp123 <joshp123@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: joshp123
Co-authored-by: joshp123 <1497361+joshp123@users.noreply.github.com>
2026-06-06 16:37:39 +00:00
Vincent Koc
9ac685b5fe fix(test): fail filtered live media no-op runs 2026-06-06 18:34:54 +02:00
Vincent Koc
cf152fe76e fix(ci): require valid Kova partial reports 2026-06-06 18:28:22 +02:00
Vincent Koc
52adf91b6f test(e2e): share mcp code mode validation 2026-06-06 18:16:57 +02:00
zenglingbiao
09d6479681 fix(build): ship export session html assets
Align the export-session template asset copy step and build-all cache output with the runtime lookup path so published packages include the HTML export assets at `dist/export-html`.

Adds a focused build-all regression assertion for the output path contract.

Fixes #90843
2026-06-06 09:14:12 -07:00
Vincent Koc
98498f2579 test(e2e): exercise gateway network health client 2026-06-06 18:13:00 +02:00
Vincent Koc
539f745d12 test(gateway): cover smoke health path 2026-06-06 18:09:18 +02:00
Vincent Koc
bc996d3dfa test(e2e): cover mcp code mode validation 2026-06-06 18:06:59 +02:00
nas
63fba5d2fe fix(cron): require HTTP context for server_error retries
Narrow cron server_error retry classification so incidental 500-599 numbers in failure text no longer trigger retryable server_error. Genuine HTTP/status 5xx strings, canonical 5xx phrases, 5xx, and standalone terse codes still retry.

Maintainer proof: focused cron retry tests, formatter/lint/diff checks, clean autoreview, Testbox-through-Crabbox check:changed tbx_01kteteqqrppbzgh560sybe0nk / Actions run 27066938422, and green PR CI on 6124f14850.

Fixes #90947.
2026-06-06 09:06:31 -07:00
Vincent Koc
e099c01a8c fix(e2e): require full kitchen sink tool surface 2026-06-06 18:05:13 +02:00
Vincent Koc
125329cde7 fix(e2e): assert kitchen sink rpc status payloads 2026-06-06 18:03:11 +02:00
Vincent Koc
ec4c79cb38 fix(e2e): require kitchen sink ready body 2026-06-06 18:00:47 +02:00
Vincent Koc
9c2d243803 fix(e2e): require shared gateway readyz proof 2026-06-06 17:56:38 +02:00
Vincent Koc
ac9d4ff2f0 test(gateway): include aborted chat run state in mock 2026-06-06 08:53:01 -07:00
Vincent Koc
b77ef4d6df fix(doctor): stop repeating talk normalization 2026-06-06 08:46:39 -07:00
Vincent Koc
f00e7af3e3 fix(scripts): require rtt readyz readiness 2026-06-06 17:42:19 +02:00
Vincent Koc
69a406118c fix(e2e): preserve docker cleanup failure artifacts 2026-06-06 17:37:12 +02:00
Vincent Koc
ffea7fa647 fix(memory-wiki): accept wiki apply op aliases 2026-06-06 08:35:33 -07:00
Vincent Koc
16921dba7d fix(ui): allow short tweakcn theme ids 2026-06-06 08:29:50 -07:00
Vincent Koc
97758910fa fix(e2e): reject invalid bundled runtime limits 2026-06-06 17:26:19 +02:00
Vincent Koc
d90a94ad16 fix(channels): strip dangling progress italics 2026-06-06 08:23:55 -07:00
Vincent Koc
7b4b238566 fix(tui): show models loading feedback 2026-06-06 08:16:46 -07:00
Vincent Koc
7a62cd5efc fix(gateway): count slugged daily memory status 2026-06-06 08:08:55 -07:00
Vincent Koc
6ace7a6ca8 fix(gateway): close chat abort send race 2026-06-06 08:01:39 -07:00
Vincent Koc
cee432f0f0 fix(auth): prefer agent-local auth profiles
Ensure selected-agent auth profiles are tried before inherited main-agent profiles for the same provider while preserving explicit agent auth order as a hard filter.

Fixes #64274
2026-06-06 07:48:11 -07:00
Vincent Koc
779fb9efe3 fix(e2e): require gateway network TCP readiness 2026-06-06 16:46:57 +02:00
Vincent Koc
da98896f0c test(agents): keep shell env mock current 2026-06-06 07:44:42 -07:00
Nimrod Gutman
59ed6413d9 [codex] Add iOS Apple Review demo mode (#90919)
Merged via squash.

Prepared head SHA: e7f7db3cb5
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Reviewed-by: @ngutman
2026-06-06 17:43:48 +03:00
Vincent Koc
bb056dca84 fix(e2e): require TCP gateway readiness 2026-06-06 16:40:59 +02:00
Vincent Koc
205d00bf82 fix(scripts): fail missing test group reports 2026-06-06 16:38:25 +02:00
Vincent Koc
4ff0aa9969 fix(scripts): fail gateway CPU QA summary failures 2026-06-06 16:34:59 +02:00
Vincent Koc
f02750d162 fix(docker): allow spaces in setup mount paths
Allow ordinary spaces in Docker setup host persistence paths while preserving control-character and comma mount guards. Quote generated and base Compose volume scalars so OPENCLAW_CONFIG_DIR, OPENCLAW_WORKSPACE_DIR, and auth-profile secret paths can be parsed when host directories contain spaces.
2026-06-06 07:32:01 -07:00
Vincent Koc
2a48a2655b fix(e2e): reject empty Docker lane plans 2026-06-06 16:28:24 +02:00
Vincent Koc
e0e3012c84 fix(e2e): reject invalid kitchen sink RPC guardrails 2026-06-06 16:26:11 +02:00
Vincent Koc
fc2a7be0bc fix(update): hand off supervised auto-updates 2026-06-06 07:19:46 -07:00
Vincent Koc
10fb3e110d test(live): require CLI backend provider proof 2026-06-06 16:18:20 +02:00
Vincent Koc
a210a53c19 test(live): require Android node core commands 2026-06-06 16:12:56 +02:00
Vincent Koc
51488bf914 test(gateway): add small model live profile
Add the small-model selector to the gateway live-model profile harness and document the OPENCLAW_LIVE_GATEWAY_MODELS=small recipe.\n\nVerification: node scripts/run-vitest.mjs run --config test/vitest/vitest.live.config.ts src/gateway/gateway-models.profiles.live.test.ts; GitHub Actions CI run 27064309683; CodeQL run 27064309687; OpenGrep PR Diff run 27064309689.
2026-06-06 07:10:26 -07:00
Vincent Koc
4af444ab30 fix(agents): count streamed model deltas incrementally
Count streamed text/thinking/tool-call deltas incrementally in model diagnostics instead of repeatedly estimating full event payloads. Updates diagnostics docs and OTEL wording for the new response byte baseline.\n\nVerification: node scripts/run-vitest.mjs run src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.test.ts; GitHub Actions CI run 27064304709; CodeQL run 27064304710; OpenGrep PR Diff run 27064304716.
2026-06-06 07:09:49 -07:00
Vincent Koc
5b84ebfc56 test(agents): keep camera media service mock complete 2026-06-06 16:00:30 +02:00
Vincent Koc
4ee50ce18e fix(agents): stream phased text deltas incrementally
Stream same-item phased final-answer deltas incrementally without rereading full partial assistant text on every token. Preserves sanitizer context for split hidden tool-call payloads and keeps full partial reads for item boundaries and text_end finalization.\n\nRefs https://github.com/openclaw/openclaw/issues/86599.
2026-06-06 06:38:28 -07:00
Vincent Koc
31c3e0c3f3 test(live): keep voice-note preflight inside plugins 2026-06-06 06:24:29 -07:00
Vincent Koc
f5eddc2b6d fix(gateway): dedupe delivery mirror history rows 2026-06-06 06:19:35 -07:00
Nimrod Gutman
a547010a95 fix(talk): resolve realtime provider secret refs (#90914)
Merged via squash.

Prepared head SHA: c5a52049be
Reviewed-by: @ngutman
2026-06-06 15:47:13 +03:00
Onur Solmaz
0aea58ab66 fix(memory): fail fast when embeddings provider is unavailable
Fixes #89691.

Memory search now treats explicitly configured non-local embedding providers as required. When that provider is unavailable, search and sync surface an unavailable memory-search result instead of silently returning FTS-only recall.

Unset/default/local/none-style paths keep FTS fallback so existing workflows do not lose keyword recall entirely. The fallback state is now surfaced in diagnostics/status instead of being hidden.

Maintainer merge note: current CI still has unrelated baseline boundary failures in extensions/google/google.live.test.ts and extensions/minimax/minimax.live.test.ts. This PR does not touch those files; the PR-specific memory, docs, lint, type, security, and ClawSweeper checks were reviewed before merge.
2026-06-06 20:39:38 +08:00
Vincent Koc
6b2af6c1ee fix(agents): keep safe tool images without native backend 2026-06-06 05:11:55 -07:00
Vincent Koc
0a08625d79 fix(agents): emit terminal abort lifecycle metadata
Carry terminal abort state into embedded agent lifecycle events before agent_end emits, and include terminal stopReason from the last assistant message when runner metadata is not available yet.

Fixes #66534
2026-06-06 04:54:11 -07:00
Vincent Koc
74331f632b test(live): tolerate ARM provider drift 2026-06-06 03:47:24 -07:00
joshavant
f4a5e5762e feat(android): brand onboarding welcome screen 2026-06-06 05:03:37 -05:00
joshavant
1098063783 fix(android): clarify nearby gateway discovery state 2026-06-06 05:03:37 -05:00
joshavant
b80893f30d chore(android): simplify onboarding entry actions 2026-06-06 05:03:37 -05:00
joshavant
72b387ad48 fix(android): show configured provider readiness 2026-06-06 05:03:37 -05:00
joshavant
44a72cde58 chore(android): remove provider setup footer 2026-06-06 05:03:37 -05:00
joshavant
81312e7aa3 chore(android): remove model catalog section 2026-06-06 05:03:37 -05:00
joshavant
53e50ec127 fix(android): reconnect saved gateway after disconnect 2026-06-06 05:03:37 -05:00
joshavant
485446af8c fix(android): keep sent chat messages in history 2026-06-06 05:03:37 -05:00
joshavant
81f4fe6c11 fix(android): pause gateway pairing retries 2026-06-06 05:03:37 -05:00
joshavant
a2455fcc09 fix(android): keep gateway pairing off main thread 2026-06-06 05:03:37 -05:00
joshavant
e4583b4f57 fix(android): show flavor channel in about 2026-06-06 05:03:37 -05:00
joshavant
9413a5aba5 fix(android): defer runtime startup after first draw 2026-06-06 05:03:36 -05:00
joshavant
b7cafb56fa fix(android): surface voice provider attention 2026-06-06 05:03:36 -05:00
joshavant
efea9ca0f5 chore(android): fix ktlint formatting 2026-06-06 05:03:36 -05:00
Vincent Koc
98f52dcc00 test(live): skip DeepInfra V4 Flash tool sentinel drift 2026-06-06 03:00:12 -07:00
joshavant
32b0b58868 style(ios): use app logo on onboarding intro 2026-06-06 04:41:33 -05:00
joshavant
9942428df0 fix(ios): disable chat composer while offline 2026-06-06 04:41:33 -05:00
joshavant
f40680c826 style(ios): align command section header padding 2026-06-06 04:41:33 -05:00
joshavant
a6582f787c fix(ios): remove extra root tab bottom insets 2026-06-06 04:41:33 -05:00
joshavant
a9a2c34293 fix(ios): stop marking scheduled agents busy 2026-06-06 04:41:33 -05:00
joshavant
2ef0d274fa fix(ios): hide agent sessions from recent sessions 2026-06-06 04:41:33 -05:00
joshavant
dc5c24fbe6 fix(ios): keep chat messages above composer 2026-06-06 04:41:33 -05:00
joshavant
0b87990328 fix(ios): remove command live activity section 2026-06-06 04:41:33 -05:00
joshavant
14f018e794 fix(ios): move approvals to settings 2026-06-06 04:41:33 -05:00
joshavant
81d099f0e9 fix(ios): remove command start work button 2026-06-06 04:41:33 -05:00
joshavant
e8c0d92015 fix(ios): clarify agent chat session 2026-06-06 04:41:32 -05:00
joshavant
67dc71983c fix(ios): show focused session agent 2026-06-06 04:41:32 -05:00
joshavant
be537060ce fix(ios): show recent sessions preview 2026-06-06 04:41:32 -05:00
joshavant
ea7e214bd4 Fix chat history races across agent switches 2026-06-06 04:41:32 -05:00
joshavant
7478e6e485 Fix chat session sync ownership 2026-06-06 04:41:32 -05:00
joshavant
83a6bce835 Fix iOS chat background presentation 2026-06-06 04:41:32 -05:00
joshavant
5c07f7ccf0 Fix iOS selected agent chat routing 2026-06-06 04:41:32 -05:00
joshavant
af50a5959d fix ios onboarding success screen 2026-06-06 04:41:32 -05:00
joshavant
472a30bd3f fix ios skill editor toggle hit target 2026-06-06 04:41:32 -05:00
joshavant
8f6f18b6e7 fix ios operator recovery live activity 2026-06-06 04:41:32 -05:00
joshavant
1746319db5 fix ios operator scope upgrade state 2026-06-06 04:41:32 -05:00
joshavant
19e827c969 fix ios operator admin scope requests 2026-06-06 04:41:32 -05:00
joshavant
f1cf898460 fix ios onboarding tls toggle hit targets 2026-06-06 04:41:32 -05:00
joshavant
7e6134cb12 fix ios onboarding developer toggle hit target 2026-06-06 04:41:32 -05:00
joshavant
2fb5ff3034 fix ios settings bottom scroll inset 2026-06-06 04:41:32 -05:00
joshavant
fbaa5a6f0a fix ios gateway settings control hit targets 2026-06-06 04:41:32 -05:00
joshavant
33cb1c18ac fix ios diagnostics toggle hit targets 2026-06-06 04:41:31 -05:00
joshavant
0ee7cf970c fix ios quick setup suppression toggle 2026-06-06 04:41:31 -05:00
joshavant
762540aa04 fix ios talk controls hit targets 2026-06-06 04:41:31 -05:00
joshavant
73f056a0a4 fix ios chat error banner overlap 2026-06-06 04:41:31 -05:00
joshavant
88f6857c2e fix ios onboarding mode row hit targets 2026-06-06 04:41:31 -05:00
joshavant
c29cc7f82f fix(ios): use safe area inset for settings scroll 2026-06-06 04:41:31 -05:00
Vincent Koc
d4b4a65809 fix(plugins): preserve core embedding providers 2026-06-06 00:30:48 -07:00
Vincent Koc
f94e4f85f0 test(pairing): isolate store state tests 2026-06-05 23:46:11 -07:00
Vincent Koc
c72c82726f fix(installer): print npm debug logs on Windows install failure 2026-06-05 23:16:39 -07:00
Vincent Koc
92242f4f68 fix(test): route extension tests through scoped paths 2026-06-05 22:59:54 -07:00
xydigit-sj
743051d400 fix(uninstall): refuse to remove current working directory during cleanup (#90813)
* fix(uninstall): refuse to remove current working directory during cleanup

* fix(uninstall): guard cleanup ancestors of cwd

---------

Co-authored-by: sallyom <somalley@redhat.com>
2026-06-06 01:51:16 -04:00
Vincent Koc
153a2badb0 fix(release): extend live Docker image pull timeout 2026-06-05 22:34:22 -07:00
Omar Shahine
37aaa5cc2b fix(imessage): frame rpc stdout on LF only (#90845)
Merged via squash.

Prepared head SHA: c62a2dcbf1
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Reviewed-by: @omarshahine
2026-06-05 22:31:50 -07:00
Kevin Lin
ab7c922825 fix(codex): report completion timeout diagnostics
Surface Codex-specific completion-timeout outcomes and structural diagnostics while preserving the existing replay-safe retry behavior.\n\nVerified with focused Vitest coverage, live forced-timeout Showboat proof, and green PR CI.
2026-06-05 22:00:38 -07:00
Vincent Koc
2fc4511eeb fix(release): retry provider-throttled cross-os agent turns 2026-06-05 21:58:46 -07:00
Vincent Koc
9313471fa5 fix(plugins): strengthen registry root memo fingerprint 2026-06-05 21:23:55 -07:00
brokemac79
2f46a27b40 fix(codex): preserve completed replies after client close (#90790)
Merged via squash.

Prepared head SHA: d948b3543c
Co-authored-by: brokemac79 <255583030+brokemac79@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-06-05 21:22:10 -07:00
Vincent Koc
8e9c377971 test(gateway): stabilize channel restart fake timers 2026-06-05 21:16:41 -07:00
Glucksberg
6f909f6454 Fix OpenAI audio auth to use API keys (#90793)
* fix(media): require api key auth for OpenAI audio

* fix(media): narrow OpenAI audio auth API scope

* fix(media): align OpenAI audio auth selection

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-06-06 00:11:05 -04:00
Vincent Koc
092075534e docs(changelog): complete 2026.6.5 release refs 2026-06-05 20:36:28 -07:00
Vincent Koc
04ecc1aae9 docs(changelog): complete 2026.6.5 notes 2026-06-05 20:23:13 -07:00
Vincent Koc
af4ba6221b docs(changelog): refresh 2026.6.5 notes 2026-06-05 20:04:04 -07:00
clawsweeper[bot]
9cbf18293b fix #90668: [Bug]: macOS node mode can silently self-reconnect in a healthy direct gateway session (#90815)
Summary:
- Adds a macOS node-mode TLS session cache keyed by gateway URL and TLS pin parameters, with Swift tests for reuse and rebuild behavior.
- PR surface: Other +78. Total +78 across 2 files.
- Reproducibility: yes. The source path is clear: current main supplies a fresh TLS session identity into `Gat ... inked macOS WSS proof demonstrates repeated connected callbacks before the cache and one callback after it.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(macos): make TLS session cache lint-safe
- PR branch already contained follow-up commit before automerge: fix #90668: [Bug]: macOS node mode can silently self-reconnect in a h…

Validation:
- ClawSweeper review passed for head 1496eac8c1.
- Required merge gates passed before the squash merge.

Prepared head SHA: 1496eac8c1
Review: https://github.com/openclaw/openclaw/pull/90815#issuecomment-4637057530

Co-authored-by: 张贵萍0668001030 <zhang.guiping@xydigit.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-06 02:44:59 +00:00
clawsweeper[bot]
50aaf1f9b6 fix(memory): resolve adapter default model in plain status identity check (#90816)
Summary:
- This PR updates memory-core index identity resolution to treat an empty configured model as the embedding adapter default and adds a regression test for plain memory status.
- PR surface: Source +5, Tests +33. Total +38 across 2 files.
- Reproducibility: yes. from source and inherited proof: current main compares identity against an unresolved empty model in the plain status path, and the source PR shows the before/after CLI behavior on the same index.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(memory): resolve adapter default model in plain status identity c…

Validation:
- ClawSweeper review passed for head 9741437564.
- Required merge gates passed before the squash merge.

Prepared head SHA: 9741437564
Review: https://github.com/openclaw/openclaw/pull/90816#issuecomment-4637058847

Co-authored-by: 宇宙熊Yzx <53250620+849261680@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-06 02:34:29 +00:00
Chunyue Wang
aa8070a76f fix(llm): defer Anthropic stream start event until after message_start (#90697)
Summary:
- The branch moves Anthropic `start` emission into `message_start` handling for the provider and transport stream paths and adds focused ordering/error tests.
- PR surface: Source +5, Tests +149. Total +154 across 4 files.
- Reproducibility: Do we have a high-confidence way to reproduce the issue? Yes from source: current main emit ... ecovery intentionally refuses to retry after any non-error output; no live expired-cache run was performed.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(agents): defer Anthropic transport stream start event until after…

Validation:
- ClawSweeper review passed for head 399a243c64.
- Required merge gates passed before the squash merge.

Prepared head SHA: 399a243c64
Review: https://github.com/openclaw/openclaw/pull/90697#issuecomment-4632866448

Co-authored-by: openperf <16864032@qq.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-06 02:17:54 +00:00
Yzx
b1e4b6b65e fix(agents): coerce non-text/image MCP tool-result blocks to text (fixes #90710) (#90728)
Summary:
- The PR converts wider MCP CallToolResult content blocks into text/image AgentToolResult blocks at the bundle-MCP materialization boundary and adds regression tests.
- PR surface: Source +36, Tests +66. Total +102 across 2 files.
- Reproducibility: yes. Source inspection shows current main lets MCP resource/audio blocks cross into a text/ ...  a spawned stdio MCP server; I did not run a live hosted Anthropic API round trip in this read-only review.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head f70dccf33e.
- Required merge gates passed before the squash merge.

Prepared head SHA: f70dccf33e
Review: https://github.com/openclaw/openclaw/pull/90728#issuecomment-4634126025

Co-authored-by: 宇宙熊Yzx <53250620+849261680@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-06 02:16:10 +00:00
Sahibzada
9e29375cec fix(voice-call): track Twilio streams after connect (#90607)
Summary:
- The PR moves Twilio inbound active-stream tracking from TwiML generation to `registerCallStream` and updates provider tests for connected-stream and no-stream cases.
- PR surface: Source -3, Tests +23. Total +20 across 2 files.
- Reproducibility: yes. from source inspection and supplied before/after output: on current main, one inbound  ... nd inbound parse queues even when no media stream registered. I did not run tests in this read-only review.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head 22575a9f27.
- Required merge gates passed before the squash merge.

Prepared head SHA: 22575a9f27
Review: https://github.com/openclaw/openclaw/pull/90607#issuecomment-4630012870

Co-authored-by: Sahibzada Allahyar <sahibzada@fastino.ai>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-06 02:15:00 +00:00
keshavbotagent
3a2f54e6a8 fix(telegram): suppress post-final tool error noise
Suppress non-actionable text-only tool/progress noise after Telegram final delivery while preserving terminal final warnings, media payloads, and exec approval prompts.

Use the core nonTerminalToolErrorWarning marker for recovered final tool warnings, and cover suppression plus preservation cases with regression tests.
2026-06-05 18:24:09 -07:00
Harjoth Khara
e5d1fadea7 test(codex): cover thread abandonment after completion-idle timeout (#90027)
Regression coverage for #89974. Confirms that after a
turn_completion_idle_timeout, OpenClaw clears the timed-out Codex
app-server thread binding and the next turn starts a fresh thread instead
of resuming the thread that may hold Codex's generic <turn_aborted> /
user-interrupted marker. No runtime behavior changes.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 17:41:52 -07:00
Josh Lehman
bbfe8ccaf6 fix: refresh prompt fence after compaction writes
Fix embedded attempts falsely reporting session takeover after OpenClaw-owned auto-compaction writes a compaction entry while the prompt fence is released.

The compaction append path now publishes an owned session-file fence only when the guarded SessionManager append produced the expected compaction entry. External or interleaved session-file edits remain takeover errors.

Closes #90729
2026-06-05 17:05:35 -07:00
Yzx
a4f7e4cbb9 fix(google): preserve Vertex ADC catalog auth (#90609)
* fix: preserve Google Vertex ADC catalog auth

* fix: register Google Vertex ADC config marker

* fix: fill Vertex ADC static catalog auth
2026-06-05 18:16:34 -04:00
Yzx
6da3b1f6a3 fix(agents): re-probe single-provider primary during cooldown (#90717)
Fixes #90702.

Allow a single-provider primary to periodically probe through the existing cooldown throttle even when no fallback chain is configured. This lets WHAM/subscription-limit cooldown state recover without waiting for a far-future provider reset timestamp.

Verified:
- node scripts/run-vitest.mjs src/agents/model-fallback.probe.test.ts
- git diff --check
- cherry-pick onto current origin/main and rerun focused regression
2026-06-05 14:20:57 -07:00
dependabot[bot]
2ab4eaa2b1 build(deps): bump docker/login-action from 3.6.0 to 4.1.0 (#74980)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3.6.0 to 4.1.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3.6.0...4907a6ddec9925e35a0a9e82d7399ccc52663121)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 14:12:53 -07:00
dependabot[bot]
c965141d67 chore(deps): bump the android-deps group across 1 directory with 9 updates (#86481)
Bumps the android-deps group with 9 updates in the /apps/android directory:

| Package | From | To |
| --- | --- | --- |
| [gradle-wrapper](https://github.com/gradle/gradle) | `9.4.1` | `9.5.1` |
| androidx.compose:compose-bom | `2026.04.01` | `2026.05.01` |
| [dnsjava:dnsjava](https://github.com/dnsjava/dnsjava) | `3.6.4` | `3.6.5` |
| [org.junit.vintage:junit-vintage-engine](https://github.com/junit-team/junit-framework) | `6.0.3` | `6.1.0` |
| [org.jetbrains.kotlinx:kotlinx-coroutines-android](https://github.com/Kotlin/kotlinx.coroutines) | `1.10.2` | `1.11.0` |
| [org.jetbrains.kotlinx:kotlinx-coroutines-test](https://github.com/Kotlin/kotlinx.coroutines) | `1.10.2` | `1.11.0` |
| [com.google.android.material:material](https://github.com/material-components/material-components-android) | `1.13.0` | `1.14.0` |
| com.android.application | `9.2.0` | `9.2.1` |
| com.android.test | `9.2.0` | `9.2.1` |



Updates `gradle-wrapper` from 9.4.1 to 9.5.1
- [Release notes](https://github.com/gradle/gradle/releases)
- [Commits](https://github.com/gradle/gradle/compare/v9.4.1...v9.5.1)

Updates `androidx.compose:compose-bom` from 2026.04.01 to 2026.05.01

Updates `dnsjava:dnsjava` from 3.6.4 to 3.6.5
- [Release notes](https://github.com/dnsjava/dnsjava/releases)
- [Changelog](https://github.com/dnsjava/dnsjava/blob/master/Changelog)
- [Commits](https://github.com/dnsjava/dnsjava/commits)

Updates `org.junit.vintage:junit-vintage-engine` from 6.0.3 to 6.1.0
- [Release notes](https://github.com/junit-team/junit-framework/releases)
- [Commits](https://github.com/junit-team/junit-framework/compare/r6.0.3...r6.1.0)

Updates `org.jetbrains.kotlinx:kotlinx-coroutines-android` from 1.10.2 to 1.11.0
- [Release notes](https://github.com/Kotlin/kotlinx.coroutines/releases)
- [Changelog](https://github.com/Kotlin/kotlinx.coroutines/blob/master/CHANGES.md)
- [Commits](https://github.com/Kotlin/kotlinx.coroutines/compare/1.10.2...1.11.0)

Updates `org.jetbrains.kotlinx:kotlinx-coroutines-test` from 1.10.2 to 1.11.0
- [Release notes](https://github.com/Kotlin/kotlinx.coroutines/releases)
- [Changelog](https://github.com/Kotlin/kotlinx.coroutines/blob/master/CHANGES.md)
- [Commits](https://github.com/Kotlin/kotlinx.coroutines/compare/1.10.2...1.11.0)

Updates `org.jetbrains.kotlinx:kotlinx-coroutines-test` from 1.10.2 to 1.11.0
- [Release notes](https://github.com/Kotlin/kotlinx.coroutines/releases)
- [Changelog](https://github.com/Kotlin/kotlinx.coroutines/blob/master/CHANGES.md)
- [Commits](https://github.com/Kotlin/kotlinx.coroutines/compare/1.10.2...1.11.0)

Updates `com.google.android.material:material` from 1.13.0 to 1.14.0
- [Release notes](https://github.com/material-components/material-components-android/releases)
- [Commits](https://github.com/material-components/material-components-android/compare/1.13.0...1.14.0)

Updates `com.android.application` from 9.2.0 to 9.2.1

Updates `com.android.test` from 9.2.0 to 9.2.1

Updates `com.android.test` from 9.2.0 to 9.2.1

---
updated-dependencies:
- dependency-name: androidx.compose:compose-bom
  dependency-version: 2026.05.01
  dependency-type: direct:production
  dependency-group: android-deps
- dependency-name: com.android.application
  dependency-version: 9.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: android-deps
- dependency-name: com.android.test
  dependency-version: 9.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: android-deps
- dependency-name: com.android.test
  dependency-version: 9.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: android-deps
- dependency-name: com.google.android.material:material
  dependency-version: 1.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: android-deps
- dependency-name: dnsjava:dnsjava
  dependency-version: 3.6.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: android-deps
- dependency-name: gradle-wrapper
  dependency-version: 9.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: android-deps
- dependency-name: org.jetbrains.kotlinx:kotlinx-coroutines-android
  dependency-version: 1.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: android-deps
- dependency-name: org.jetbrains.kotlinx:kotlinx-coroutines-test
  dependency-version: 1.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: android-deps
- dependency-name: org.jetbrains.kotlinx:kotlinx-coroutines-test
  dependency-version: 1.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: android-deps
- dependency-name: org.junit.vintage:junit-vintage-engine
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: android-deps
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 14:07:08 -07:00
dependabot[bot]
c6972a0664 chore(deps): bump github.com/apple/swift-testing (#81757)
Bumps the swift-deps group with 1 update in the /apps/swabble directory: [github.com/apple/swift-testing](https://github.com/apple/swift-testing).


Updates `github.com/apple/swift-testing` from 6.3.1 to 6.3.2
- [Release notes](https://github.com/apple/swift-testing/releases)
- [Commits](https://github.com/apple/swift-testing/compare/6.3.1...6.3.2)

---
updated-dependencies:
- dependency-name: github.com/apple/swift-testing
  dependency-version: 6.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swift-deps
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 14:05:15 -07:00
dependabot[bot]
662d366f01 chore(deps): bump the actions group across 1 directory with 4 updates (#90601)
Bumps the actions group with 4 updates in the / directory: [github/codeql-action](https://github.com/github/codeql-action), [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action), [docker/build-push-action](https://github.com/docker/build-push-action) and [openai/codex-action](https://github.com/openai/codex-action).


Updates `github/codeql-action` from 4 to 4.36.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Commits](https://github.com/github/codeql-action/compare/v4...v4.36.1)

Updates `docker/setup-buildx-action` from 4.0.0 to 4.1.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](4d04d5d948...d7f5e7f509)

Updates `docker/build-push-action` from 7.1.0 to 7.2.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](bcafcacb16...f9f3042f7e)

Updates `openai/codex-action` from 1.7 to 1.8
- [Changelog](https://github.com/openai/codex-action/blob/main/CHANGELOG.md)
- [Commits](5c3f4ccdb2...e0fdf01220)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.36.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/build-push-action
  dependency-version: 7.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: openai/codex-action
  dependency-version: '1.8'
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 13:37:40 -07:00
dependabot[bot]
aee45f5f73 chore(deps): bump the swift-deps group across 1 directory with 3 updates (#86483)
Bumps the swift-deps group with 3 updates in the /apps/macos directory: [github.com/apple/swift-log](https://github.com/apple/swift-log), [github.com/sparkle-project/sparkle](https://github.com/sparkle-project/Sparkle) and [github.com/steipete/peekaboo](https://github.com/steipete/Peekaboo).


Updates `github.com/apple/swift-log` from 1.12.0 to 1.13.1
- [Release notes](https://github.com/apple/swift-log/releases)
- [Commits](https://github.com/apple/swift-log/compare/1.12.0...1.13.1)

Updates `github.com/sparkle-project/sparkle` from 2.9.1 to 2.9.2
- [Release notes](https://github.com/sparkle-project/Sparkle/releases)
- [Commits](https://github.com/sparkle-project/Sparkle/compare/2.9.1...2.9.2)

Updates `github.com/steipete/peekaboo` from 3.2.1 to 3.3.0
- [Release notes](https://github.com/steipete/Peekaboo/releases)
- [Commits](https://github.com/steipete/Peekaboo/compare/v3.2.1...v3.3.0)

---
updated-dependencies:
- dependency-name: github.com/apple/swift-log
  dependency-version: 1.12.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swift-deps
- dependency-name: github.com/sparkle-project/sparkle
  dependency-version: 2.9.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swift-deps
- dependency-name: github.com/steipete/peekaboo
  dependency-version: 3.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: swift-deps
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 13:10:45 -07:00
Dallin Romney
ac9a219692 fix(tui): stabilize optimistic user messages across history reloads, runId reassignment, and abort (#86205)
* fix(tui): preserve optimistic user messages

* refactor(tui): drop unused pending-user chat-log helpers

* fix(tui): reconcile optimistic user row across runId reassignment and abort

* refactor(tui): reuse asDateTimestampMs for history timestamp coercion

* test(tui): fix event-handler chatLog render mock arity
2026-06-05 12:09:24 -07:00
Matt H
db7d70ae4d feat(parallel): add Parallel as a bundled web_search provider (#85158)
- New extensions/parallel package modeled on extensions/exa
- Wires Parallel's POST /v1/search through the generic web_search contract,
  exposing Parallel's recommended {objective, search_queries} shape (plus
  optional count, session_id, client_model) so the model can supply both the
  natural-language goal and 2-3 short keyword queries as Parallel docs advise
- client_model lets the model report its own slug so Parallel can tailor
  optimizations for the consuming model's capabilities; partitions the cache
  by client_model so different models do not silently share ranked excerpts
- Honors top-level tools.web.search.{maxResults,timeoutSeconds,cacheTtlMinutes}
  via the shared SDK helpers (mergeScopedSearchConfig, withTrustedWebSearchEndpoint,
  buildSearchCacheKey, read/writeCachedSearchPayload)
- Auto-detect order 75; auth via PARALLEL_API_KEY or
  plugins.entries.parallel.config.webSearch.apiKey
- Optional baseUrl override for proxies (e.g. Cloudflare AI Gateway)
- Threads caller-supplied session_id through follow-up calls; strips
  auto-generated session_id from the shared cache to avoid cross-task leaks
- Always sends advanced_settings.max_results so result volume matches the
  OpenClaw web_search default (5) instead of Parallel's default (10)
- Identifies the plugin via User-Agent header built from package version
- Runtime accepts the generic `query` arg as a fallback so the operator
  CLI (openclaw capability web.search) keeps working when Parallel is the
  active provider: it is promoted into the lone `search_queries` entry.
  `objective` stays optional and is never synthesized from a keyword
  query (Parallel documents it as natural-language intent). Agent callers
  using the native objective+search_queries shape take precedence; the
  schema still advertises only the native keys
- Updates the agent tool-display extractor (src/agents/tool-display-common.ts)
  to recognize Parallel's objective+search_queries shape so calls render with
  query context in CLI progress and Codex activity metadata
- Adds /tools/parallel-search docs page, web.md provider listing, docs nav,
  labeler entry, per-plugin registration contract test, and minimal core
  touch-points (legacy migrate, registration cases, providers contract list,
  runtime bundled list, vitest extension paths)
2026-06-05 12:01:58 -07:00
Jason (Json)
36d9241cf7 docs: prefer web_fetch in weather skill (#90250)
* docs: prefer web_fetch in weather skill

* docs: use compact wttr json in weather skill
2026-06-05 14:35:55 -04:00
zenglingbiao
d896a4c7a3 fix(context-engine): forward isHeartbeat to afterTurn (fixes #89302) (#90632)
Merged via squash.

Prepared head SHA: 2f6da84c4b
Co-authored-by: zenglingbiao <290951975+zenglingbiao@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-06-05 11:27:37 -07:00
Gio Della-Libera
b3eba2ff38 fix(gateway): dedupe probe warnings by gateway identity (#85791)
Merged via squash.

Prepared head SHA: 13e3c00f56
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl_microsoft <115749436+giodl_microsoft@users.noreply.github.com>
Reviewed-by: @giodl_microsoft
2026-06-05 10:23:12 -07:00
Ted Li
21aa297434 fix(cron): auto-migrate legacy cron store (#90208)
Merged via squash.

Prepared head SHA: f5aa1b6759
Co-authored-by: MonkeyLeeT <6754057+MonkeyLeeT@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-06-05 10:22:02 -07:00
Shakker
4752e9a67d test: bracket provider worker env 2026-06-05 17:09:55 +01:00
Shakker
ec91dce0b8 test: scope internal transcript state env 2026-06-05 17:08:30 +01:00
Shakker
fbbb88925a test: scope openrouter capability env 2026-06-05 17:06:09 +01:00
Shakker
9235c25d33 test: clean model cache state env 2026-06-05 17:04:19 +01:00
Shakker
6ce71737e5 test: manage workspace state fixture 2026-06-05 17:02:10 +01:00
Shakker
935c80d6e1 test: use managed skill workshop state 2026-06-05 17:01:20 +01:00
Vincent Koc
286772e930 test: shorten vitest no-output heartbeat 2026-06-05 09:00:02 -07:00
Shakker
b19904931e test: contain sessions tool state env 2026-06-05 16:58:37 +01:00
Shakker
415272d17e test: isolate pdf media state env 2026-06-05 16:56:05 +01:00
Shakker
002aa1061b test: narrow media tool state env 2026-06-05 16:54:58 +01:00
Shakker
8a83c13389 test: bound sandbox media state env 2026-06-05 16:53:44 +01:00
Shakker
a16b6c02ce test: pair cron task state env 2026-06-05 16:52:47 +01:00
Peter Steinberger
2514980118 feat(matrix): handle voice preflight and threads (#90415)
* feat(matrix): handle voice preflight and threads

Co-authored-by: Frank Dierolf <frank_dierolf@web.de>
Co-authored-by: marc.wilson <marcwilson@gazasrv15i5.globaladvisors.biz>

* test(matrix): satisfy ci guards

* fix(matrix): preserve thread relations on edits

* chore: annotate deprecated compatibility aliases

* fix(matrix): include poll thread roots in reads

* test(matrix): enable audio preflight qa config

* test(matrix): make voice preflight QA mention deterministic

---------

Co-authored-by: Frank Dierolf <frank_dierolf@web.de>
Co-authored-by: marc.wilson <marcwilson@gazasrv15i5.globaladvisors.biz>
2026-06-05 08:49:35 -07:00
Shakker
c85b0ee3db test: scope subagent sqlite state env 2026-06-05 16:48:32 +01:00
Shakker
1e683ff245 test: scope auth path state env 2026-06-05 16:46:55 +01:00
Shakker
fc0b141445 test: contain launch restart home env 2026-06-05 16:45:08 +01:00
Shakker
a0840cad8f test: scope restart sentinel state env 2026-06-05 16:43:32 +01:00
Shakker
03b35b53e3 test: delegate media redirect state env 2026-06-05 16:43:00 +01:00
Peter Steinberger
797bcd5bdb fix: propagate ClickClack toolsAllow through replies
Propagate ClickClack account-level runtime tool allowlists through inbound reply dispatch so restricted ClickClack accounts keep their tool policy when model/agent replies are generated.

This threads `toolsAllow` through shared dispatch, provider wrappers, embedded agent execution, and ACP hook events. ACP-bound sessions now fail closed for restrictive runtime allowlists because ACPX cannot enforce per-turn tool allowlists on reused persistent sessions.

Verification:
- Live ClickClack E2E on Crabbox AWS `run_6a0472ed7e71`, provider `aws`, id `cbx_dace25addcaa`.
- `node scripts/run-vitest.mjs run src/auto-reply/reply/dispatch-acp.test.ts src/plugin-sdk/acp-runtime.test.ts src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts src/auto-reply/dispatch.test.ts src/auto-reply/reply/agent-runner-execution.test.ts src/auto-reply/reply/provider-dispatcher.test.ts extensions/clickclack/src/inbound.test.ts --reporter=verbose`
- Crabbox changed gate `run_d32af37fb265`, provider `aws`, id `cbx_8236876017c9`: `corepack pnpm check:changed`
- Autoreview clean: `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`

Supersedes #89500.

Co-authored-by: Michael Appel <mappel@nvidia.com>
2026-06-05 08:40:35 -07:00
Shakker
5a0f9cb03c test: scope logging config path env 2026-06-05 16:39:50 +01:00
Shakker
e4de53a460 test: snapshot flows state env 2026-06-05 16:38:23 +01:00
Shakker
d1fe0184b9 test: preserve secrets state env snapshot 2026-06-05 16:37:09 +01:00
Vincent Koc
da88940c6c fix(android): skip gradle resource tasks on linux arm 2026-06-05 08:14:42 -07:00
Ayaan Zaidi
520992a1de test(gateway): avoid future session fixture timestamps 2026-06-05 18:19:19 +05:30
Ayaan Zaidi
00d21a4720 test(telegram): align transcript append mock 2026-06-05 18:19:19 +05:30
Ayaan Zaidi
3d68f7e5f7 test(gateway): stabilize live session metadata fixture 2026-06-05 18:19:19 +05:30
Ayaan Zaidi
ceee4c6b01 fix(sessions): mark transcript rewrites in registry 2026-06-05 18:19:19 +05:30
Fermin Quant
e22e857ddd fix(sessions): keep transcript append result discriminant 2026-06-05 18:19:19 +05:30
Fermin Quant
57bed6ae0c fix(sessions): cover terminal transcript markers 2026-06-05 18:19:19 +05:30
Fermin Quant
0c9ac48d2c fix(sessions): reconcile stale terminal main transcripts 2026-06-05 18:19:19 +05:30
Ayaan Zaidi
afa04d6454 fix(gateway): share codex model visibility 2026-06-05 17:14:34 +05:30
Ayaan Zaidi
85343ea546 fix(gateway): fail closed for unknown model auth 2026-06-05 17:14:34 +05:30
Ayaan Zaidi
d6dbcb2f4b fix(android): surface expiring providers in palette 2026-06-05 17:14:34 +05:30
Ayaan Zaidi
61d121f1ca fix(android): show unavailable model rows as attention 2026-06-05 17:14:34 +05:30
Ayaan Zaidi
21512a696f fix(gateway): preserve codex alias model availability 2026-06-05 17:14:34 +05:30
Ayaan Zaidi
ea1ef72394 fix(gateway): keep unresolved profile refs unknown 2026-06-05 17:14:34 +05:30
Ayaan Zaidi
7c885528ba fix(gateway): recognize env profile refs in model availability 2026-06-05 17:14:34 +05:30
Ayaan Zaidi
cec5e36a39 fix(gateway): avoid resolving auth during models list 2026-06-05 17:14:34 +05:30
Ayaan Zaidi
e404ce98f5 fix(gateway): require resolved auth for model availability 2026-06-05 17:14:34 +05:30
Ayaan Zaidi
30160933f0 refactor(android): distill provider availability cleanup 2026-06-05 17:14:34 +05:30
Tosko4
8b66003a0b fix(android): clarify provider attention state 2026-06-05 17:14:34 +05:30
Chunyue Wang
12a569109b fix(agents): detect unsigned thinking-only stall when reasoning payload inflates payloadCount (#89874)
Summary:
- Merged fix(agents): detect unsigned thinking-only stall when reasoning payload inflates payloadCount after ClawSweeper review.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head c613c3884f.
- Required merge gates passed before the squash merge.

Prepared head SHA: c613c3884f
Review: https://github.com/openclaw/openclaw/pull/89874#issuecomment-4630564594

Co-authored-by: openperf <16864032@qq.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-05 10:29:18 +00:00
Chunyue Wang
1a3ce7c2a8 fix(qqbot): sanitize outbound text to strip reasoning/thinking content (#90132)
Summary:
- Adds QQBot outbound `sanitizeText` wired to `sanitizeAssistantVisibleText` plus a regression test for stripping `<thinking>` and `<think>` blocks.
- PR surface: Source +2, Tests +19. Total +21 across 2 files.
- Reproducibility: yes. source-reproducible: current main QQBot outbound lacks `sanitizeText`, and shared deli ... nnel text sanitization when that hook exists. I did not run a live Tencent QQBot plus MiniMax reproduction.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(qqbot): add curly braces for eslint(curly) compliance

Validation:
- ClawSweeper review passed for head 17cf140183.
- Required merge gates passed before the squash merge.

Prepared head SHA: 17cf140183
Review: https://github.com/openclaw/openclaw/pull/90132#issuecomment-4618527026

Co-authored-by: openperf <16864032@qq.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-05 06:57:16 +00:00
ooiuuii
560b77a4af test: add Codex session route migration coverage (#90319)
* Add Codex session route migration coverage

* Use synthetic Telegram session id in Codex test
2026-06-04 23:28:08 -07:00
ooiuuii
cfd5f1ad13 Add Codex multi-agent migration coverage (#90317) 2026-06-04 23:27:34 -07:00
Kevin Lin
d7759c6a35 feat(googlechat): add native approval cards
## Summary

- Adds native Google Chat approval cards for exec and plugin approval requests that originate from Google Chat spaces or threads.
- Uses opaque server-side action tokens for Google Chat `cardsV2` button callbacks and updates delivered approval messages after resolution or expiry.
- Preserves the shipped Google Chat typing-message default while keeping approval cards on the channel-local native path.
- Suppresses duplicate manual `/approve ...` follow-up delivery inside `extensions/googlechat/` when the native card path owns the approval prompt.
- Documents Google Chat native approval behavior and the `typingIndicator: "message"` default.

## Linked context

Which issue does this close?

Closes #

Which issues, PRs, or discussions are related?

Related Spec 24.8: Google Chat native approval cards.

Was this requested by a maintainer or owner?

Requested by maintainer in the Codex task thread.

## Real behavior proof (required for external PRs)

- Behavior addressed: Google Chat exec and plugin approvals render as native cards and resolve through Google Chat button clicks. The latest change verifies an exec approval card is not accompanied by a duplicate manual `/approve` instruction bubble.
- Real environment tested: OpenClaw dev profile with a real Google Chat DM to the OpenClaw app, local gateway behind a temporary Cloudflare quick tunnel, and Arc/Computer Use against the signed-in Google Chat session.
- Exact steps or command run after this patch: Rebuilt the gateway runtime, started the dev-profile gateway with the Google Chat webhook routed through the tunnel, sent a fresh exec request from Google Chat, verified only the native approval card appeared, clicked `Allow Once` in Google Chat, and checked the command output reply plus marker file.
- Evidence after fix (screenshot, recording, terminal capture, console output, redacted runtime log, linked artifact, or copied live output): Latest proof used nonce `GCHAT_NODOUBLE_LIVE_20260604070730`, approval id `949bc08c-9e57-47c0-b045-137603782292`, and proof directory `.mem/main/proofs/demo-89502-dev-gchat-exec-approval-no-double-send-channel-race/`. `raw/google-chat-gchat-nodouble-request-card-only-clean.png` shows the fresh user message followed by a single native `Exec Approval Required` card with `Allow Once`/`Deny` and no manual `/approve` follow-up bubble. `raw/google-chat-gchat-nodouble-resolved-clean.png` shows the card edited to `Exec Approval: Allowed once` and the final successful command reply. `raw/gchat-nodouble-live-filtered-log.txt` contains `googlechat approval resolved id=949bc08c-9e57-47c0-b045-137603782292 decision=allow-once`. `raw/marker-file-check.txt` records `/tmp/openclaw-gchat-no-double-GCHAT_NODOUBLE_LIVE_20260604070730` as created.
- Observed result after fix: The approval prompt posted as a native Google Chat card only. No duplicate manual approval-instruction bubble was sent. Clicking `Allow Once` resolved the approval through the gateway and OpenClaw replied with the successful exec output in the same Google Chat DM.
- What was not tested: A persistent production Google Chat app URL; live proof used a temporary Cloudflare tunnel for the local dev callback.
- Proof limitations or environment constraints: Video was not captured for the final resumed manual UI run; still screenshots, gateway/proxy logs, a marker-file artifact, and Showboat verification were captured.
- Before evidence (optional but encouraged): Before the final channel-local suppression path, Google Chat could show both the native approval card and a separate manual `/approve` instruction bubble.

## Tests and validation

Which commands did you run?

- `node scripts/build-all.mjs gatewayWatch`
- `node scripts/run-vitest.mjs extensions/googlechat/src/monitor-webhook.test.ts extensions/googlechat/src/monitor.test.ts extensions/googlechat/src/monitor.reply-delivery.test.ts extensions/googlechat/src/monitor-durable.test.ts extensions/googlechat/src/approval-card-actions.test.ts extensions/googlechat/src/approval-handler.runtime.test.ts extensions/googlechat/src/approval-native.test.ts extensions/googlechat/src/approval-card-click.test.ts extensions/googlechat/src/channel-config.test.ts extensions/googlechat/src/targets.test.ts`
- `git diff --check`
- `pnpm docs:list`
- `uvx showboat --workdir .mem/main/proofs/demo-89502-dev-gchat-exec-approval-no-double-send-channel-race verify .mem/main/proofs/demo-89502-dev-gchat-exec-approval-no-double-send-channel-race/raw/showboat-summary.md`
- Live dev-profile Google Chat proof described above.

What regression coverage was added or updated?

- Added Google Chat native approval capability, runtime delivery, card token, and card-click resolver tests.
- Added in-flight native card send suppression coverage so manual follow-up text is suppressed while native card delivery is pending.
- Added cleanup coverage so manual follow-ups are restored if native card send fails.
- Updated webhook ACK coverage for card-click events and default typing-indicator behavior coverage.

What failed before this fix, if known?

Google Chat could deliver the native approval card and still allow a model/message-tool manual `/approve` follow-up to appear as a second visible bubble.

If no test was added, why not?

Tests were added for the changed runtime and webhook behavior.

## Risk checklist

Did user-visible behavior change? (`Yes/No`)

Yes.

Did config, environment, or migration behavior change? (`Yes/No`)

No migration. The shipped Google Chat `typingIndicator: "message"` default is preserved.

Did security, auth, secrets, network, or tool execution behavior change? (`Yes/No`)

Yes.

What is the highest-risk area?

Approval authorization and callback token handling for native Google Chat card actions.

How is that risk mitigated?

Callbacks carry opaque action tokens only, token bindings check account, space, message, expiry, allowed decision, and in-flight state, and actor authorization reuses the existing Google Chat approver allowlist adapter based on stable `users/<id>` principals.

## Current review state

What is the next action?

Merge after current-head CI for `5923f2af46`.

What is still waiting on author, maintainer, CI, or external proof?

Current-head CI is green for `5923f2af46`; live dev-profile proof is complete.

Which bot or reviewer comments were addressed?

Addressed duplicate approval delivery by keeping the final suppression path inside `extensions/googlechat/`, preserving default typing-message behavior, and proving the current Google Chat surface sends only the native approval card.
2026-06-04 23:05:06 -07:00
Vincent Koc
e0018382eb fix(agents): reject empty completion handoffs 2026-06-04 21:33:42 -07:00
clawsweeper[bot]
69d1d78649 fix(mattermost): anchor slash state on globalThis (#68113) (#90534)
Summary:
- The branch stores Mattermost slash-command account state in a process-wide Symbol.for/globalThis Map and adds module-reload regression coverage.
- PR surface: Source +21, Tests +43. Total +64 across 2 files.
- Reproducibility: yes. at source level: current main's route handler returns 503 when its module-local accoun ... pulate state through a separate loader path. I did not run a live Mattermost POST in this read-only review.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head 3cf28a1f96.
- Required merge gates passed before the squash merge.

Prepared head SHA: 3cf28a1f96
Review: https://github.com/openclaw/openclaw/pull/90534#issuecomment-4627897262

Co-authored-by: ben.li <ly85206559@163.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-06-05 04:10:43 +00:00
Peter Steinberger
cb5bb9b936 docs: document e2e helpers 2026-06-05 00:04:03 -04:00
Peter Steinberger
bafe17e60b docs: document vitest routing maps 2026-06-04 23:59:11 -04:00
Peter Steinberger
613a2835cb docs: document scoped script helpers 2026-06-04 23:57:22 -04:00
Peter Steinberger
a59eba3ee1 docs: document test project scripts 2026-06-04 23:55:54 -04:00
Peter Steinberger
9b1a01e4f9 docs: document test wrapper scripts 2026-06-04 23:54:19 -04:00
Peter Steinberger
29746cf7a9 docs: document smoke test scripts 2026-06-04 23:53:10 -04:00
joshavant
17ab517047 fix(ios): use dynamic settings bottom margin 2026-06-04 22:52:52 -05:00
joshavant
697eeb8bab fix(ios): keep diagnostics action reachable 2026-06-04 22:52:52 -05:00
joshavant
853f1c0d9e fix(ios): keep gateway row grouped and tappable 2026-06-04 22:52:52 -05:00
joshavant
1447a4507a fix(ios): keep talk unavailable without config 2026-06-04 22:52:52 -05:00
joshavant
748881e0a8 fix(ios): label chat attachment button 2026-06-04 22:52:52 -05:00
Peter Steinberger
ff83d4d164 docs: document runner scripts 2026-06-04 23:52:06 -04:00
Vincent Koc
13078d24ab chore(release): refresh plugin sdk api baseline 2026-06-04 20:50:17 -07:00
Vincent Koc
48c19590eb fix(test): install playwright deps after host validation failure 2026-06-04 20:50:17 -07:00
Peter Steinberger
72547a1ac6 docs: document release audit scripts 2026-06-04 23:49:34 -04:00
Peter Steinberger
26bc069308 docs: document profiling scripts 2026-06-04 23:48:20 -04:00
Peter Steinberger
57f8d71c50 docs: document release runner scripts 2026-06-04 23:46:55 -04:00
Peter Steinberger
980c91d293 docs: document ci dependency docs scripts 2026-06-04 23:42:32 -04:00
Peter Steinberger
6b0ffa2106 docs: document package boundary scripts 2026-06-04 23:37:42 -04:00
Peter Steinberger
056421f4f8 docs: document root runtime guard scripts 2026-06-04 23:34:16 -04:00
Dallin Romney
fb750e6eed Fix main CI guard drift (#90532) 2026-06-04 20:31:41 -07:00
Peter Steinberger
978fdd7d2a docs: document root guard scripts 2026-06-04 23:30:59 -04:00
Peter Steinberger
74f3baebb7 docs: document root build check scripts 2026-06-04 23:28:04 -04:00
Peter Steinberger
deff9ea180 docs: document cjs bridge headers 2026-06-04 23:26:24 -04:00
Peter Steinberger
9fd5f9ee7c docs: document source bridge files 2026-06-04 23:25:42 -04:00
Vincent Koc
4dd7bc6d88 fix(test): stage live docker home credentials 2026-06-04 20:22:35 -07:00
Onur Solmaz
0dbf17471b feat(memory): support qmd query rerank toggle
Add memory.qmd.rerank as an opt-out for QMD query reranking when searchMode is query.

When set to false, direct QMD query calls pass --no-rerank and the mcporter unified query tool receives rerank:false. Search and vsearch modes keep their existing behavior.

Refs #61834.
2026-06-05 11:18:57 +08:00
Peter Steinberger
f3abe61b78 docs: document script lib test helpers 2026-06-04 23:08:26 -04:00
Peter Steinberger
92cdcae500 docs: document script lib report helpers 2026-06-04 23:07:12 -04:00
Peter Steinberger
3cf1bd22f9 docs: document script lib runtime package helpers 2026-06-04 23:05:22 -04:00
Peter Steinberger
44cd0ec13f docs: document script lib plugin helpers 2026-06-04 23:03:25 -04:00
Peter Steinberger
d77bac8911 docs: document script lib package helpers 2026-06-04 23:01:02 -04:00
Peter Steinberger
1da49dcfd0 docs: document script lib process helpers 2026-06-04 22:59:33 -04:00
Peter Steinberger
ee74fff7ad docs: document script lib inventory helpers 2026-06-04 22:57:30 -04:00
Peter Steinberger
1de46bb425 docs: document script lib extension helpers 2026-06-04 22:56:09 -04:00
Peter Steinberger
e662435067 docs: document script lib guard helpers 2026-06-04 22:54:18 -04:00
Peter Steinberger
62a6fd8139 docs: document script lib scan helpers 2026-06-04 22:52:34 -04:00
Peter Steinberger
88158525a7 docs: document script lib helper contracts 2026-06-04 22:51:08 -04:00
Peter Steinberger
c8bb7330b5 docs: add headers to build check scripts 2026-06-04 22:49:21 -04:00
Peter Steinberger
8732ef2f28 docs: document channel sdk core contracts 2026-06-04 22:46:51 -04:00
Peter Steinberger
8f6e71087b docs: document agent harness sdk contracts 2026-06-04 22:45:30 -04:00
Peter Steinberger
9448f91e6f docs: document memory runtime contracts 2026-06-04 22:44:01 -04:00
Peter Steinberger
5613a0fb6e docs: document discord sdk facade contracts 2026-06-04 22:42:23 -04:00
Peter Steinberger
82710b4f1f docs: document lmstudio runtime contracts 2026-06-04 22:41:26 -04:00
Peter Steinberger
d23558e691 docs: document qa runtime facade contracts 2026-06-04 22:40:12 -04:00
Peter Steinberger
2f00fbf28e docs: document tts runtime contracts 2026-06-04 22:39:02 -04:00
Peter Steinberger
86872e0880 docs: document channel approval ingress contracts 2026-06-04 22:38:03 -04:00
Peter Steinberger
506c2ee181 docs: document qa video gateway sdk contracts 2026-06-04 22:34:53 -04:00
Peter Steinberger
1e6fb5089b docs: document approval reaction reply contracts 2026-06-04 22:32:37 -04:00
Peter Steinberger
14690904f0 docs: document browser session oauth sdk contracts 2026-06-04 22:31:20 -04:00
Peter Steinberger
99bb94589b docs: document sdk facade loader contracts 2026-06-04 22:29:06 -04:00
Peter Steinberger
de4571da4b docs: document sdk dedupe and group contracts 2026-06-04 22:27:50 -04:00
Peter Steinberger
a4087c54b5 docs: document provider facade constants 2026-06-04 22:26:17 -04:00
Peter Steinberger
4756d6a42a docs: document sdk migration and approval contracts 2026-06-04 22:24:26 -04:00
Peter Steinberger
9e22b8560c docs: document sdk facade contracts 2026-06-04 22:22:21 -04:00
Peter Steinberger
c1b49bb1d0 docs: document sdk payload and fetch contracts 2026-06-04 22:20:19 -04:00
Peter Steinberger
d6c0f9ccb8 docs: document sdk utility contracts 2026-06-04 22:18:33 -04:00
Peter Steinberger
5d350e785a docs: document sdk single-export contracts 2026-06-04 22:16:29 -04:00
Peter Steinberger
de68623ffe docs: document sdk runtime helper contracts 2026-06-04 22:14:54 -04:00
Peter Steinberger
848f39e70d docs: document public sdk contract helpers 2026-06-04 22:13:13 -04:00
Peter Steinberger
b311fd607f docs: document generated locale bundles 2026-06-04 22:11:11 -04:00
Patrick Erichsen
8f85f94946 feat: install GitHub-backed ClawHub skills (#90478)
* feat: install GitHub-backed ClawHub skills

* fix: satisfy ClawHub install type checks

* fix: harden github-backed skill installs

* fix: keep heartbeat template non-actionable

* feat: support forcing pending ClawHub installs
2026-06-04 19:10:02 -07:00
Peter Steinberger
5380d11977 docs: document scoped extension sources 2026-06-04 22:07:59 -04:00
Peter Steinberger
23716de446 docs: document discord extension sources 2026-06-04 22:06:01 -04:00
Peter Steinberger
efd1a9ace6 docs: document messaging extension sources 2026-06-04 22:03:15 -04:00
Sally O'Malley
7ac1eeb122 fix service env placeholder collection (#90488)
Signed-off-by: sallyom <somalley@redhat.com>
2026-06-04 22:02:24 -04:00
Peter Steinberger
58912f8fd8 docs: document channel extension sources 2026-06-04 21:59:00 -04:00
Peter Steinberger
6868cde4d4 docs: document large extension sources 2026-06-04 21:40:44 -04:00
Peter Steinberger
3c7c25afd2 fix: accept codex app-server auth aliases 2026-06-04 21:40:36 -04:00
Peter Steinberger
96e5812426 docs: document medium extension sources 2026-06-04 21:33:54 -04:00
Shakker
126ebfc997 test: share probe target env cleanup 2026-06-05 02:20:14 +01:00
Shakker
4b151593e2 test: scope model scan key env 2026-06-05 02:18:51 +01:00
Shakker
56f652b499 test: pin oauth tls brew env 2026-06-05 02:17:15 +01:00
Shakker
5a704d26a1 test: localize control ui home env 2026-06-05 02:15:55 +01:00
Shakker
e7bcbd3e7e test: isolate windows acl system root 2026-06-05 02:14:03 +01:00
Shakker
afcf1ddb9d test: confine web fetch key env 2026-06-05 02:12:53 +01:00
Shakker
85e16da2b4 test: scope preauth budget env 2026-06-05 02:11:44 +01:00
Shakker
38e142657b test: bound proxy resolver env 2026-06-05 02:10:43 +01:00
Shakker
4dd00347fc test: contain web media home env 2026-06-05 02:09:30 +01:00
Shakker
0973eb61c3 test: snapshot pairing gateway env 2026-06-05 02:07:35 +01:00
Shakker
e282cb2af5 test: contain media roots state env 2026-06-05 02:05:18 +01:00
Shakker
6f419b3853 test: narrow media proxy env scope 2026-06-05 02:04:56 +01:00
Peter Steinberger
4fa5092cdc docs: document small extension sources 2026-06-04 21:02:07 -04:00
Shakker
53a3d58d62 test: isolate npm update smoke env 2026-06-05 02:00:29 +01:00
Shakker
cef423d066 test: isolate group report planner env 2026-06-05 01:58:47 +01:00
Shakker
5cf63f295b test: snapshot exec audit home env 2026-06-05 01:57:47 +01:00
Shakker
86d958647f test: scope embedded shutdown grace env 2026-06-05 01:57:25 +01:00
Peter Steinberger
12a56d4d46 docs: document control ui sources 2026-06-04 20:57:09 -04:00
Shakker
39cc11ad28 test: scope tui shutdown grace env 2026-06-05 01:56:16 +01:00
Peter Steinberger
4df95d3c3f docs: document package sources 2026-06-04 20:54:41 -04:00
Peter Steinberger
b8d08f0cfd docs: document repository scripts 2026-06-04 20:52:50 -04:00
Shakker
95d51c5fe8 test: snapshot redact config env 2026-06-05 01:51:48 +01:00
Shakker
5c6a501269 test: snapshot log tail config env 2026-06-05 01:51:48 +01:00
Shakker
dc4c9030fc test: snapshot diagnostic state env 2026-06-05 01:51:48 +01:00
Shakker
8ede9e0e07 test: scope doctor gateway token env 2026-06-05 01:51:48 +01:00
Shakker
9739249043 test: scope channel prompts locale 2026-06-05 01:51:48 +01:00
Shakker
dbb80f3bb7 test: scope search setup locale 2026-06-05 01:51:27 +01:00
Shakker
61d9ac8c5d test: scope channel status locale 2026-06-05 01:51:27 +01:00
Shakker
abc00f4c98 test: snapshot logging config env 2026-06-05 01:51:27 +01:00
Shakker
28737a0b09 test: snapshot console settings env 2026-06-05 01:51:27 +01:00
Shakker
28b63e69e9 test: snapshot logger settings env 2026-06-05 01:51:27 +01:00
Shakker
5392cb7139 test: snapshot logger level env 2026-06-05 01:51:27 +01:00
Shakker
55c414ca81 test: reuse parallels env helper 2026-06-05 01:51:27 +01:00
Shakker
74680e3484 test: reuse release env helper 2026-06-05 01:51:27 +01:00
Shakker
d6e1ca997b test: snapshot openai provider env 2026-06-05 01:51:27 +01:00
Shakker
c4ed850f9b test: snapshot tts prefs env 2026-06-05 01:51:27 +01:00
Shakker
4957e3b02f test: share brew env helpers 2026-06-05 01:51:27 +01:00
Shakker
323c8aa87f test: snapshot npm global config env 2026-06-05 01:51:27 +01:00
Shakker
442a2107b5 test: scope bun install detection env 2026-06-05 01:51:27 +01:00
Shakker
ed52d27d78 test: share env api key snapshot 2026-06-05 01:51:27 +01:00
Shakker
cb17c84410 test: let flow registry helper own state env 2026-06-05 01:51:27 +01:00
Shakker
f57adba400 test: snapshot task executor env 2026-06-05 01:51:27 +01:00
Shakker
9f6ed16a6d test: snapshot task flow maintenance env 2026-06-05 01:51:27 +01:00
Shakker
99a838fac4 test: snapshot task registry store env 2026-06-05 01:51:27 +01:00
Shakker
064182aff8 test: snapshot task flow audit env 2026-06-05 01:51:26 +01:00
Shakker
0f9bb59b73 test: snapshot task owner state env 2026-06-05 01:51:26 +01:00
Shakker
79b6dd049e test: scope inherited agent dir fixture 2026-06-05 01:51:26 +01:00
Peter Steinberger
58c663920d docs: document script tests 2026-06-04 20:49:50 -04:00
Marcus Castro
dd2083c7ec fix(whastapp): bound connection startup waits (#90486)
* fix: add timeout to waitForWaConnection to prevent indefinite hangs

If Baileys fails to emit a 'connection.update' event with either 'open'
or 'close' status (e.g. due to network issues or internal errors), the
waitForWaConnection promise hangs forever, blocking the entire monitor
loop.

Add a configurable timeout (default 60s) that rejects the promise and
cleans up the event listener if no connection state is received in time.
The timeout is backward-compatible as an optional parameter with a
sensible default.

* test: add coverage for waitForWaConnection timeout path

- Test that promise rejects with descriptive error after timeout
- Test that event listener is cleaned up after timeout
- Test that timer is cleared when connection opens before timeout

* fix: default timeoutMs to 0 to preserve QR login behavior

The 60s default broke the QR login flow in login-qr.ts, which calls
waitForWaConnection without a timeout and expects to wait up to 3 minutes
while the user scans. Change the default to 0 (wait forever, matching
original behavior) and pass the 60s timeout explicitly at the monitor
callsite where it's actually needed.

* fix: bound whatsapp connection startup waits

* fix: align web channel wait contract

* fix: retry whatsapp setup timeouts

* fix: satisfy whatsapp status lint

* fix: preserve whatsapp wait compatibility

---------

Co-authored-by: MMMMSSSS8899 <praelovk@gmail.com>
2026-06-04 21:45:43 -03:00
Peter Steinberger
29f5e9d35c docs: document test helpers 2026-06-04 20:42:26 -04:00
Peter Steinberger
25211167e8 docs: document vitest config files 2026-06-04 20:40:11 -04:00
Peter Steinberger
ecb6779a16 docs: document root test files 2026-06-04 20:37:28 -04:00
Peter Steinberger
edb920b857 docs: document remaining src helpers 2026-06-04 20:34:26 -04:00
Peter Steinberger
b2e320dfb1 docs: document support test files 2026-06-04 20:31:55 -04:00
Peter Steinberger
1bdf210b43 docs: document rescue and trajectory tests 2026-06-04 20:29:59 -04:00
Peter Steinberger
d8326f13c3 docs: document proxy and mcp helpers 2026-06-04 20:27:46 -04:00
Peter Steinberger
9b30ff181c docs: document routing helpers 2026-06-04 20:25:55 -04:00
Peter Steinberger
4f79f2419c docs: document video generation helpers 2026-06-04 20:24:49 -04:00
Peter Steinberger
65546f0158 docs: document tui components 2026-06-04 20:23:23 -04:00
Peter Steinberger
6d58ff3562 docs: document session helpers 2026-06-04 20:22:09 -04:00
Peter Steinberger
47bae66415 docs: document session config tests 2026-06-04 20:20:20 -04:00
Peter Steinberger
f5b6a977d7 docs: document tts helpers 2026-06-04 20:19:15 -04:00
Peter Steinberger
85e6940202 docs: document talk helpers 2026-06-04 20:17:42 -04:00
Peter Steinberger
5ba4eeceac docs: document daemon tests 2026-06-04 20:16:21 -04:00
Peter Steinberger
a628a66e4d docs: document process helpers 2026-06-04 20:14:34 -04:00
Peter Steinberger
ef08c83e17 docs: document utility helpers 2026-06-04 20:12:49 -04:00
Peter Steinberger
b6ce59d367 docs: document wizard helpers 2026-06-04 20:11:22 -04:00
Peter Steinberger
c8665c66ba docs: document flow helpers 2026-06-04 20:10:01 -04:00
Peter Steinberger
4c3b4f8ad8 docs: document hook helpers 2026-06-04 20:08:40 -04:00
Peter Steinberger
e6f85453dc docs: document llm helpers 2026-06-04 20:07:13 -04:00
Peter Steinberger
f1bdc91b64 docs: document media helpers 2026-06-04 20:05:16 -04:00
Peter Steinberger
add135d238 docs: document logging helpers 2026-06-04 20:04:06 -04:00
Vincent Koc
563dac5989 test(core): remove stale unused test bindings 2026-06-04 17:03:40 -07:00
Vincent Koc
5bc300a1df test(agents): align pdf default model expectation 2026-06-04 17:03:40 -07:00
Vincent Koc
1d19d7ec46 fix(auto-reply): skip commented heartbeat scaffolding 2026-06-04 17:03:40 -07:00
Peter Steinberger
87d053c0cb docs: document shared helpers 2026-06-04 20:02:33 -04:00
Peter Steinberger
5b53cddc75 docs: document cron test files 2026-06-04 20:01:05 -04:00
Peter Steinberger
6c48a12562 docs: document skill runtime files 2026-06-04 19:58:44 -04:00
Peter Steinberger
43cee29f70 docs: document skill loading files 2026-06-04 19:57:11 -04:00
Peter Steinberger
725ddd11cc docs: document remaining plugin runtime files 2026-06-04 19:54:07 -04:00
Peter Steinberger
d2d14d5793 docs: document plugin contract tests 2026-06-04 19:52:26 -04:00
Peter Steinberger
f25c246f6b docs: document plugin runtime helpers 2026-06-04 19:48:26 -04:00
Peter Steinberger
6486fc1c0d docs: document model command tests 2026-06-04 19:46:17 -04:00
Peter Steinberger
81eee47045 docs: document doctor command tests 2026-06-04 19:44:23 -04:00
Peter Steinberger
4499b24781 docs: document cli program tests 2026-06-04 19:41:55 -04:00
Peter Steinberger
b59b34f9d5 docs: document cli service tests 2026-06-04 19:39:51 -04:00
Shakker
912e70acbd test: scope system run helper env 2026-06-05 00:38:28 +01:00
Shakker
16147e16e3 test: isolate approval path token cases 2026-06-05 00:38:28 +01:00
Shakker
638be00f4b test: scope fake runtime path setup 2026-06-05 00:38:28 +01:00
Shakker
695e09d360 test: scope proof temp dir env 2026-06-05 00:38:28 +01:00
Shakker
69ddcc00e6 test: scope invoke prepare path env 2026-06-05 00:38:28 +01:00
Shakker
a18c60e141 test: scope sandbox audit home env 2026-06-05 00:38:28 +01:00
Shakker
ec048ae693 test: restore cron state dir through helper 2026-06-05 00:38:28 +01:00
Shakker
7675b10223 test: capture usage format env setup 2026-06-05 00:38:28 +01:00
Shakker
25a1b0c240 test: cover allowlist tilde expansion 2026-06-05 00:38:28 +01:00
Shakker
c006ed5e16 test: restore reply harness temp home 2026-06-05 00:38:27 +01:00
Shakker
b4e048e60a test: reset gateway token env per case 2026-06-05 00:38:27 +01:00
Shakker
f365568f1b test: shorten tool metadata home paths 2026-06-05 00:38:27 +01:00
Shakker
e2c23d8a5e test: verify model status agent-dir env 2026-06-05 00:38:27 +01:00
Peter Steinberger
408ba4c8a0 docs: document remaining cli tests 2026-06-04 19:37:38 -04:00
Peter Steinberger
4995907541 docs: document cli support tests 2026-06-04 19:35:08 -04:00
Peter Steinberger
8cb093e7a9 docs: document cli test batch 2026-06-04 19:32:43 -04:00
Peter Steinberger
3e29885c83 docs: document channel subdir tests 2026-06-04 19:30:09 -04:00
Peter Steinberger
867d7898df docs: document channel plugin contracts 2026-06-04 19:28:31 -04:00
Peter Steinberger
fa46138047 docs: document channel plugin tests 2026-06-04 19:24:55 -04:00
Peter Steinberger
c135624c69 docs: document root channel tests 2026-06-04 19:23:04 -04:00
Peter Steinberger
048f307695 docs: document remaining plugin sdk files 2026-06-04 19:21:04 -04:00
Peter Steinberger
feffb6d02f docs: document plugin sdk runtime helpers 2026-06-04 19:16:24 -04:00
Peter Steinberger
a16c6ca94b docs: document plugin sdk public helpers 2026-06-04 19:14:41 -04:00
Vincent Koc
7fb748462e fix(ci): classify live installer docker lanes 2026-06-04 16:13:27 -07:00
Peter Steinberger
50dcaad71a docs: document remaining command tests 2026-06-04 19:11:43 -04:00
Peter Steinberger
7a7ca15776 docs: document command setup batch 2026-06-04 19:07:41 -04:00
Peter Steinberger
bf19d198d9 docs: document command cleanup batch 2026-06-04 19:05:16 -04:00
Peter Steinberger
eaad487c42 docs: document command report batch 2026-06-04 19:03:19 -04:00
Peter Steinberger
12ade5c5e8 docs: document command scan batch 2026-06-04 19:01:12 -04:00
Peter Steinberger
076bf2a361 docs: document command status batch 2026-06-04 18:59:04 -04:00
Peter Steinberger
0156de5c34 docs: document command onboarding batch 2026-06-04 18:57:02 -04:00
Shakker
646eb00112 test: pin acp prompt home redaction 2026-06-04 23:55:53 +01:00
Shakker
06f95f9a65 test: anchor auth sqlite agent dirs 2026-06-04 23:55:53 +01:00
Shakker
9a78886c78 test: localize logger env overrides 2026-06-04 23:55:53 +01:00
Shakker
66212260ef test: seal crestodian rescue stores 2026-06-04 23:55:53 +01:00
Shakker
dda0a98b76 test: bracket agent directory fixtures 2026-06-04 23:55:53 +01:00
Shakker
c71d3e45a1 test: isolate tts status fixture homes 2026-06-04 23:55:53 +01:00
Shakker
986025afe4 test: guard fs-safe tilde fixtures 2026-06-04 23:55:53 +01:00
Peter Steinberger
0d393ba6b4 docs: document command diagnostics batch 2026-06-04 18:54:31 -04:00
Peter Steinberger
0de924b35c docs: document command support batch 2026-06-04 18:52:07 -04:00
Peter Steinberger
4a47a9db98 docs: document command test batch 2026-06-04 18:50:37 -04:00
Peter Steinberger
5fa55d93f7 docs: document command helper tests 2026-06-04 18:48:22 -04:00
Peter Steinberger
64008398d1 docs: document noninteractive onboarding tests 2026-06-04 18:47:21 -04:00
Peter Steinberger
5c362884f3 docs: document channel setup tests 2026-06-04 18:46:23 -04:00
Peter Steinberger
71b09b99f8 docs: document migrate command tests 2026-06-04 18:45:28 -04:00
Peter Steinberger
3f31b62cd4 docs: document channels capability tests 2026-06-04 18:44:22 -04:00
Peter Steinberger
4927388580 docs: document gateway status tests 2026-06-04 18:43:35 -04:00
Peter Steinberger
19da9d8832 docs: document status-all command tests 2026-06-04 18:42:37 -04:00
Peter Steinberger
bea27678b4 docs: document link understanding 2026-06-04 18:41:10 -04:00
Peter Steinberger
ba28f7b018 docs: document model catalog planners 2026-06-04 18:40:10 -04:00
9510 changed files with 102451 additions and 20917 deletions

4
.github/labeler.yml vendored
View File

@@ -574,6 +574,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:

View File

@@ -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?
<details>
@@ -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?
<details>
@@ -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?
<details>
@@ -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?
<details>

View File

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

View File

@@ -35,7 +35,7 @@ jobs:
java-version: "21"
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
with:
category: "/codeql-critical-security/android"

View File

@@ -342,13 +342,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
with:
category: "/codeql-critical-quality/plugin-sdk-package-contract"

View File

@@ -35,7 +35,7 @@ jobs:
swift --version
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # 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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
with:
sarif_file: sarif-results-filtered
category: "/codeql-critical-security/macos"

View File

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

View File

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

View File

@@ -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 }}

View File

@@ -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

View File

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

View File

@@ -13,7 +13,7 @@ on:
default: true
type: boolean
public_release_branch:
description: Public branch that contains the release tag commit, usually main or release/YYYY.M.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)

View File

@@ -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 }}

View File

@@ -887,7 +887,7 @@ jobs:
summary=".artifacts/docker-tests/release-${DOCKER_E2E_CHUNK}/summary.json"
if [[ ! -f "$summary" ]]; then
echo "Docker chunk summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY"
exit 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 +897,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 +1147,7 @@ jobs:
summary=".artifacts/docker-tests/targeted-${{ steps.plan.outputs.artifact_suffix }}/summary.json"
if [[ ! -f "$summary" ]]; then
echo "Docker targeted summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY"
exit 0
exit 1
fi
node .release-harness/scripts/docker-e2e.mjs summary "$summary" "Docker E2E targeted lanes" >> "$GITHUB_STEP_SUMMARY"
@@ -1157,7 +1157,7 @@ jobs:
with:
name: docker-e2e-${{ steps.plan.outputs.artifact_suffix }}
path: .artifacts/docker-tests/
if-no-files-found: ignore
if-no-files-found: error
validate_docker_openwebui:
needs: [validate_selected_ref, prepare_docker_e2e_image]
@@ -1274,7 +1274,7 @@ jobs:
summary=".artifacts/docker-tests/release-openwebui/summary.json"
if [[ ! -f "$summary" ]]; then
echo "Docker Open WebUI summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY"
exit 0
exit 1
fi
node .release-harness/scripts/docker-e2e.mjs summary "$summary" "Docker E2E chunk: openwebui" >> "$GITHUB_STEP_SUMMARY"
@@ -1284,7 +1284,7 @@ jobs:
with:
name: docker-e2e-openwebui
path: .artifacts/docker-tests/
if-no-files-found: ignore
if-no-files-found: error
prepare_docker_e2e_image:
needs: validate_selected_ref
@@ -1918,7 +1918,7 @@ jobs:
profiles: stable full
- suite_id: native-live-src-gateway-core
label: Native live gateway core
command: 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 +2038,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

View File

@@ -391,7 +391,7 @@ jobs:
tideclaw_alpha_publish=true
fi
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ "${tideclaw_alpha_publish}" != "true" ]]; then
echo "Real publish runs must be dispatched from main, release/YYYY.M.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

View File

@@ -244,8 +244,8 @@ jobs:
run: |
set -euo pipefail
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
echo "OPENAI_API_KEY is not configured; live GPT 5.5 lane 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:
@@ -547,7 +547,7 @@ jobs:
.artifacts/kova/bundles/${{ matrix.lane }}
.artifacts/kova/summaries/${{ matrix.lane }}.md
.artifacts/openclaw-performance/source/${{ matrix.lane }}
if-no-files-found: ignore
if-no-files-found: error
retention-days: ${{ matrix.deep_profile == 'true' && 14 || 30 }}
- name: Prepare clawgrit reports checkout

View File

@@ -132,7 +132,7 @@ jobs:
fi
fi
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release-ci/[0-9a-f]{12}-[0-9]+$ ]] && [[ "${tideclaw_alpha_check}" != "true" ]]; then
echo "Release checks must be dispatched from main, release/YYYY.M.D, a Full Release Validation release-ci/<sha>-<timestamp> ref, or a Tideclaw alpha branch for alpha prereleases." >&2
echo "Release checks must be dispatched from main, release/YYYY.M.PATCH, a Full Release Validation release-ci/<sha>-<timestamp> 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,6 +1134,50 @@ 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]
@@ -1141,6 +1299,7 @@ jobs:
done
- name: Upload Matrix QA artifacts
id: upload_matrix_qa_artifacts
if: always()
uses: actions/upload-artifact@v7
with:
@@ -1149,6 +1308,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]
@@ -1237,6 +1440,7 @@ jobs:
done
- name: Upload Telegram QA artifacts
id: upload_telegram_qa_artifacts
if: always()
uses: actions/upload-artifact@v7
with:
@@ -1245,10 +1449,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 +1580,7 @@ jobs:
done
- name: Upload Discord QA artifacts
id: upload_discord_qa_artifacts
if: always()
uses: actions/upload-artifact@v7
with:
@@ -1340,10 +1589,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 +1723,7 @@ jobs:
done
- name: Upload WhatsApp QA artifacts
id: upload_whatsapp_qa_artifacts
if: always()
uses: actions/upload-artifact@v7
with:
@@ -1438,10 +1732,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 +1863,7 @@ jobs:
done
- name: Upload Slack QA artifacts
id: upload_slack_qa_artifacts
if: always()
uses: actions/upload-artifact@v7
with:
@@ -1533,6 +1872,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 +1936,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 +1960,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 +2021,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 +2037,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

View File

@@ -120,7 +120,7 @@ jobs:
tideclaw_alpha_publish=true
fi
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${WORKFLOW_REF}" != "refs/heads/main" && ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ && "${tideclaw_alpha_publish}" != "true" ]]; then
echo "publish_openclaw_npm=true requires dispatching this workflow from main, release/YYYY.M.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

View File

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

View File

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

View File

@@ -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

View File

@@ -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

View File

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

View File

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

View File

@@ -2,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<MainViewModel?>(null) }
LaunchedEffect(Unit) {
withFrameNanos { }
withContext(Dispatchers.Default) {
(application as NodeApp).prefs
}
val readyViewModel = viewModel
activateViewModel(readyViewModel)
activeViewModel = readyViewModel
}
val currentViewModel = activeViewModel
if (currentViewModel == null) {
OpenClawTheme {
StartupSurface()
}
} else {
val appearanceThemeMode by currentViewModel.appearanceThemeMode.collectAsState()
OpenClawTheme(themeMode = appearanceThemeMode) {
RootScreen(viewModel = currentViewModel)
}
}
}
}
override fun onStart() {
super.onStart()
foreground = true
initializedViewModel?.setForeground(true)
}
override fun onStop() {
foreground = false
initializedViewModel?.setForeground(false)
super.onStop()
}
override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
setIntent(intent)
pendingIntent = intent
initializedViewModel?.let { handleAssistantIntent(viewModel = it, intent = intent) }
}
/**
* Wires MainViewModel only after Activity first draw and background prefs warm-up.
*/
private fun activateViewModel(readyViewModel: MainViewModel) {
if (initializedViewModel != null) return
initializedViewModel = readyViewModel
readyViewModel.setForeground(foreground)
startViewModelCollectors(readyViewModel)
pendingIntent?.let { initialIntent ->
handleAssistantIntent(viewModel = readyViewModel, intent = initialIntent)
pendingIntent = null
}
}
/**
* Starts lifecycle collectors after ViewModel construction so they cannot force early startup.
*/
private fun startViewModelCollectors(readyViewModel: MainViewModel) {
if (didStartViewModelCollectors) return
didStartViewModelCollectors = true
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
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,
)
}
}
}

View File

@@ -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<NodeRuntime?>(null)
private var foreground = true
@Volatile private var foreground = false
@Volatile private var runtimeStartupQueued = false
private val _requestedHomeDestination = MutableStateFlow<HomeDestination?>(null)
val requestedHomeDestination: StateFlow<HomeDestination?> = _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<Boolean> = runtimeState(initial = false) { it.isConnected }
val isNodeConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.nodeConnected }
val statusText: StateFlow<String> = runtimeState(initial = "Offline") { it.statusText }
val gatewayConnectionProblem: StateFlow<GatewayConnectionProblem?> = runtimeState(initial = null) { it.gatewayConnectionProblem }
val serverName: StateFlow<String?> = runtimeState(initial = null) { it.serverName }
val remoteAddress: StateFlow<String?> = runtimeState(initial = null) { it.remoteAddress }
val gatewayVersion: StateFlow<String?> = runtimeState(initial = null) { it.gatewayVersion }
@@ -150,6 +172,7 @@ class MainViewModel(
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
val installedAppsSharingEnabled: StateFlow<Boolean> = prefs.installedAppsSharingEnabled
val speakerEnabled: StateFlow<Boolean> = prefs.speakerEnabled
val appearanceThemeMode: StateFlow<AppearanceThemeMode> = prefs.appearanceThemeMode
val voiceCaptureMode: StateFlow<VoiceCaptureMode> = runtimeState(initial = VoiceCaptureMode.Off) { it.voiceCaptureMode }
val micEnabled: StateFlow<Boolean> = runtimeState(initial = false) { it.micEnabled }
@@ -180,12 +203,6 @@ class MainViewModel(
val chatSessions: StateFlow<List<ChatSessionEntry>> = runtimeState(initial = emptyList()) { it.chatSessions }
val pendingRunCount: StateFlow<Int> = runtimeState(initial = 0) { it.pendingRunCount }
init {
if (prefs.onboardingCompleted.value) {
ensureRuntime()
}
}
val canvas: CanvasController
get() = ensureRuntime().canvas
@@ -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?,

View File

@@ -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<String> = _statusText.asStateFlow()
private val _gatewayConnectionProblem = MutableStateFlow<GatewayConnectionProblem?>(null)
val gatewayConnectionProblem: StateFlow<GatewayConnectionProblem?> = _gatewayConnectionProblem.asStateFlow()
private val _pendingGatewayTrust = MutableStateFlow<GatewayTrustPrompt?>(null)
val pendingGatewayTrust: StateFlow<GatewayTrustPrompt?> = _pendingGatewayTrust.asStateFlow()
@@ -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)
@@ -2085,6 +2132,7 @@ class NodeRuntime(
id = id,
name = obj["name"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: id,
provider = provider,
available = obj.optionalBoolean("available"),
supportsVision = "image" in inputTypes,
supportsAudio = "audio" in inputTypes,
supportsDocuments = "document" in inputTypes,
@@ -2701,6 +2749,7 @@ data class GatewayModelSummary(
val id: String,
val name: String,
val provider: String,
val available: Boolean?,
val supportsVision: Boolean,
val supportsAudio: Boolean,
val supportsDocuments: Boolean,
@@ -2883,6 +2932,15 @@ private fun JsonObject?.double(key: String): Double? = (this?.get(key) as? JsonP
private fun JsonObject?.boolean(key: String): Boolean = (this?.get(key) as? JsonPrimitive)?.content?.trim() == "true"
private fun JsonObject?.optionalBoolean(key: String): Boolean? =
(this?.get(key) as? JsonPrimitive)?.content?.trim()?.lowercase()?.let { value ->
when (value) {
"true" -> true
"false" -> false
else -> null
}
}
internal fun cronJobLastRunStatus(state: JsonObject?): String? =
state
.cronStatus("lastStatus")

View File

@@ -53,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) }

View File

@@ -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<Boolean> = _speakerEnabled
private val _appearanceThemeMode =
MutableStateFlow(AppearanceThemeMode.fromRawValue(plainPrefs.getString(appearanceThemeModeKey, null)))
val appearanceThemeMode: StateFlow<AppearanceThemeMode> = _appearanceThemeMode
fun setLastDiscoveredStableId(value: String) {
val trimmed = value.trim()
plainPrefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
@@ -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<String> {
val raw = plainPrefs.getString(notificationsForwardingPackagesKey, null)?.trim()
if (raw.isNullOrEmpty()) {

View File

@@ -61,9 +61,11 @@ class ChatController(
private val pendingRuns = mutableSetOf<String>()
private val pendingRunTimeoutJobs = ConcurrentHashMap<String, Job>()
// Preserve sent messages locally until chat.history includes the gateway-confirmed copy.
private val optimisticMessagesByRunId = LinkedHashMap<String, ChatMessage>()
private val pendingRunTimeoutMs = 120_000L
// Drops stale history responses after session switches or refresh races.
private val historyLoadGeneration = AtomicLong(0)
@@ -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<ChatMessage>) {
val retained =
retainUnmatchedOptimisticMessages(
incoming = incoming,
optimistic = optimisticMessagesByRunId.values,
).toSet()
optimisticMessagesByRunId.entries.removeAll { entry -> entry.value !in retained }
}
private fun parseHistory(
historyJson: String,
sessionKey: String,
@@ -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<ChatMessageContent> {
obj["content"].asArrayOrNull()?.let { content ->
return content.mapNotNull(::parseChatMessageContent)
}
obj["content"].asStringOrNull()?.let { text ->
return listOf(ChatMessageContent(type = "text", text = text))
}
obj["text"].asStringOrNull()?.let { text ->
return listOf(ChatMessageContent(type = "text", text = text))
}
return emptyList()
}
internal data class MainSessionState(
val currentSessionKey: String,
val appliedMainSessionKey: String,
@@ -732,29 +760,41 @@ internal fun mergeOptimisticMessages(
): List<ChatMessage> {
if (optimistic.isEmpty()) return incoming
val unmatchedIncoming = incoming.toMutableList()
val missingOptimistic =
optimistic.filter { message ->
val matchIndex =
unmatchedIncoming.indexOfFirst { incomingMessage ->
incomingMessageConsumesOptimistic(incomingMessage, message)
}
if (matchIndex >= 0) {
unmatchedIncoming.removeAt(matchIndex)
false
} else {
true
}
}
val missingOptimistic = retainUnmatchedOptimisticMessages(incoming = incoming, optimistic = optimistic)
if (missingOptimistic.isEmpty()) return incoming
return (incoming + missingOptimistic).sortedWith(compareBy<ChatMessage> { it.timestampMs ?: Long.MAX_VALUE }.thenBy { it.id })
}
internal fun retainUnmatchedOptimisticMessages(
incoming: List<ChatMessage>,
optimistic: Collection<ChatMessage>,
): List<ChatMessage> {
if (optimistic.isEmpty()) return emptyList()
val unmatchedIncoming = incoming.toMutableList()
return optimistic.filter { message ->
val matchIndex =
unmatchedIncoming.indexOfFirst { incomingMessage ->
incomingMessageConsumesOptimistic(incomingMessage, message)
}
if (matchIndex >= 0) {
unmatchedIncoming.removeAt(matchIndex)
false
} else {
true
}
}
}
/**
* Message identity used only for refresh reconciliation; it avoids exposing gateway ids as UI keys.
*/
internal fun messageIdentityKey(message: ChatMessage): String? {
val idempotencyKey = message.idempotencyKey?.trim().orEmpty()
if (idempotencyKey.isNotEmpty()) {
return listOf(message.role.trim().lowercase(), idempotencyKey).joinToString(separator = "|")
}
val contentKey = messageContentIdentityKey(message) ?: return null
val timestamp = message.timestampMs?.toString().orEmpty()
if (timestamp.isEmpty() && contentKey.isEmpty()) return null
@@ -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

View File

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

View File

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

View File

@@ -77,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
}

View File

@@ -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,

View File

@@ -297,17 +297,15 @@ private fun CommandSectionLabel(title: String) {
}
}
/** Builds provider quick-action metadata from current gateway/catalog state. */
private fun providerCommandSubtitle(
internal fun providerCommandSubtitle(
isConnected: Boolean,
providers: List<GatewayModelProviderSummary>,
models: List<GatewayModelSummary>,
): String {
if (!isConnected) return "Connect Gateway to load models"
val readyProviderCount = providers.count { modelProviderReady(it.status) }
if (!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. */

View File

@@ -1,7 +1,7 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayConnectionProblem
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.ui.mobileCardSurface
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.BorderStroke
@@ -66,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,

View File

@@ -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)
}
}
}

View File

@@ -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),

View File

@@ -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<String?>(null) }
var attemptedConnect by rememberSaveable { mutableStateOf(false) }
var attemptedGatewayName by rememberSaveable { mutableStateOf<String?>(null) }
var connectAttemptStartedAtMs by rememberSaveable { mutableLongStateOf(0L) }
var recoveryNowMs by remember { mutableLongStateOf(SystemClock.elapsedRealtime()) }
OpenClawSystemBarAppearance(lightAppearance = !onboardingDark && step != OnboardingStep.Welcome)
val qrScannerOptions =
remember {
GmsBarcodeScannerOptions
@@ -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"}",

View File

@@ -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)
}

View File

@@ -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<String>()) }
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,50 +125,12 @@ 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,
)
private data class ProviderRow(
internal data class ProviderRow(
val id: String,
val name: String,
val status: String,
@@ -202,28 +138,28 @@ private data class ProviderRow(
val modelCount: Int,
)
/** Combines auth-provider readiness rows with catalog-only providers. */
private fun providerRows(
/** Combines gateway auth-provider readiness with configured model providers. */
internal fun providerRows(
providers: List<GatewayModelProviderSummary>,
models: List<GatewayModelSummary>,
): List<ProviderRow> {
val modelCounts = models.groupingBy { it.provider }.eachCount()
val authRows =
providers.map { provider ->
val ready = modelProviderReady(provider.status)
ProviderRow(
id = provider.id,
name = provider.displayName,
status = if (ready) "Ready" else "Needs setup",
ready = ready,
modelCount = modelCounts[provider.id] ?: 0,
)
}
// Static/catalog-only providers may expose models without a matching auth
// provider row; keep them visible as ready providers.
val missingAuthRows =
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 -> authRows.none { it.id == provider } }
.filter { provider -> provider.trim().lowercase() !in authProviderIds }
.map { provider ->
ProviderRow(
id = provider,
@@ -233,33 +169,9 @@ private fun providerRows(
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<ProviderRow>): List<ProviderSetupRow> {
val byId = providerRows.associateBy { it.id.trim().lowercase() }
return listOf("openai", "anthropic", "google", "openrouter", "ollama").map { id ->
val row = byId[id] ?: byId["ollama-local"].takeIf { id == "ollama" }
ProviderSetupRow(
id = id,
name = providerDisplayName(id),
subtitle = providerSetupSubtitle(id, row),
ready = row?.ready == true,
)
}
}
private fun providerSetupSubtitle(
id: String,
row: ProviderRow?,
): String =
when {
row?.ready == true -> if (row.modelCount > 0) "${row.modelCount} models available" else "Ready"
row != null -> "Finish setup to use ${row.name}"
id == "ollama" -> "Use models running on your network"
else -> "Add provider credentials on your Gateway"
}
/** Normalizes gateway provider status strings into a ready/not-ready boolean. */
internal fun modelProviderReady(status: String): Boolean {
val normalized = status.trim().lowercase()
@@ -270,14 +182,6 @@ internal fun modelProviderReady(status: String): Boolean {
normalized == "static"
}
/** Groups models by provider using the same display priority as provider rows. */
private fun sortedModelGroups(models: List<GatewayModelSummary>): List<Pair<String, List<GatewayModelSummary>>> =
models
.groupBy { it.provider }
.entries
.sortedWith(compareBy({ providerPriority(it.key) }, { providerDisplayName(it.key).lowercase() }))
.map { it.key to it.value }
private fun providerPriority(row: ProviderRow): Int = providerPriority(row.id)
private fun providerPriority(provider: String): Int =
@@ -299,7 +203,15 @@ private fun ProviderList(
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
Column {
if (rows.isEmpty()) {
ProviderListRow(ProviderRow(id = "loading", name = "Provider catalog", status = if (refreshing) "Loading" else "No providers", ready = false, modelCount = 0))
ProviderListRow(
ProviderRow(
id = "loading",
name = "Provider catalog",
status = if (refreshing) "Loading" else "No providers",
ready = false,
modelCount = 0,
),
)
} else {
val visibleRows = rows.take(5)
visibleRows.forEachIndexed { index, row ->
@@ -320,7 +232,6 @@ private fun ProviderOverviewPanel(
modelCount: Int,
refreshing: Boolean,
onRefresh: () -> Unit,
onSetup: () -> Unit,
) {
val readyCount = providerRows.count { it.ready }
val needsSetupCount = providerRows.count { !it.ready }
@@ -329,17 +240,14 @@ private fun ProviderOverviewPanel(
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ProviderMetricTile(label = "Ready", value = readyCount.toString(), modifier = Modifier.weight(1f))
ProviderMetricTile(label = "Models", value = modelCount.toString(), modifier = Modifier.weight(1f))
ProviderMetricTile(label = "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())
}
}
}
@@ -364,55 +272,13 @@ private fun ProviderMetricTile(
}
}
@Composable
private fun ProviderSetupList(
rows: List<ProviderSetupRow>,
onSetup: () -> Unit,
) {
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
Column {
rows.forEachIndexed { index, row ->
ProviderSetupListRow(row = row, onClick = onSetup)
if (index != rows.lastIndex) {
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
}
}
}
}
}
@Composable
private fun ProviderSetupListRow(
row: ProviderSetupRow,
onClick: () -> Unit,
) {
Surface(onClick = onClick, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
ProviderBadge(text = row.name)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(text = row.name, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
Text(text = row.subtitle, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Box(modifier = Modifier.size(5.dp).clip(CircleShape).background(if (row.ready) ClawTheme.colors.success else ClawTheme.colors.warning))
Text(text = if (row.ready) "Ready" else "Setup", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Open ${row.name}", modifier = Modifier.size(17.dp), tint = ClawTheme.colors.text)
}
}
}
}
@Composable
private fun ProviderListRow(row: ProviderRow) {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
ProviderBadge(text = row.name)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(text = row.name, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
Text(text = if (row.modelCount > 0) "${row.modelCount} models" else "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)) {
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(if (row.ready) ClawTheme.colors.success else ClawTheme.colors.warning))
@@ -439,78 +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<GatewayModelSummary>,
expanded: Boolean,
onToggle: () -> Unit,
) {
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
Column {
Surface(onClick = onToggle, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
ProviderBadge(text = providerDisplayName(provider))
Text(text = providerDisplayName(provider), style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
ProviderMiniTag(text = "${models.size} models")
Icon(imageVector = if (expanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = if (expanded) "Collapse ${providerDisplayName(provider)} models" else "Expand ${providerDisplayName(provider)} models", modifier = Modifier.size(14.dp), tint = ClawTheme.colors.textMuted)
}
}
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
val visibleModels = if (expanded) models else models.take(3)
visibleModels.forEachIndexed { index, model ->
ModelRow(model)
if (index != visibleModels.lastIndex || models.size > visibleModels.size) {
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
}
}
if (models.size > visibleModels.size) {
Surface(onClick = onToggle, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Text(text = "View all models", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, modifier = Modifier.weight(1f))
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "View all models", modifier = Modifier.size(14.dp), tint = ClawTheme.colors.text)
}
}
}
}
}
}
@Composable
private fun ModelRow(model: GatewayModelSummary) {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(horizontal = 10.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = model.name, style = ClawTheme.type.mono, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
modelCapabilityLabels(model).take(3).forEach { label ->
ProviderMiniTag(text = label)
}
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(ClawTheme.colors.success))
}
}
/** Derives compact capability chips for model catalog rows. */
private fun modelCapabilityLabels(model: GatewayModelSummary): List<String> =
buildList {
if (model.supportsReasoning) add("Reasoning")
if (model.supportsVision) add("Vision")
if (model.supportsAudio) add("Voice")
if (model.supportsDocuments) add("Docs")
if ((model.contextTokens ?: 0L) >= 100_000L) add("Long context")
if (isEmpty()) add("Fast")
}
@Composable
private fun ProviderSectionLabel(title: String) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
@@ -538,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)
}
}

View File

@@ -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),

View File

@@ -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<String> = AppearanceThemeMode.entries.map { it.displayLabel }
internal fun appearanceThemeModeForLabel(label: String): AppearanceThemeMode = AppearanceThemeMode.fromDisplayLabel(label)
/** Converts raw gateway connection text into stable settings metric labels. */
private fun gatewayStatusLabel(
statusText: String,
@@ -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,

View File

@@ -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,7 +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 = providers.count { modelProviderReady(it.status) }
val readyProviderCount = providerRows(providers = providers, models = models).count { it.ready }
val attentionRows =
homeAttentionRows(
isConnected = isConnected,
@@ -455,13 +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"
models.isNotEmpty() -> "${models.size} models"
else -> "Setup"
else -> "No ready"
},
icon = Icons.Outlined.Inventory2,
tab = Tab.ProvidersModels,
@@ -541,6 +548,7 @@ internal fun homeAttentionRows(
channelsSummary: GatewayChannelsSummary,
nodesDevicesSummary: GatewayNodesDevicesSummary,
readyProviderCount: Int,
expiringProviderCount: Int = 0,
): List<HomeAttentionRow> =
listOfNotNull(
if (!isConnected) {
@@ -564,7 +572,7 @@ internal fun homeAttentionRows(
null
},
if (isConnected && readyProviderCount == 0) {
HomeAttentionRow("Providers", "No ready providers", Icons.Outlined.Inventory2, Tab.ProvidersModels)
HomeAttentionRow("Providers", "No ready providers", Icons.Outlined.Inventory2, Tab.Settings, SettingsRoute.Gateway)
} else {
null
},
@@ -747,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
@@ -845,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) {
@@ -906,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),
),

View File

@@ -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<VoiceAction?>(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")

View File

@@ -15,7 +15,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.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 {

View File

@@ -31,7 +31,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.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 {

View File

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

View File

@@ -82,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

View File

@@ -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,

View File

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

View File

@@ -168,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<String, RealtimeToolRun>()
private val pendingRealtimeToolCalls = LinkedHashSet<String>()

View File

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

View File

@@ -294,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<GatewayEndpoint>(desired, "endpoint")
assertEquals("127.0.0.1", endpoint.host)
assertEquals(18789, endpoint.port)
assertEquals("shared-token", readField<String?>(desired, "token"))
}
@Test
fun connect_showsSecureEndpointGuidanceWhenTlsProbeFails() {
val app = RuntimeEnvironment.getApplication()

View File

@@ -77,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()

View File

@@ -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<String>(), retained.map { it.id })
}
@Test
fun retainUnmatchedOptimisticMessagesKeepsDistinctIdempotencyKey() {
val optimistic =
ChatMessage(
id = "local-user",
role = "user",
content = listOf(ChatMessageContent(type = "text", text = "hello")),
timestampMs = 1000L,
idempotencyKey = "run-2:user",
)
val remoteUser = optimistic.copy(id = "remote-user", timestampMs = 2000L, idempotencyKey = "run-1:user")
val retained = retainUnmatchedOptimisticMessages(incoming = listOf(remoteUser), optimistic = listOf(optimistic))
assertEquals(listOf("local-user"), retained.map { it.id })
}
@Test
fun mergeOptimisticMessagesDoesNotDuplicateHistoryTurns() {
val user =

View File

@@ -20,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<Pair<GatewaySession.ErrorShape, Boolean>>()
val server =
startGatewayServer(json = json) { webSocket, id, method ->
if (method == "connect") {
webSocket.send(
"""
{"type":"res","id":"$id","ok":false,"error":{"code":"NOT_PAIRED","message":"pairing required: device approval is required","details":{"code":"PAIRING_REQUIRED","reason":"not-paired","requestId":"request-1"}}}
""".trimIndent(),
)
}
}
val harness =
createReconnectHarness { error, pauseReconnect ->
connectFailure.complete(error to pauseReconnect)
}
try {
connectNodeSession(harness.session, server.port)
val (error, pauseReconnect) = withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { connectFailure.await() }
assertEquals("PAIRING_REQUIRED", error.details?.code)
assertEquals("not-paired", error.details?.reason)
assertEquals("request-1", error.details?.requestId)
assertTrue(pauseReconnect)
} finally {
shutdownReconnectHarness(harness, server)
}
}
@Test
fun pairingRequiredFailureDropsUnsafeRequestId() =
runBlocking {
val json = Json { ignoreUnknownKeys = true }
val connectFailure = CompletableDeferred<Pair<GatewaySession.ErrorShape, Boolean>>()
val server =
startGatewayServer(json = json) { webSocket, id, method ->
if (method == "connect") {
webSocket.send(
"""
{"type":"res","id":"$id","ok":false,"error":{"code":"NOT_PAIRED","message":"pairing required: device approval is required","details":{"code":"PAIRING_REQUIRED","reason":"not-paired","requestId":"request-1;echo unsafe"}}}
""".trimIndent(),
)
}
}
val harness =
createReconnectHarness { error, pauseReconnect ->
connectFailure.complete(error to pauseReconnect)
}
try {
connectNodeSession(harness.session, server.port)
val (error, pauseReconnect) = withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { connectFailure.await() }
assertEquals("PAIRING_REQUIRED", error.details?.code)
assertEquals("not-paired", error.details?.reason)
assertNull(error.details?.requestId)
assertTrue(pauseReconnect)
} finally {
shutdownReconnectHarness(harness, server)
}
}
private fun createReconnectHarness(
onConnectFailure: (GatewaySession.ErrorShape, Boolean) -> Unit = { _, _ -> },
): ReconnectHarness {
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}""") },
)

View File

@@ -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(

View File

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

View File

@@ -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(""))
}
}

View File

@@ -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

View File

@@ -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"),
)
}
}

View File

@@ -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<String>(), timeline.items.map(::chatTimelineItemKey))
assertEquals(null, timeline.scrollTargetIndex)
}
private fun textMessage(
id: String,
role: String,
text: String,
): ChatMessage =
ChatMessage(
id = id,
role = role,
content = listOf(ChatMessageContent(type = "text", text = text)),
timestampMs = null,
)
}

View File

@@ -1,9 +1,9 @@
[versions]
agp = "9.2.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-compose-bom = "2026.05.01"
androidx-core = "1.18.0"
androidx-exifinterface = "1.4.2"
androidx-lifecycle = "2.10.0"
@@ -13,14 +13,14 @@ androidx-uiautomator = "2.4.0-beta02"
androidx-webkit = "1.15.0"
bcprov = "1.84"
commonmark = "0.28.0"
coroutines = "1.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"
material = "1.14.0"
okhttp = "5.3.2"
play-services-code-scanner = "16.1.0"
robolectric = "4.16.1"

Binary file not shown.

View File

@@ -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

View File

@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/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/.

View File

@@ -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%

View File

@@ -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

View File

@@ -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)

View File

@@ -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<OpenClawChatTransportEvent> {
AsyncStream { continuation in
continuation.yield(.health(ok: true))
continuation.finish()
}
}
func setActiveSessionKey(_: String) async throws {}
func resetSession(sessionKey _: String) async throws {
await self.store.reset()
}
func compactSession(sessionKey _: String) async throws {}
}
private actor AppleReviewDemoChatStore {
private let sessionKey = "main"
private var messages: [OpenClawChatMessage]
init() {
self.messages = AppleReviewDemoChatStore.seedMessages()
}
func createSession(key: String) throws -> OpenClawChatCreateSessionResponse {
try Self.decode(
CreateSessionPayload(ok: true, key: key, sessionId: "apple-review-demo-\(key)"),
as: OpenClawChatCreateSessionResponse.self)
}
func history(sessionKey: String) throws -> OpenClawChatHistoryPayload {
let normalizedSessionKey = Self.normalizedSessionKey(sessionKey)
return try Self.decode(
HistoryPayload(
sessionKey: normalizedSessionKey,
sessionId: "apple-review-demo-\(normalizedSessionKey)",
messages: self.messages,
thinkingLevel: "auto"),
as: OpenClawChatHistoryPayload.self)
}
func sendMessage(sessionKey _: String, message: String, runId: String) throws -> OpenClawChatSendResponse {
let now = Date().timeIntervalSince1970 * 1000
self.messages.append(Self.message(role: "user", text: message, timestamp: now))
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
let subject = trimmed.isEmpty ? "that request" : "\"\(trimmed)\""
self.messages.append(
Self.message(
role: "assistant",
text: """
Demo mode is active. I can show the review flow locally for \(subject), including chat, agent \
selection, settings, and Gateway-connected UI states. Live automation requires pairing a real \
OpenClaw Gateway.
""",
timestamp: now + 1))
return try Self.decode(
SendPayload(runId: runId, status: "ok"),
as: OpenClawChatSendResponse.self)
}
func sessions() throws -> OpenClawChatSessionsListResponse {
let entry = OpenClawChatSessionEntry(
key: self.sessionKey,
kind: "chat",
displayName: "Apple Review Demo",
surface: "ios",
subject: "Gateway review flow",
room: nil,
space: nil,
updatedAt: Date().timeIntervalSince1970 * 1000,
sessionId: "apple-review-demo-main",
systemSent: true,
abortedLastRun: false,
thinkingLevel: "auto",
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: "demo",
model: "local-demo",
contextTokens: 128_000,
thinkingLevels: [
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
],
thinkingOptions: ["auto", "low", "medium"],
thinkingDefault: "auto")
return OpenClawChatSessionsListResponse(
ts: Date().timeIntervalSince1970 * 1000,
path: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(
modelProvider: "demo",
model: "local-demo",
contextTokens: 128_000,
thinkingLevels: [
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
],
thinkingOptions: ["auto", "low", "medium"],
thinkingDefault: "auto",
mainSessionKey: self.sessionKey),
sessions: [entry])
}
func reset() {
self.messages = Self.seedMessages()
}
private static func seedMessages() -> [OpenClawChatMessage] {
let now = Date().timeIntervalSince1970 * 1000
return [
self.message(
role: "assistant",
text: """
Apple Review demo mode is active. This local chat transport lets reviewers inspect the iOS app \
without a private Gateway.
""",
timestamp: now),
]
}
private static func message(role: String, text: String, timestamp: Double) -> OpenClawChatMessage {
OpenClawChatMessage(
role: role,
content: [
OpenClawChatMessageContent(
type: "text",
text: text,
mimeType: nil,
fileName: nil,
content: nil),
],
timestamp: timestamp)
}
private static func normalizedSessionKey(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "main" : trimmed
}
private static func decode<T: Decodable>(_ value: some Encodable, as type: T.Type) throws -> T {
let data = try JSONEncoder().encode(value)
return try JSONDecoder().decode(type, from: data)
}
private struct HistoryPayload: Encodable {
var sessionKey: String
var sessionId: String?
var messages: [OpenClawChatMessage]?
var thinkingLevel: String?
}
private struct SendPayload: Encodable {
var runId: String
var status: String
}
private struct CreateSessionPayload: Encodable {
var ok: Bool?
var key: String
var sessionId: String?
}
}

View File

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

View File

@@ -99,14 +99,14 @@ extension AgentProTab {
} label: {
Label("Run", systemImage: "play.fill")
}
.disabled(busy || !self.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) }

View File

@@ -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

View File

@@ -285,7 +285,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 +514,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 +542,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)
}

View File

@@ -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
@@ -640,7 +677,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 +693,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 +712,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 +735,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 +749,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: "{}",

View File

@@ -63,8 +63,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 +73,7 @@ struct AgentProTab: View {
switch self {
case .all: "All"
case .online: "Online"
case .busy: "Busy"
case .idle: "Idle"
case .ready: "Ready"
}
}
}
@@ -90,22 +88,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)
}
}
}
@@ -142,7 +137,6 @@ struct AgentProTab: View {
.refreshable {
await self.refreshOverview(force: true)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationBarHidden(true)
.navigationDestination(for: AgentRoute.self) { route in

View File

@@ -6,6 +6,7 @@ struct ChatProTab: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(\.colorScheme) private var colorScheme
@State private var viewModel: OpenClawChatViewModel?
@State private var viewModelUsesAppleReviewDemoTransport = false
var body: some View {
NavigationStack {
@@ -24,8 +25,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 +44,9 @@ struct ChatProTab: View {
Spacer()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.navigationBarHidden(true)
}
.task {
@@ -50,6 +55,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()
@@ -99,10 +108,29 @@ struct ChatProTab: View {
private func syncChatViewModel() {
let sessionKey = self.appModel.chatSessionKey
let usesDemoTransport = self.appModel.isAppleReviewDemoModeEnabled
guard let viewModel else {
self.viewModelUsesAppleReviewDemoTransport = usesDemoTransport
self.viewModel = OpenClawChatViewModel(
sessionKey: sessionKey,
transport: 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 +140,7 @@ struct ChatProTab: View {
return
}
guard viewModel.sessionKey != sessionKey else { return }
viewModel.switchSession(to: sessionKey)
viewModel.syncSession(to: sessionKey)
}
private var talkControl: OpenClawChatTalkControl {
@@ -130,8 +158,7 @@ struct ChatProTab: View {
}
private var activeAgentID: String {
self.normalized(self.appModel.selectedAgentId)
?? self.normalized(self.appModel.gatewayDefaultAgentId)
self.normalized(self.appModel.chatAgentId)
?? "main"
}
@@ -156,8 +183,14 @@ struct ChatProTab: View {
}
private var gatewayConnected: Bool {
GatewayStatusBuilder.build(appModel: self.appModel) == .connected &&
self.appModel.isOperatorGatewayConnected
guard GatewayStatusBuilder.build(appModel: self.appModel) == .connected else {
return false
}
return self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
}
private var messagePlaceholder: String {
self.gatewayConnected ? "Message \(self.agentDisplayName)..." : "Connect to a gateway"
}
private var chatUserAccent: Color {
@@ -169,7 +202,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 {

View File

@@ -126,7 +126,7 @@ struct CommandSessionRow: View {
}
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" {
@@ -144,41 +144,31 @@ struct CommandSessionRow: View {
}
}
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: 10, style: .continuous)
.fill(self.rowFill)
.overlay {
RoundedRectangle(cornerRadius: 10, 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.black.opacity(0.025)
}
private var rowBorder: Color {
self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.black.opacity(0.045)
}
}
@@ -250,32 +240,3 @@ struct CommandTaskRow: View {
.padding(.vertical, 8)
}
}
struct CommandLiveActivityRow: View {
let title: String
let value: String
let color: Color
var body: some View {
HStack(spacing: 8) {
ProStatusDot(color: self.color)
Text(self.title)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Spacer(minLength: 8)
Text(self.value)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 10)
.padding(.vertical, 9)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.black.opacity(0.08))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Color.primary.opacity(0.06), lineWidth: 1)
}
}
}
}

View File

@@ -2,10 +2,13 @@ import OpenClawChatUI
import SwiftUI
struct CommandCenterTab: View {
fileprivate static let recentSessionsFetchLimit = 200
@Environment(NodeAppModel.self) private var appModel
@Environment(\.colorScheme) private var colorScheme
@Environment(\.scenePhase) private var scenePhase
@State private var activeChatSessions: [OpenClawChatSessionEntry] = []
@State private var defaultChatSessionEntry: OpenClawChatSessionEntry?
@State private var recentChatSessions: [OpenClawChatSessionEntry] = []
var openChat: () -> Void
var openSettings: () -> Void
@@ -26,15 +29,6 @@ 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 {
@@ -44,10 +38,8 @@ struct CommandCenterTab: View {
VStack(alignment: .leading, spacing: 10) {
self.header
self.gatewayCard
self.pendingApprovals
self.activeTasks
self.liveActivity
self.startWorkAction
self.defaultChatSessionSection
self.recentSessions
}
.padding(.top, 16)
.padding(.bottom, 18)
@@ -56,8 +48,8 @@ struct CommandCenterTab: View {
}
.navigationBarHidden(true)
}
.task(id: self.activeSessionsRefreshID) {
await self.refreshActiveSessionsIfNeeded()
.task(id: self.recentSessionsRefreshID) {
await self.refreshRecentSessionsIfNeeded()
}
}
@@ -152,158 +144,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)
}
@@ -378,164 +273,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 +346,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<String>()
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 +403,24 @@ struct CommandCenterTab: View {
return result
}
private static func sessionTitle(_ session: OpenClawChatSessionEntry) -> String {
fileprivate static func sessionWorkItem(
for session: OpenClawChatSessionEntry,
currentSessionKey: String) -> WorkItem
{
let isCurrent = session.key == currentSessionKey
return WorkItem(
id: "chat-session-\(session.key)",
icon: isCurrent ? "bubble.left.and.text.bubble.right.fill" : "bubble.left.fill",
title: Self.sessionTitle(session),
detail: Self.sessionDetail(session),
state: isCurrent ? "open" : "recent",
trailing: "chat",
color: isCurrent ? OpenClawBrand.accent : OpenClawBrand.ok,
progress: nil,
route: .chat(session.key))
}
fileprivate static func sessionTitle(_ session: OpenClawChatSessionEntry) -> String {
if let title = redactedSessionTitle(for: session.key) {
return title
}
@@ -606,7 +436,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 +455,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 +475,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 +490,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 +547,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 +557,166 @@ struct CommandCenterTab: View {
return trimmed.isEmpty ? nil : trimmed
}
}
private struct CommandSessionsScreen: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(\.dismiss) private var dismiss
@State private var sessions: [OpenClawChatSessionEntry] = []
@State private var isLoading = false
@State private var loadErrorText: String?
let openChat: () -> Void
var body: some View {
ZStack {
CommandControlBackground()
ScrollView {
VStack(alignment: .leading, spacing: 10) {
self.header
self.sessionsPanel
}
.padding(.top, 16)
.padding(.bottom, 18)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle("Sessions")
.navigationBarTitleDisplayMode(.inline)
.task(id: self.refreshID) {
await self.refreshSessions()
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Sessions")
.font(.system(size: 27, weight: .bold, design: .rounded))
Text(self.headerDetail)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var sessionsPanel: some View {
CommandPanel(padding: 0) {
VStack(spacing: 0) {
HStack(spacing: 8) {
Text("Recent sessions")
.font(.subheadline.weight(.bold))
Spacer(minLength: 8)
if self.isLoading {
ProgressView()
.controlSize(.small)
}
}
.padding(.horizontal, 12)
.padding(.top, 10)
.padding(.bottom, 3)
if let loadErrorText {
CommandEmptyStateRow(
icon: "exclamationmark.triangle.fill",
title: "Sessions unavailable",
detail: loadErrorText)
.padding(.horizontal, 10)
.padding(.bottom, 10)
} else if self.sessionRows.isEmpty {
CommandEmptyStateRow(
icon: self.appModel
.isCommandSessionListAvailable ? "bubble.left.and.text.bubble.right.fill" : "wifi.slash",
title: self.appModel.isCommandSessionListAvailable ? "No recent sessions" : "Gateway offline",
detail: self.appModel
.isCommandSessionListAvailable ? "Start a chat and it will appear here." :
"Connect to the gateway.")
.padding(.horizontal, 10)
.padding(.bottom, 10)
} else {
VStack(spacing: 8) {
ForEach(self.sessionRows) { item in
Button {
self.open(item)
} label: {
CommandSessionRow(item: item)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 10)
.padding(.bottom, 10)
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var headerDetail: String {
if self.isLoading, self.sessions.isEmpty { return "Loading recent sessions" }
let count = self.sessionRows.count
if count == 0 {
return self.appModel.isCommandSessionListAvailable ? "No recent sessions" : "Gateway offline"
}
return "\(count) \(count == 1 ? "session" : "sessions")"
}
private var sessionRows: [CommandCenterTab.WorkItem] {
self.sessions
.filter { CommandCenterTab.isRecentChatSession(
$0.key,
defaultSessionKey: self.appModel.defaultChatSessionKey) }
.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
.map {
CommandCenterTab.sessionWorkItem(
for: $0,
currentSessionKey: self.appModel.chatSessionKey)
}
}
private var refreshID: String {
self.appModel.commandSessionListMode
}
private func open(_ item: CommandCenterTab.WorkItem) {
switch item.route {
case let .chat(sessionKey):
self.appModel.openChat(sessionKey: sessionKey)
self.dismiss()
self.openChat()
case .settings:
break
}
}
private func refreshSessions() async {
guard self.appModel.isCommandSessionListAvailable else {
self.sessions = []
self.loadErrorText = nil
return
}
self.isLoading = true
self.loadErrorText = nil
defer { self.isLoading = false }
do {
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
? AppleReviewDemoChatTransport()
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit)
self.sessions = response.sessions
} catch {
self.sessions = []
self.loadErrorText = "Try again after the gateway reconnects."
}
}
}
extension NodeAppModel {
fileprivate var isCommandSessionListAvailable: Bool {
self.isAppleReviewDemoModeEnabled || self.isOperatorGatewayConnected
}
fileprivate var commandSessionListMode: String {
if self.isAppleReviewDemoModeEnabled { return "demo" }
return self.isOperatorGatewayConnected ? "operator" : "offline"
}
}

View File

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

View File

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

View File

@@ -42,9 +42,9 @@ extension SettingsProTab {
self.diagnosticCheckRow(
icon: "antenna.radiowaves.left.and.right",
title: "Gateway Link",
detail: self.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,6 +132,7 @@ extension SettingsProTab {
}
func reconnectGateway() async {
guard !self.appModel.isAppleReviewDemoModeEnabled else { return }
guard !self.isReconnectingGateway else { return }
self.isReconnectingGateway = true
defer { self.isReconnectingGateway = false }
@@ -153,16 +154,18 @@ extension SettingsProTab {
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())
@@ -220,6 +223,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,6 +283,15 @@ 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 {
@@ -473,6 +493,7 @@ extension SettingsProTab {
func title(for route: SettingsRoute) -> String {
switch route {
case .gateway: "Gateway"
case .approvals: "Approvals"
case .permissions: "Permissions"
case .voice: "Voice & Talk"
case .diagnostics: "Diagnostics"
@@ -589,6 +610,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 +638,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 +703,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 +743,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"

View File

@@ -37,6 +37,8 @@ extension SettingsProTab {
NavigationLink(value: SettingsRoute.gateway) {
self.gatewayConnectionRow
.padding(14)
.frame(maxWidth: .infinity, minHeight: SettingsLayout.rowHeight, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Divider()
@@ -58,14 +60,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)
@@ -98,7 +100,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() }
}
@@ -116,6 +119,13 @@ 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",
@@ -154,11 +164,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))
@@ -168,6 +180,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)
@@ -187,6 +202,8 @@ extension SettingsProTab {
switch route {
case .gateway:
self.gatewayDestination
case .approvals:
self.approvalsDestination
case .permissions:
self.permissionsDestination
case .voice:
@@ -201,9 +218,9 @@ extension SettingsProTab {
self.aboutDestination
}
}
.padding(.vertical, 18)
.padding(.top, 18)
.padding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle(self.title(for: route))
.navigationBarTitleDisplayMode(.inline)
@@ -218,9 +235,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)
@@ -229,7 +246,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)")
}
@@ -239,15 +256,98 @@ extension SettingsProTab {
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
self.manualGatewayCard
self.deviceIdentityCard
self.agentSelectionCard
self.gatewaySetupCard
self.discoveredGatewaysCard
self.manualGatewayCard
self.gatewayAdvancedCard
}
}
var approvalsDestination: some View {
VStack(alignment: .leading, spacing: 14) {
self.detailStatusCard(
icon: "checkmark.shield.fill",
title: "Approvals",
detail: self.pendingApproval == nil ? "No gateway actions are waiting for review." :
"Review the pending gateway action.",
value: self.pendingApproval == nil ? "clear" : "1 waiting",
color: self.pendingApproval == nil ? OpenClawBrand.ok : OpenClawBrand.warn)
self.approvalsReviewCard
}
}
var approvalsReviewCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
if let pendingApproval {
VStack(spacing: 0) {
ForEach(Array(self.approvalItems.enumerated()), id: \.element.id) { index, item in
SettingsApprovalRow(item: item)
if index < self.approvalItems.count - 1 {
Divider().padding(.leading, 46)
}
}
}
if let errorText = self.appModel.pendingExecApprovalPromptErrorText {
Text(errorText)
.font(.caption2.weight(.medium))
.foregroundStyle(OpenClawBrand.danger)
}
HStack(spacing: 8) {
Button {
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once") }
} label: {
Label("Allow", systemImage: "checkmark")
}
.buttonStyle(.borderedProminent)
.disabled(self.appModel.pendingExecApprovalPromptResolving)
if pendingApproval.allowsAllowAlways {
Button {
Task {
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-always")
}
} label: {
Label("Always", systemImage: "checkmark.shield")
}
.buttonStyle(.bordered)
.disabled(self.appModel.pendingExecApprovalPromptResolving)
}
Button(role: .destructive) {
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "deny") }
} label: {
Label("Deny", systemImage: "xmark")
}
.buttonStyle(.bordered)
.disabled(self.appModel.pendingExecApprovalPromptResolving)
Spacer(minLength: 0)
}
.controlSize(.small)
} else {
HStack(spacing: 12) {
ProIconBadge(systemName: "checkmark.shield.fill", color: OpenClawBrand.ok)
VStack(alignment: .leading, spacing: 3) {
Text("No approvals waiting")
.font(.subheadline.weight(.semibold))
Text(self.approvalEmptyDetail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
var permissionsDestination: some View {
VStack(alignment: .leading, spacing: 14) {
self.toggleCard(
@@ -290,19 +390,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)
self.diagnosticChecksCard
self.detailListCard {
self.detailRow("Device", value: DeviceInfoHelper.deviceFamily())
Divider()
self.detailRow("Platform", value: DeviceInfoHelper.platformStringForDisplay())
Divider()
self.detailRow("App", value: DeviceInfoHelper.openClawVersionString())
Divider()
self.detailRow("Model", value: DeviceInfoHelper.modelIdentifier())
}
color: self.gatewayDiagnosticConnected ? OpenClawBrand.ok : OpenClawBrand.warn)
ProCard(radius: SettingsLayout.cardRadius) {
self.gatewayActionButton(
@@ -316,6 +404,18 @@ extension SettingsProTab {
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
self.diagnosticChecksCard
self.detailListCard {
self.detailRow("Device", value: DeviceInfoHelper.deviceFamily())
Divider()
self.detailRow("Platform", value: DeviceInfoHelper.platformStringForDisplay())
Divider()
self.detailRow("App", value: DeviceInfoHelper.openClawVersionString())
Divider()
self.detailRow("Model", value: DeviceInfoHelper.modelIdentifier())
}
self.diagnosticsAdvancedCard
}
}
@@ -404,6 +504,7 @@ extension SettingsProTab {
icon: String,
color: Color,
isBusy: Bool,
isDisabled: Bool = false,
action: @escaping () -> Void) -> some View
{
Button(action: action) {
@@ -425,7 +526,7 @@ extension SettingsProTab {
}
}
.buttonStyle(.plain)
.disabled(isBusy)
.disabled(isBusy || isDisabled)
}
func toggleCard(
@@ -497,7 +598,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("")
@@ -508,7 +609,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)
}
@@ -610,7 +711,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()
@@ -618,7 +719,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",
@@ -637,16 +738,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)
@@ -659,8 +765,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)
@@ -681,26 +792,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)
@@ -738,11 +867,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: {
@@ -790,6 +918,44 @@ extension SettingsProTab {
}
}
func settingsButtonToggle(
_ title: String,
isOn: Binding<Bool>,
onChange: ((Bool) -> Void)? = nil) -> some View
{
// Settings switch rows need full-width taps; wrapping Toggle crashes this NavigationStack on iOS 26.
Button {
isOn.wrappedValue.toggle()
} label: {
HStack {
Text(title)
Spacer(minLength: 8)
self.settingsSwitchIndicator(isOn: isOn.wrappedValue)
}
.font(.subheadline)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(title)
.accessibilityValue(isOn.wrappedValue ? "On" : "Off")
.onChange(of: isOn.wrappedValue) { _, enabled in
onChange?(enabled)
}
}
func settingsSwitchIndicator(isOn: Bool) -> some View {
Capsule()
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
.frame(width: 52, height: 32)
.overlay(alignment: isOn ? .trailing : .leading) {
Circle()
.fill(Color.white)
.frame(width: 28, height: 28)
.padding(2)
.shadow(color: Color.black.opacity(0.14), radius: 1, x: 0, y: 1)
}
}
func simpleSettingsRow(title: String, value: String) -> some View {
HStack {
Text(title)

View File

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

View File

@@ -8,13 +8,16 @@ struct TalkProTab: View {
TalkDefaults.speakerphoneEnabledByDefault
@AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false
@State private var showPermissionPrompt = false
@State private var showTalkIssueDetails = false
var openSettings: () -> Void
private var state: TalkProState {
TalkProState(
gatewayConnected: self.gatewayConnected,
isDemoMode: self.appModel.isAppleReviewDemoModeEnabled,
isEnabled: self.appModel.talkMode.isEnabled || self.talkEnabled,
statusText: self.appModel.talkMode.statusText,
isConfigLoaded: self.appModel.talkMode.gatewayTalkConfigLoaded,
isListening: self.appModel.talkMode.isListening,
isSpeaking: self.appModel.talkMode.isSpeaking,
isUserSpeechDetected: self.appModel.talkMode.isUserSpeechDetected,
@@ -28,6 +31,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
@@ -36,7 +48,6 @@ struct TalkProTab: View {
.padding(.top, 16)
.padding(.bottom, 18)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationBarHidden(true)
}
@@ -61,6 +72,14 @@ 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() }
}
@@ -148,7 +167,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",
@@ -172,9 +191,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)
@@ -191,13 +222,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 {
@@ -217,6 +244,25 @@ struct TalkProTab: View {
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private func controlToggleRow(_ title: String, isOn: Binding<Bool>) -> some View {
Toggle(title, isOn: isOn)
.contentShape(Rectangle())
.padding(.horizontal, 14)
.padding(.vertical, 10)
.overlay {
// Keep Toggle semantics for accessibility while making the full visual row tappable.
Button {
isOn.wrappedValue.toggle()
} label: {
Rectangle()
.fill(.clear)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHidden(true)
}
}
private func cardHeader(
title: String,
value: String?,
@@ -267,12 +313,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)"
@@ -281,11 +333,15 @@ struct TalkProTab: View {
private var heroSubtitle: String {
if self.state
.prefersPermissionCopy { return "Gateway approval is required before this phone can capture voice." }
if self.appModel.isAppleReviewDemoModeEnabled { return "Voice is disabled in Apple Review demo mode." }
if !self.gatewayConnected { return "Connect to your gateway to start a voice conversation." }
if !self.appModel.talkMode.gatewayTalkConfigLoaded {
return "Open Voice settings after the gateway loads Talk configuration."
}
let subtitle = (self.appModel.talkMode.gatewayTalkVoiceModeSubtitle ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if !subtitle.isEmpty { return subtitle }
return "Routes voice to \(self.appModel.activeAgentName)."
return "Routes voice to \(self.appModel.chatAgentName)."
}
private var transportText: String {
@@ -296,6 +352,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
@@ -309,15 +380,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<Bool> {
Binding(
get: { self.talkSpeakerphoneEnabled },
set: { enabled in
self.talkSpeakerphoneEnabled = enabled
self.appModel.setTalkSpeakerphoneEnabled(enabled)
})
}
private func handlePrimaryAction() {
switch self.state.primaryAction {
case .start:
@@ -335,7 +419,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)
}
@@ -363,8 +449,10 @@ enum TalkProWaveformMode: Equatable {
struct TalkProState: Equatable {
let gatewayConnected: Bool
let isDemoMode: Bool
let isEnabled: Bool
let statusText: String
let isConfigLoaded: Bool
let isListening: Bool
let isSpeaking: Bool
let isUserSpeechDetected: Bool
@@ -375,6 +463,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:
@@ -390,6 +479,7 @@ struct TalkProState: Equatable {
default:
break
}
if !self.isConfigLoaded { return "Voice config unavailable" }
if self.isSpeaking { return "Speaking" }
if self.isListening { return "Listening" }
if self.normalizedStatus.contains("connecting") { return "Connecting" }
@@ -399,6 +489,7 @@ struct TalkProState: Equatable {
}
var chipText: String {
if self.isDemoMode { return "Demo" }
if !self.gatewayConnected { return "Offline" }
switch self.permissionState {
case .missingScope, .requestFailed:
@@ -412,6 +503,7 @@ struct TalkProState: Equatable {
default:
break
}
if !self.isConfigLoaded { return "Config" }
if self.isSpeaking { return "Speaking" }
if self.isListening { return "Listening" }
if self.isEnabled { return "Ready" }
@@ -419,6 +511,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:
@@ -432,6 +525,7 @@ struct TalkProState: Equatable {
default:
break
}
if !self.isConfigLoaded { return "exclamationmark.triangle.fill" }
if self.isSpeaking { return "speaker.wave.2.fill" }
if self.isListening { return "mic.fill" }
if self.normalizedStatus.contains("thinking") { return "sparkles" }
@@ -440,6 +534,7 @@ struct TalkProState: Equatable {
}
var color: Color {
if self.isDemoMode { return .secondary }
if !self.gatewayConnected { return .secondary }
switch self.permissionState {
case .requestFailed, .loadFailed:
@@ -447,11 +542,13 @@ struct TalkProState: Equatable {
case .missingScope, .requestingUpgrade, .upgradeRequested, .apiKeyMissing:
return OpenClawBrand.warn
default:
if !self.isConfigLoaded { return OpenClawBrand.warn }
return self.isEnabled ? OpenClawBrand.ok : OpenClawBrand.accentHot
}
}
var primaryAction: TalkProPrimaryAction {
if self.isDemoMode { return .waiting }
if !self.gatewayConnected { return .openSettings }
switch self.permissionState {
case .missingScope, .requestFailed:
@@ -471,7 +568,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"
}
}
@@ -481,7 +578,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"
}
}
@@ -509,6 +606,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:
@@ -518,6 +616,7 @@ struct TalkProState: Equatable {
default:
break
}
if !self.isConfigLoaded { return .still }
if self.isSpeaking { return .speaking }
if self.isListening, self.isUserSpeechDetected { return .inputSpeech }
if self.isListening { return .level(micLevel) }

View File

@@ -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()
}
}
}
}
}
}

View File

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

View File

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

View File

@@ -111,6 +111,7 @@ 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
}
@@ -125,6 +126,7 @@ 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
}
@@ -457,6 +459,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
// Foreground recovery must actively restart the saved gateway config.
@@ -548,6 +551,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 +589,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 +640,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)) }
@@ -760,6 +771,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
@@ -777,13 +789,19 @@ final class NodeAppModel {
func setSelectedAgentId(_ agentId: String?) {
let trimmed = (agentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let nextSelectedAgentId = trimmed.isEmpty ? nil : trimmed
let currentSelectedAgentId = self.selectedAgentId?.trimmingCharacters(in: .whitespacesAndNewlines)
let selectedAgentChanged = currentSelectedAgentId != nextSelectedAgentId
let stableID = (self.connectedGatewayID ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if stableID.isEmpty {
self.selectedAgentId = 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 +920,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 +1854,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 +1874,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
@@ -1926,6 +1968,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
@@ -1949,10 +1992,12 @@ extension NodeAppModel {
}
func disconnectGateway() {
self.isAppleReviewDemoModeEnabled = false
self.gatewayAutoReconnectEnabled = false
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
self.lastGatewayProblem = nil
self.operatorGatewayProblem = nil
self.nodeGatewayTask?.cancel()
self.nodeGatewayTask = nil
self.operatorGatewayTask?.cancel()
@@ -1983,10 +2028,12 @@ extension NodeAppModel {
extension NodeAppModel {
private func prepareForGatewayConnect(url: URL, stableID: String) {
self.isAppleReviewDemoModeEnabled = false
self.gatewayAutoReconnectEnabled = true
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
self.lastGatewayProblem = nil
self.operatorGatewayProblem = nil
self.nodeGatewayTask?.cancel()
self.operatorGatewayTask?.cancel()
self.gatewayHealthMonitor.stop()
@@ -2001,17 +2048,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 +2096,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 +2305,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 +2329,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 +2352,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 +2375,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 +2443,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 +2470,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 +2479,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 +2530,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 +2576,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 +2621,7 @@ extension NodeAppModel {
}
await MainActor.run {
guard !self.isAppleReviewDemoModeEnabled else { return }
self.lastGatewayProblem = nil
self.gatewayStatusText = "Offline"
LiveActivityManager.shared.endActivity(reason: "gateway_loop_stopped")
@@ -2527,7 +2639,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 +2651,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 +2675,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 {
@@ -2599,6 +2760,52 @@ extension NodeAppModel {
}
}
extension NodeAppModel {
func enterAppleReviewDemoMode() {
self.isAppleReviewDemoModeEnabled = true
self.gatewayAutoReconnectEnabled = false
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
self.lastGatewayProblem = nil
self.operatorGatewayProblem = nil
self.nodeGatewayTask?.cancel()
self.nodeGatewayTask = nil
self.operatorGatewayTask?.cancel()
self.operatorGatewayTask = nil
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil
self.gatewayHealthMonitor.stop()
LiveActivityManager.shared.endActivity(reason: "apple_review_demo")
Task {
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
}
self.gatewayStatusText = "Connected"
self.nodeStatusText = "Connected"
self.gatewayServerName = AppleReviewDemoMode.gatewayName
self.gatewayRemoteAddress = AppleReviewDemoMode.gatewayAddress
self.connectedGatewayID = AppleReviewDemoMode.gatewayID
self.activeGatewayConnectConfig = nil
self.gatewayConnected = true
self.setOperatorConnected(false)
UserDefaults.standard.set(false, forKey: "talk.enabled")
UserDefaults.standard.set(false, forKey: "talk.background.enabled")
self.talkMode.updateGatewayConnected(false)
self.talkMode.setEnabled(false)
self.talkMode.statusText = "Demo mode only"
self.seamColorHex = nil
self.mainSessionBaseKey = "main"
self.selectedAgentId = nil
self.gatewayDefaultAgentId = "main"
self.gatewayAgents = AppleReviewDemoMode.agents
self.focusedChatSessionKey = nil
self.talkMode.updateMainSessionKey(self.mainSessionKey)
self.homeCanvasRevision &+= 1
}
}
extension NodeAppModel {
private struct PendingForegroundNodeAction: Decodable {
var id: String
@@ -4425,6 +4632,10 @@ extension NodeAppModel {
self.gatewayConnected = connected
}
func _test_isGatewayConnected() -> Bool {
self.gatewayConnected
}
func _test_applyPendingForegroundNodeActions(
_ actions: [(id: String, command: String, paramsJSON: String?)]) async
{
@@ -4441,12 +4652,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 +4672,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 +4777,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(

View File

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

View File

@@ -7,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)
}

View File

@@ -72,6 +72,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 +174,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 +213,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 +281,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 +310,8 @@ struct OnboardingWizardView: View {
@ViewBuilder
private var modeStep: some View {
self.setupCodeSection
Section("Connection Mode") {
OnboardingModeRow(
title: OnboardingConnectionMode.homeNetwork.title,
@@ -318,16 +329,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 +350,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<Bool>) -> some View {
// Onboarding Form switch rows need full-width taps; native Toggle only hits the switch edge on iOS 26.
Button {
isOn.wrappedValue.toggle()
} label: {
HStack {
Text(title)
Spacer(minLength: 8)
self.onboardingSwitchIndicator(isOn: isOn.wrappedValue)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(title)
.accessibilityValue(isOn.wrappedValue ? "On" : "Off")
}
private func onboardingSwitchIndicator(isOn: Bool) -> some View {
Capsule()
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
.frame(width: 52, height: 32)
.overlay(alignment: isOn ? .trailing : .leading) {
Circle()
.fill(Color.white)
.frame(width: 28, height: 28)
.padding(2)
.shadow(color: Color.black.opacity(0.14), radius: 1, x: 0, y: 1)
}
}
@ViewBuilder
private var connectStep: some View {
if let selectedMode {
@@ -440,7 +485,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 +615,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 +660,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 +691,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 +750,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() {

View File

@@ -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
}
}
}

View File

@@ -298,7 +298,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)

View File

@@ -14,6 +14,15 @@ enum SessionKey {
return "agent:\(trimmedAgent):\(normalizedBase)"
}
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
}
static func isCanonicalMainSessionKey(_ value: String?) -> Bool {
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return false }

View File

@@ -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<StartupWaitResult, Never>?
private var pendingPreRelayEvents: [EventFrame] = []
private var inputSampleRateHz = Double(RealtimeTalkRelaySession.defaultSampleRateHz)
private var outputSampleRateHz = Double(RealtimeTalkRelaySession.defaultSampleRateHz)
private var eventTask: Task<Void, Never>?
@@ -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)
}

View File

@@ -7,6 +7,96 @@ enum TalkModeExecutionMode {
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?

View File

@@ -60,6 +60,10 @@ 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 {
@@ -77,6 +81,12 @@ final class TalkModeManager: NSObject {
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 +139,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 +204,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 +313,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 +342,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 +401,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 +452,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()
@@ -1047,8 +1076,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 +1100,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 +1159,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 +1182,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 +2412,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 +2982,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 +3027,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,6 +3061,49 @@ extension TalkModeManager {
self.gatewayTalkUsesRealtimeRelay
}
func _test_markNativeFallbackActive(after issue: TalkRuntimeIssue) {
self.markNativeFallbackActive(after: issue)
}
func _test_recordRealtimeIssue(_ issue: TalkRuntimeIssue) {
self.pendingRealtimeIssue = issue
self.gatewayTalkLastIssueText = issue.diagnosticSummary
self.gatewayTalkActiveModeTitle = "Realtime unavailable"
self.gatewayTalkActiveModeSubtitle = issue.displayMessage
}
func _test_handleRealtimeRelayStatus(_ status: String) {
self.handleRealtimeRelayStatus(status)
}
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_seedTranscript(_ transcript: String) {
self.lastTranscript = transcript
self.lastHeard = Date()

View File

@@ -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
@@ -20,6 +21,7 @@ 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

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