mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-22 23:13:42 +08:00
Compare commits
379 Commits
codex/tele
...
codex/mess
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fa840c26f | ||
|
|
e399a92e6c | ||
|
|
36e76ef424 | ||
|
|
ddd3d69b86 | ||
|
|
ae4806ed9a | ||
|
|
0a4de3de57 | ||
|
|
eb7f3b7b50 | ||
|
|
a4c81c6f35 | ||
|
|
b8e9ab9385 | ||
|
|
c8a35c4645 | ||
|
|
577e64db63 | ||
|
|
60d200f797 | ||
|
|
7f4bd454fe | ||
|
|
221f5349b5 | ||
|
|
ee9813f478 | ||
|
|
cbe68ba1a1 | ||
|
|
d391434f4e | ||
|
|
4faeb378ee | ||
|
|
1f9ebb9dda | ||
|
|
0aabaebba1 | ||
|
|
6fe3088bc6 | ||
|
|
7f499643b2 | ||
|
|
777a113973 | ||
|
|
bc9e601491 | ||
|
|
0df9f297b6 | ||
|
|
f015c3ff52 | ||
|
|
15a0156a8c | ||
|
|
fad1c8a071 | ||
|
|
e2c92be90b | ||
|
|
ba06376c79 | ||
|
|
c2004fe662 | ||
|
|
373e3fc719 | ||
|
|
08f66133ef | ||
|
|
dca9cecaee | ||
|
|
d4c6bdfeae | ||
|
|
6b5eba1f43 | ||
|
|
1b77145687 | ||
|
|
7cda26aa6c | ||
|
|
61150870e2 | ||
|
|
75a011977d | ||
|
|
8aabb79a83 | ||
|
|
cabb55380f | ||
|
|
0ab1449215 | ||
|
|
c5e8bd08b8 | ||
|
|
de5f1fa99a | ||
|
|
26e64bda14 | ||
|
|
f52db027a0 | ||
|
|
98af51748d | ||
|
|
0212188cb6 | ||
|
|
5f5e3b4511 | ||
|
|
48bb3b0a74 | ||
|
|
4c6fe55d20 | ||
|
|
814386a10b | ||
|
|
c17a48ccfd | ||
|
|
9a4fb3ed7e | ||
|
|
ee915cfede | ||
|
|
8961eae3f0 | ||
|
|
7f4462e5c0 | ||
|
|
01d95b9757 | ||
|
|
504f0dfa36 | ||
|
|
b77f36fb1c | ||
|
|
9b7e431b89 | ||
|
|
faf96ff99b | ||
|
|
4399eee6e0 | ||
|
|
016c34ff1d | ||
|
|
1d5b5db4d2 | ||
|
|
aef8d1771d | ||
|
|
7ddcca6c77 | ||
|
|
c452a1e7e5 | ||
|
|
01087cb936 | ||
|
|
180cecda85 | ||
|
|
4f80cc1943 | ||
|
|
ebd8b00cc3 | ||
|
|
b25a0d013b | ||
|
|
7d5afcbb3f | ||
|
|
bbf3eec786 | ||
|
|
ec0cf9af04 | ||
|
|
46c8864048 | ||
|
|
23c58081d0 | ||
|
|
205c595b13 | ||
|
|
178e510aae | ||
|
|
7f943b5d8f | ||
|
|
5955f354f7 | ||
|
|
efb7e4742f | ||
|
|
b33deb4159 | ||
|
|
652712e0ad | ||
|
|
9f2c0a80b4 | ||
|
|
da1925cb67 | ||
|
|
277a4b6952 | ||
|
|
229323d37a | ||
|
|
0e6f314dbb | ||
|
|
cf0657852f | ||
|
|
66dcc4ee8f | ||
|
|
1b1580cbc3 | ||
|
|
e72f601925 | ||
|
|
94b6d9f8b2 | ||
|
|
6dbd5bd446 | ||
|
|
2bb00f6726 | ||
|
|
95eac52e92 | ||
|
|
e0b53cae41 | ||
|
|
02182d5a30 | ||
|
|
159b3002e4 | ||
|
|
a901396ad1 | ||
|
|
c49647ee23 | ||
|
|
db606a8475 | ||
|
|
6ccca4ae95 | ||
|
|
b248b4816b | ||
|
|
d2ad7d6b4c | ||
|
|
bde07ddb15 | ||
|
|
04061bc801 | ||
|
|
88c49f9e68 | ||
|
|
f39f56a096 | ||
|
|
2000227e9e | ||
|
|
f43e83c937 | ||
|
|
8a8f9dc8cb | ||
|
|
0fb1de5f73 | ||
|
|
b7f9bf5a5c | ||
|
|
3260da003d | ||
|
|
ec67290e0b | ||
|
|
c89632b647 | ||
|
|
5813fa4584 | ||
|
|
233765b361 | ||
|
|
e3b77d6d2c | ||
|
|
248169b646 | ||
|
|
88fe39bc8b | ||
|
|
43c6c260de | ||
|
|
4a360ac1cc | ||
|
|
3eb2d64392 | ||
|
|
b05c6158c0 | ||
|
|
ec7495c993 | ||
|
|
ec10d12112 | ||
|
|
3cc8b2a3d0 | ||
|
|
a2d0d6b0c2 | ||
|
|
40db92f609 | ||
|
|
3faddfb506 | ||
|
|
2fd02c2060 | ||
|
|
624d920351 | ||
|
|
0604d25101 | ||
|
|
1e8d9666b0 | ||
|
|
1c5fda115f | ||
|
|
a329b9e1ee | ||
|
|
e427262044 | ||
|
|
9ec9fbf58d | ||
|
|
de743c5a54 | ||
|
|
194f0786d4 | ||
|
|
8284c035a0 | ||
|
|
ae80adbefb | ||
|
|
c9b6a8b408 | ||
|
|
3156d94bca | ||
|
|
79be940130 | ||
|
|
0671a2a788 | ||
|
|
168f8a758e | ||
|
|
46030f5489 | ||
|
|
c0312748c4 | ||
|
|
a30ac3f8d7 | ||
|
|
6745fe8e70 | ||
|
|
2c0c9c92f4 | ||
|
|
2585249737 | ||
|
|
3d3cf96dc9 | ||
|
|
86ebceeb2e | ||
|
|
c4f14a39a5 | ||
|
|
9cdf8a1e2f | ||
|
|
e964987cd2 | ||
|
|
b79effefee | ||
|
|
d91ef6bb17 | ||
|
|
b3ec4f08d1 | ||
|
|
cd019cfa41 | ||
|
|
5c4c6a4207 | ||
|
|
b58572e283 | ||
|
|
4d47f9a4c0 | ||
|
|
90fd26b602 | ||
|
|
3844513431 | ||
|
|
6b52105b23 | ||
|
|
d786b4eb55 | ||
|
|
1fdeee380e | ||
|
|
2e389b6a46 | ||
|
|
f4dc9b1232 | ||
|
|
aa687a08cd | ||
|
|
e57fa51412 | ||
|
|
3c3ef6067e | ||
|
|
ec8e7003a6 | ||
|
|
6c7fe58468 | ||
|
|
7b9066120a | ||
|
|
6e9d47bd12 | ||
|
|
9e4eca00ff | ||
|
|
404fd6d9ab | ||
|
|
6e7bd551f2 | ||
|
|
ca0fe884ff | ||
|
|
d5cc0d53b7 | ||
|
|
1a7669bc63 | ||
|
|
447a3643c6 | ||
|
|
950e5c8c50 | ||
|
|
0af55f971d | ||
|
|
a13468320c | ||
|
|
c8a953af93 | ||
|
|
ac69776330 | ||
|
|
48a14e41e2 | ||
|
|
32fbb9ff01 | ||
|
|
9c00268914 | ||
|
|
5d775122c1 | ||
|
|
99c88629c3 | ||
|
|
9a6744baba | ||
|
|
befb0f3d39 | ||
|
|
d1470360c4 | ||
|
|
94ac563399 | ||
|
|
cbf72e5e26 | ||
|
|
9c5e8eb495 | ||
|
|
3c8050c44c | ||
|
|
45930457ca | ||
|
|
167e73cd5f | ||
|
|
110042d840 | ||
|
|
ea8f4ebb4d | ||
|
|
0c67dc7f82 | ||
|
|
e98760a1bf | ||
|
|
67c12e0368 | ||
|
|
989e53c20d | ||
|
|
bbcac0019b | ||
|
|
64b6cafcaa | ||
|
|
98f2e568b3 | ||
|
|
c289e3ea87 | ||
|
|
c0ac4564f7 | ||
|
|
07b28a6dd6 | ||
|
|
85ef8fb975 | ||
|
|
c885a1c243 | ||
|
|
dd772307a3 | ||
|
|
43b03b7621 | ||
|
|
9868f4cf29 | ||
|
|
d3cf65eb14 | ||
|
|
3aefd355c4 | ||
|
|
8d492637af | ||
|
|
5de8f8e8a9 | ||
|
|
338a0062c4 | ||
|
|
69e646f680 | ||
|
|
d41f595c75 | ||
|
|
a9669c0f9f | ||
|
|
2294c28355 | ||
|
|
499ccd1522 | ||
|
|
de195645f9 | ||
|
|
357e3ecc65 | ||
|
|
f359299df4 | ||
|
|
3d5be4c5a9 | ||
|
|
6db000630c | ||
|
|
fd05179d0a | ||
|
|
e067203b22 | ||
|
|
817ca4bf65 | ||
|
|
41175edd98 | ||
|
|
b6e04fa6a2 | ||
|
|
efe7393064 | ||
|
|
d7a90ebea6 | ||
|
|
e5cd050e51 | ||
|
|
aca22366f2 | ||
|
|
e8a90a03df | ||
|
|
c842f542cd | ||
|
|
415a338dc6 | ||
|
|
ceb7e04108 | ||
|
|
ee6c42945a | ||
|
|
abf70ac04e | ||
|
|
2ce12552bb | ||
|
|
289eea04d0 | ||
|
|
ac28341ebf | ||
|
|
ca4264202e | ||
|
|
af5e0b26ef | ||
|
|
f4cc4655ef | ||
|
|
5a82e4aa19 | ||
|
|
eff8b41fb0 | ||
|
|
d593f5b062 | ||
|
|
6af2fa4ec3 | ||
|
|
6db48f70e8 | ||
|
|
818aa36f7c | ||
|
|
7d0bb236f2 | ||
|
|
1882984380 | ||
|
|
9342deeae3 | ||
|
|
26352f5a13 | ||
|
|
ff50cdf396 | ||
|
|
7e0584579c | ||
|
|
5200e8a436 | ||
|
|
ac43f47820 | ||
|
|
448eb36f75 | ||
|
|
65030f3164 | ||
|
|
7811e313b3 | ||
|
|
ddf9fbed34 | ||
|
|
29f8715f05 | ||
|
|
5c39e0019d | ||
|
|
47eb4ca14f | ||
|
|
9eee202a69 | ||
|
|
a54c73687f | ||
|
|
a57ab2448f | ||
|
|
c982358753 | ||
|
|
18a514e39e | ||
|
|
33fc2375f8 | ||
|
|
ad925bd43b | ||
|
|
9108ae0114 | ||
|
|
2ab3a4e422 | ||
|
|
5d799c2d20 | ||
|
|
125f0c31dd | ||
|
|
e1c1c57242 | ||
|
|
0556ac0291 | ||
|
|
eb814b0216 | ||
|
|
a002c416c7 | ||
|
|
fd790e2977 | ||
|
|
6b82eaa2cd | ||
|
|
70e51b81cf | ||
|
|
0e2a06ae10 | ||
|
|
6048cd43a5 | ||
|
|
d7896ed4c9 | ||
|
|
f6de2b3885 | ||
|
|
2a01fbb56c | ||
|
|
7f8141ead9 | ||
|
|
ab7aa88ef2 | ||
|
|
4408e60c31 | ||
|
|
165cc581cd | ||
|
|
ff5354ee4f | ||
|
|
00da318350 | ||
|
|
eea71708ac | ||
|
|
79197b3196 | ||
|
|
d0bc520de8 | ||
|
|
e0d1a2a9b9 | ||
|
|
68c5a892d0 | ||
|
|
375afbad2d | ||
|
|
a00e7d3898 | ||
|
|
1bb0ebab0b | ||
|
|
97aa0c8c01 | ||
|
|
e61fe1c539 | ||
|
|
88d8d6af93 | ||
|
|
b9a2c11521 | ||
|
|
ecb6da9289 | ||
|
|
5c9a8f33b3 | ||
|
|
d7b23d5bca | ||
|
|
a059309a9f | ||
|
|
3bc728eaa9 | ||
|
|
c81271ee6e | ||
|
|
3d96111a5a | ||
|
|
f5f0b2c7c9 | ||
|
|
28beea9e88 | ||
|
|
edd7c8e4a1 | ||
|
|
9b97e1ef2f | ||
|
|
94d8391c03 | ||
|
|
e00cb664ad | ||
|
|
323c9760d3 | ||
|
|
edcf862da5 | ||
|
|
78d226bb3b | ||
|
|
6899eff155 | ||
|
|
9e9feb52f4 | ||
|
|
48acdd3d85 | ||
|
|
d0f7c8fa28 | ||
|
|
5d19beb547 | ||
|
|
13c97c5a8d | ||
|
|
b7ba7c3f2a | ||
|
|
f07c87405c | ||
|
|
03d774d6d8 | ||
|
|
4e60ad7212 | ||
|
|
d916f176e1 | ||
|
|
e2c8e7c8ae | ||
|
|
d7083bab4c | ||
|
|
5e0850fc54 | ||
|
|
1c1c75df72 | ||
|
|
aef93881af | ||
|
|
1d77170a30 | ||
|
|
6ee60fcfe2 | ||
|
|
b66e91ba77 | ||
|
|
896fd13b1c | ||
|
|
ddeaebfc68 | ||
|
|
04eac15f43 | ||
|
|
754b4234cb | ||
|
|
cf235b209f | ||
|
|
ac07701833 | ||
|
|
ff871e162a | ||
|
|
f0a86450b1 | ||
|
|
d60ab48511 | ||
|
|
b86435f0b5 | ||
|
|
6fcfeed5dc | ||
|
|
b2f9f197a5 | ||
|
|
6da73ac90f | ||
|
|
cc835b6d72 | ||
|
|
cbaf858227 | ||
|
|
1f794d2816 | ||
|
|
ba7ce3c6b9 | ||
|
|
57ec361682 | ||
|
|
d75e16a1b9 |
@@ -23,10 +23,11 @@ Use when:
|
||||
- Prefer small fixes at the right ownership boundary; no refactor unless it clearly improves the bug class.
|
||||
- Keep going until the selected review path returns no accepted/actionable findings.
|
||||
- If a review-triggered fix changes code, rerun focused tests and rerun the review helper.
|
||||
- Default to Codex review. If Codex is unavailable or exits with an error, the helper falls back to the first configured CLI from `claude -p`, `pi -p`, `opencode run`, `droid exec`, or `copilot`. Prefer Codex for final closeout because it uses native review mode; non-Codex reviewers use a Codex-inspired generated diff prompt. The helper runs nested Codex review in yolo/full-access mode by default; use `--no-yolo` only when intentionally testing sandbox behavior.
|
||||
- Default to Codex review with no fallback. Prefer Codex for final closeout because it uses native review mode; non-Codex reviewers use a Codex-inspired generated diff prompt. Use `--fallback-reviewer auto|claude|pi|opencode|droid|copilot` only when a second-model fallback is explicitly wanted and authenticated. The helper runs nested Codex review in yolo/full-access mode by default; use `--no-yolo` only when intentionally testing sandbox behavior.
|
||||
- Stop as soon as the review command/helper exits 0 with no accepted/actionable findings. Do not run an extra direct `codex review` just to get a nicer "clean" line, a second opinion, or clearer closeout wording.
|
||||
- Treat the helper's successful exit plus absence of actionable findings as the clean review result, even if the underlying Codex CLI output is terse.
|
||||
- If rejecting a finding as intentional/not worth fixing, add a brief inline code comment only when it explains a real invariant or ownership decision that future reviewers should know.
|
||||
- If creating or updating a PR while rejecting any autoreview finding, record the rejected finding and reason in the PR description so later reviewers can distinguish intentional design decisions from missed review output.
|
||||
- Do not push just to review. Push only when the user requested push/ship/PR update.
|
||||
- For OpenClaw maintainers, keep autoreview validation Crabbox/Testbox-aware when maintainer validation mode is enabled (`OPENCLAW_TESTBOX=1` or `AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION=1`). A review pass may inspect files and run cheap non-Node probes, but it must not start local `pnpm`, Vitest, `tsgo`, `npm test`, or `node scripts/run-vitest.mjs` from a Codex/worktree review unless the operator explicitly requested local proof. For runtime proof, use existing evidence or route through Crabbox/Testbox and report the id. Do not apply this rule to ordinary contributors who do not have maintainer Testbox access.
|
||||
|
||||
@@ -51,11 +52,11 @@ git fetch origin
|
||||
codex review --base origin/main
|
||||
```
|
||||
|
||||
Do not pass any prompt with `--base`. Some Codex CLI versions reject both inline
|
||||
and stdin prompt forms, including the helper's `codex review --base <ref> -`,
|
||||
with `--base <BRANCH> cannot be used with [PROMPT]`. If the helper hits this
|
||||
error, run plain `codex review --base <ref>` and report that the helper prompt
|
||||
injection was skipped.
|
||||
Do not pass any prompt with `--base`, `--commit`, or `--uncommitted`. Codex CLI
|
||||
review targets and custom review prompts are mutually exclusive: target modes
|
||||
generate their own review prompt internally. Use plain target review for native
|
||||
Codex closeout, or use custom prompt review (`codex review -`) only when you
|
||||
intentionally want a generated diff prompt instead of native target review.
|
||||
|
||||
If an open PR exists, use its actual base:
|
||||
|
||||
@@ -116,13 +117,13 @@ The helper:
|
||||
- use `--mode commit --commit <ref>` for already-committed work, especially clean `main` after landing
|
||||
- should be left in `--mode auto` or forced to `--mode branch` for PR/branch work; do not force `--mode local` after committing
|
||||
- supports `--reviewer codex|claude|pi|opencode|droid|copilot|auto`; `auto` means Codex first
|
||||
- supports `--fallback-reviewer auto|claude|pi|opencode|droid|copilot|none`; default is configured CLI fallback
|
||||
- supports `--fallback-reviewer auto|claude|pi|opencode|droid|copilot|none`; default is `none`
|
||||
- falls back only when Codex is unavailable or exits nonzero, not when Codex reports findings
|
||||
- writes only to stdout unless `--output` or `AUTOREVIEW_OUTPUT` is set
|
||||
- supports `--dry-run`, `--parallel-tests`, and commit refs
|
||||
- runs nested review with `--dangerously-bypass-approvals-and-sandbox --sandbox danger-full-access` by default
|
||||
- injects maintainer-only OpenClaw validation policy into native Codex review when `OPENCLAW_TESTBOX=1` or `AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION=1`, so local memory-heavy Node/Vitest checks are avoided in favor of Crabbox/Testbox proof
|
||||
- branch mode may fail on Codex CLI versions that reject `--base` plus the helper's stdin prompt; on that exact parser error, rerun plain `codex review --base <ref>` instead of falling back to a non-Codex reviewer
|
||||
- with `OPENCLAW_TESTBOX=1` or `AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION=1`, disables auto local `pnpm run check` and routes Codex through generated prompt review (`codex review -`) so the no-local-heavy-tests policy is included; native Codex target review cannot accept extra prompt text
|
||||
- non-Codex reviewers receive the generated diff prompt and maintainer validation policy text when maintainer validation is active
|
||||
- keeps accepting `--full-access`; use `--no-yolo` or `AUTOREVIEW_YOLO=0` to opt out
|
||||
- still accepts legacy `CODEX_REVIEW_*` env vars when the matching `AUTOREVIEW_*` var is unset
|
||||
- prints `autoreview clean: no accepted/actionable findings reported` when the selected review command exits 0
|
||||
|
||||
@@ -11,9 +11,9 @@ Options:
|
||||
--base REF Base ref for branch review. Default: PR base or origin/main.
|
||||
--commit REF Commit ref for commit review. Default: HEAD.
|
||||
--reviewer codex|claude|pi|opencode|droid|copilot|auto
|
||||
Review engine. Default: Codex with configured fallback on error.
|
||||
Review engine. Default: Codex.
|
||||
--fallback-reviewer auto|claude|pi|opencode|droid|copilot|none
|
||||
Fallback when Codex is unavailable or exits nonzero. Default: auto.
|
||||
Fallback when Codex is unavailable or exits nonzero. Default: none.
|
||||
--codex-bin PATH Codex binary. Default: codex.
|
||||
--claude-bin PATH Claude binary. Default: claude.
|
||||
--pi-bin PATH Pi binary. Default: pi.
|
||||
@@ -44,7 +44,7 @@ mode=auto
|
||||
base_ref=
|
||||
commit_ref=HEAD
|
||||
reviewer=${AUTOREVIEW_REVIEWER:-${CODEX_REVIEW_REVIEWER:-auto}}
|
||||
fallback_reviewer=${AUTOREVIEW_FALLBACK_REVIEWER:-${CODEX_REVIEW_FALLBACK_REVIEWER:-auto}}
|
||||
fallback_reviewer=${AUTOREVIEW_FALLBACK_REVIEWER:-${CODEX_REVIEW_FALLBACK_REVIEWER:-none}}
|
||||
codex_bin=${CODEX_BIN:-codex}
|
||||
claude_bin=${CLAUDE_BIN:-claude}
|
||||
pi_bin=${PI_BIN:-pi}
|
||||
@@ -58,7 +58,6 @@ parallel_tests=
|
||||
parallel_tests_auto=false
|
||||
dry_run=false
|
||||
codex_review_prompt=
|
||||
codex_review_stdin_prompt=false
|
||||
codex_review_prompt_file=false
|
||||
openclaw_maintainer_validation=${AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION:-${OPENCLAW_TESTBOX:-0}}
|
||||
|
||||
@@ -249,13 +248,8 @@ OpenClaw maintainer autoreview validation policy:
|
||||
- If remote validation is not necessary for the finding, state the targeted proof that should be run instead of starting local tests.
|
||||
EOF
|
||||
)
|
||||
if [[ "$review_kind" == local ]]; then
|
||||
review_cmd+=(-)
|
||||
codex_review_stdin_prompt=true
|
||||
else
|
||||
review_cmd=("$codex_bin" "${codex_args[@]}" review -)
|
||||
codex_review_prompt_file=true
|
||||
fi
|
||||
review_cmd=("$codex_bin" "${codex_args[@]}" review -)
|
||||
codex_review_prompt_file=true
|
||||
fi
|
||||
|
||||
printf 'autoreview target: %s\n' "$review_kind"
|
||||
@@ -278,8 +272,8 @@ if [[ "$reviewer" == auto || "$reviewer" == codex ]]; then
|
||||
printf 'review:'
|
||||
printf ' %q' "${review_cmd[@]}"
|
||||
printf '\n'
|
||||
if [[ "$codex_review_stdin_prompt" == true || "$codex_review_prompt_file" == true ]]; then
|
||||
printf 'review policy: OpenClaw maintainer Crabbox/Testbox-aware validation prompt injected\n'
|
||||
if [[ "$codex_review_prompt_file" == true ]]; then
|
||||
printf 'review policy: OpenClaw maintainer validation active; using generated prompt review because Codex target review cannot accept extra prompt text\n'
|
||||
fi
|
||||
else
|
||||
printf 'review: %s prompt review\n' "$reviewer"
|
||||
@@ -337,11 +331,8 @@ run_review() {
|
||||
rm -f "$prompt_file"
|
||||
prompt_file=
|
||||
return "$status"
|
||||
elif [[ "$codex_review_stdin_prompt" == true ]]; then
|
||||
printf '%s\n' "$codex_review_prompt" | "${review_cmd[@]}" 2>&1 | tee "$review_output"
|
||||
else
|
||||
"${review_cmd[@]}" 2>&1 | tee "$review_output"
|
||||
fi
|
||||
"${review_cmd[@]}" 2>&1 | tee "$review_output"
|
||||
}
|
||||
|
||||
diff_for_review() {
|
||||
|
||||
44
.agents/skills/channel-message-flows/SKILL.md
Normal file
44
.agents/skills/channel-message-flows/SKILL.md
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: channel-message-flows
|
||||
description: "Use when previewing local channel message flow fixtures."
|
||||
---
|
||||
|
||||
# Channel Message Flows
|
||||
|
||||
Use this from the OpenClaw repo root to send canned channel preview flows while iterating on message UX. These are real sends/edits/deletes against the configured channel target.
|
||||
|
||||
## Telegram
|
||||
|
||||
Native Telegram `sendMessageDraft` tool progress, then a final answer:
|
||||
|
||||
```bash
|
||||
node --import tsx scripts/dev/channel-message-flows.ts \
|
||||
--channel telegram \
|
||||
--target <telegram-chat-id> \
|
||||
--flow working-final \
|
||||
--duration-ms 20000
|
||||
```
|
||||
|
||||
Thinking preview, then a final answer:
|
||||
|
||||
```bash
|
||||
node --import tsx scripts/dev/channel-message-flows.ts \
|
||||
--channel telegram \
|
||||
--target <telegram-chat-id> \
|
||||
--flow thinking-final
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
- `--account <accountId>`: Telegram account id when not using the default.
|
||||
- `--thread-id <id>`: Telegram forum topic/message thread id.
|
||||
- `--delay-ms <ms>`: Override preview update cadence.
|
||||
- `--duration-ms <ms>`: Simulated working duration for `working-final`.
|
||||
- `--final-text <text>`: Override the durable final message.
|
||||
|
||||
## Notes
|
||||
|
||||
- `--target` is the numeric Telegram chat id.
|
||||
- `working-final` exercises native Telegram `sendMessageDraft` with static `Working` status and sample tool progress.
|
||||
- `thinking-final` exercises formatted `Thinking` reasoning preview clearing before the final answer.
|
||||
- Only `--channel telegram` is implemented for now.
|
||||
@@ -45,6 +45,10 @@ pnpm crabbox:run -- --help | sed -n '1,120p'
|
||||
shim can be stale.
|
||||
- Check `.crabbox.yaml` for direct-provider defaults. Omitting `--provider`
|
||||
means brokered AWS today.
|
||||
- The brokered AWS default is a Linux developer image in `eu-west-1`; the repo
|
||||
config pins hot `eu-west-1a/b/c` placement so Fast Snapshot Restore can apply.
|
||||
If warmup drifts well past the minute-scale path, verify image promotion,
|
||||
region/AZ placement, and FSR state before blaming OpenClaw.
|
||||
- For broad OpenClaw maintainer `pnpm` gates, prefer the repo wrapper with
|
||||
`--provider blacksmith-testbox` or the repo Testbox helpers when the standing
|
||||
Testbox policy applies.
|
||||
@@ -78,6 +82,25 @@ Use these only when the task needs an existing non-Linux host. OpenClaw broad
|
||||
Linux validation uses the repo Crabbox config unless a provider is explicitly
|
||||
requested.
|
||||
|
||||
Native brokered Windows is available for Windows-specific proof. Use the AWS
|
||||
developer image in `us-west-2` on demand; it has the expected OpenClaw developer
|
||||
toolchain and Docker image cache. Keep broad Linux gates on Linux/Testbox unless
|
||||
the bug is Windows-specific:
|
||||
|
||||
```sh
|
||||
../crabbox/bin/crabbox warmup \
|
||||
--provider aws \
|
||||
--target windows \
|
||||
--windows-mode normal \
|
||||
--region us-west-2 \
|
||||
--market on-demand \
|
||||
--timing-json
|
||||
```
|
||||
|
||||
The hydrate workflow assumes Docker should already be baked into Linux images
|
||||
and only installs it as a fallback. Do not add per-run Docker installs to proof
|
||||
commands unless the image probe shows Docker is actually missing.
|
||||
|
||||
When the user explicitly asks for brokered macOS runners, use Crabbox AWS
|
||||
macOS only after confirming the deployed coordinator supports EC2 Mac host
|
||||
lifecycle/image routes and the operator has AWS EC2 Mac Dedicated Host quota
|
||||
|
||||
@@ -28,7 +28,10 @@ git status --short --branch
|
||||
git rev-parse HEAD
|
||||
```
|
||||
|
||||
If env lacks keys, use `$one-password` to inject or set them, then rerun the script. The script prints only provider status and HTTP class, never tokens.
|
||||
1Password service-account values are the first source for release provider
|
||||
preflight. Inject those exact targeted keys first, then run the verifier; use
|
||||
ambient env only when it was already intentionally injected for this release.
|
||||
The script prints only provider status and HTTP class, never tokens.
|
||||
|
||||
## Dispatch
|
||||
|
||||
|
||||
@@ -170,6 +170,13 @@ live`; keep it clearly beta and avoid implying stable promotion.
|
||||
CI, validation, or internal release mechanics unless the release is explicitly
|
||||
about those. Peter prefers concrete user wins: features, integrations,
|
||||
workflow improvements, and practical reliability fixes.
|
||||
- Do not feature QA parity, test coverage, release gates, or validation lanes in
|
||||
user-facing launch tweets. Keep them for release notes or maintainer proof
|
||||
unless the operator explicitly asks for validation-focused copy.
|
||||
- Do not feature plugin-author or developer tooling such as SDK helpers,
|
||||
tool-plugin scaffolding, build/validate/init commands, or internal CLI
|
||||
plumbing in general user-facing launch tweets unless the operator explicitly
|
||||
asks for developer-focused copy.
|
||||
- Tone: high-signal, slightly cheeky, confident, not corporate. One joke is
|
||||
enough. Avoid punching down, insulting users, or promising what was not
|
||||
verified.
|
||||
|
||||
@@ -6,6 +6,10 @@ capacity:
|
||||
strategy: most-available
|
||||
fallback: on-demand-after-120s
|
||||
hints: true
|
||||
availabilityZones:
|
||||
- eu-west-1a
|
||||
- eu-west-1b
|
||||
- eu-west-1c
|
||||
regions:
|
||||
- eu-west-1
|
||||
- eu-west-2
|
||||
|
||||
4
.github/actions/setup-node-env/action.yml
vendored
4
.github/actions/setup-node-env/action.yml
vendored
@@ -40,6 +40,7 @@ runs:
|
||||
id: pnpm-cache
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
pnpm-version: ${{ inputs.pnpm-version }}
|
||||
cache-key-suffix: ${{ inputs.cache-key-suffix }}
|
||||
|
||||
@@ -58,14 +59,15 @@ runs:
|
||||
if command -v bun &>/dev/null; then bun -v; fi
|
||||
|
||||
- name: Capture node path
|
||||
if: inputs.install-deps == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
if command -v cygpath >/dev/null 2>&1; then
|
||||
node_bin="$(cygpath -u "$node_bin")"
|
||||
fi
|
||||
# zizmor: ignore[github-env] node_bin comes from trusted actions/setup-node output in this composite action.
|
||||
echo "NODE_BIN=$node_bin" >> "$GITHUB_ENV"
|
||||
echo "$node_bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install dependencies
|
||||
if: inputs.install-deps == 'true'
|
||||
|
||||
@@ -5,6 +5,10 @@ inputs:
|
||||
description: pnpm version to activate via corepack.
|
||||
required: false
|
||||
default: "11.0.8"
|
||||
node-version:
|
||||
description: Expected Node.js version already installed by actions/setup-node.
|
||||
required: false
|
||||
default: "24.x"
|
||||
cache-key-suffix:
|
||||
description: Suffix appended to the cache key.
|
||||
required: false
|
||||
@@ -41,12 +45,85 @@ runs:
|
||||
env:
|
||||
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
|
||||
PNPM_VERSION: ${{ inputs.pnpm-version }}
|
||||
REQUESTED_NODE_VERSION: ${{ inputs.node-version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "$PNPM_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}([.-][0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "::error::Invalid pnpm-version input: '$PNPM_VERSION'"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
requested_node="${REQUESTED_NODE_VERSION:-${NODE_VERSION:-}}"
|
||||
requested_node="${requested_node#v}"
|
||||
|
||||
node_version_matches() {
|
||||
local actual="$1"
|
||||
local requested="$2"
|
||||
if [[ -z "$requested" ]]; then
|
||||
return 0
|
||||
fi
|
||||
case "$requested" in
|
||||
*x)
|
||||
[[ "${actual%%.*}" == "${requested%%.*}" ]]
|
||||
;;
|
||||
*.*.*)
|
||||
[[ "$actual" == "$requested" ]]
|
||||
;;
|
||||
*.*)
|
||||
[[ "$actual" == "$requested".* ]]
|
||||
;;
|
||||
*)
|
||||
[[ "${actual%%.*}" == "$requested" ]]
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
active_node_version="$(node -p 'process.versions.node' 2>/dev/null || true)"
|
||||
if ! node_version_matches "$active_node_version" "$requested_node"; then
|
||||
node_roots=()
|
||||
for root in \
|
||||
"${RUNNER_TOOL_CACHE:-}" \
|
||||
"${AGENT_TOOLSDIRECTORY:-}" \
|
||||
"${ACTIONS_RUNNER_TOOL_CACHE:-}" \
|
||||
"/opt/hostedtoolcache" \
|
||||
"/home/runner/_work/_tool" \
|
||||
"/Users/runner/hostedtoolcache" \
|
||||
"/c/hostedtoolcache/windows"
|
||||
do
|
||||
if [[ -d "$root/node" ]]; then
|
||||
node_roots+=("$root/node")
|
||||
elif [[ "$(basename "$root")" == "node" && -d "$root" ]]; then
|
||||
node_roots+=("$root")
|
||||
fi
|
||||
done
|
||||
|
||||
node_bin=""
|
||||
for node_root in "${node_roots[@]}"; do
|
||||
while IFS= read -r candidate; do
|
||||
candidate_version="$("$candidate" -p 'process.versions.node' 2>/dev/null || true)"
|
||||
if node_version_matches "$candidate_version" "$requested_node"; then
|
||||
node_bin="$candidate"
|
||||
break 2
|
||||
fi
|
||||
done < <(find "$node_root" \( -name node -o -name node.exe \) -type f 2>/dev/null | sort -r)
|
||||
done
|
||||
|
||||
if [[ -n "$node_bin" ]]; then
|
||||
echo "Using Node $("$node_bin" -p 'process.versions.node') from $node_bin"
|
||||
export PATH="$(dirname "$node_bin"):$PATH"
|
||||
hash -r
|
||||
fi
|
||||
fi
|
||||
|
||||
active_node_version="$(node -p 'process.versions.node' 2>/dev/null || true)"
|
||||
if ! node_version_matches "$active_node_version" "$requested_node"; then
|
||||
echo "::error::Expected Node '${requested_node}', but active node is '${active_node_version:-missing}' at $(command -v node || true)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
node -v
|
||||
command -v node
|
||||
command -v corepack
|
||||
corepack enable
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare "pnpm@$PNPM_VERSION" --activate; then
|
||||
|
||||
@@ -119,8 +119,10 @@ than Telegram-visible behavior`. Use this manifest shape and do not create
|
||||
`$OPENCLAW_TELEGRAM_USER_DRIVER_SCRIPT`, the workflow-provided `crabbox`
|
||||
binary, and the workflow-provided local `ffmpeg`/`ffprobe`; do not generate,
|
||||
install, or patch replacement proof tooling during the run. Use the same
|
||||
proof idea for baseline and candidate. You may iterate and rerun if the
|
||||
visual result is not convincing.
|
||||
proof idea for baseline and candidate. Let `start` return or fail on its
|
||||
own; do not kill it while Crabbox is still waiting for bootstrap. Use a long
|
||||
command timeout for `start`, `send`, `view`, and `finish`. You may iterate
|
||||
and rerun if the visual result is not convincing.
|
||||
7. Open Telegram Desktop directly to the newest relevant message with the
|
||||
runner `view` command before finishing each recording. Keep the chat scrolled
|
||||
to the bottom so new proof messages appear in-frame.
|
||||
|
||||
5
.github/labeler.yml
vendored
5
.github/labeler.yml
vendored
@@ -286,6 +286,11 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/oc-path/**"
|
||||
"extensions: policy":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/policy/**"
|
||||
- "docs/cli/policy.md"
|
||||
"extensions: open-prose":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
12
.github/pull_request_template.md
vendored
12
.github/pull_request_template.md
vendored
@@ -9,6 +9,12 @@ If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta
|
||||
- What changed:
|
||||
- What did NOT change (scope boundary):
|
||||
|
||||
## Motivation
|
||||
|
||||
Explain why this change should exist now. Link it to the user pain, failure mode, maintainer need, or product goal. If this is purely mechanical, write `N/A`.
|
||||
|
||||
-
|
||||
|
||||
## Change Type (select all)
|
||||
|
||||
- [ ] Bug fix
|
||||
@@ -35,12 +41,6 @@ If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta
|
||||
- Related #
|
||||
- [ ] This PR fixes a bug or regression
|
||||
|
||||
## Motivation
|
||||
|
||||
Explain why this change should exist now. Link it to the user pain, failure mode, maintainer need, or product goal. If this is purely mechanical, write `N/A`.
|
||||
|
||||
-
|
||||
|
||||
## Real behavior proof (required for external PRs)
|
||||
|
||||
External contributors must show after-fix evidence from a real OpenClaw setup. Unit tests, mocks, lint, typechecks, snapshots, and CI are supplemental only. Screenshots are encouraged even for CLI, console, text, or log changes; terminal screenshots and copied live output count. Be mindful of private information like IP addresses, API keys, phone numbers, non-public endpoints, or other private details when providing evidence.
|
||||
|
||||
11
.github/workflows/crabbox-hydrate.yml
vendored
11
.github/workflows/crabbox-hydrate.yml
vendored
@@ -62,17 +62,20 @@ jobs:
|
||||
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
|
||||
sudo ln -sf "$pnpm_bin" /usr/local/bin/pnpm
|
||||
|
||||
- name: Ensure Docker is available
|
||||
- name: Ensure Docker is running
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "docker not found; installing fallback engine"
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
fi
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
sudo systemctl start docker
|
||||
sudo systemctl start docker || true
|
||||
elif command -v service >/dev/null 2>&1; then
|
||||
sudo service docker start || true
|
||||
fi
|
||||
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
@@ -82,6 +85,10 @@ jobs:
|
||||
sudo chmod 666 /var/run/docker.sock
|
||||
fi
|
||||
|
||||
docker version
|
||||
docker buildx version || true
|
||||
docker compose version || true
|
||||
|
||||
- name: Hydrate provider env helper
|
||||
shell: bash
|
||||
env:
|
||||
|
||||
4
.github/workflows/docker-release.yml
vendored
4
.github/workflows/docker-release.yml
vendored
@@ -155,7 +155,7 @@ jobs:
|
||||
cache-from: type=gha,scope=docker-release-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-amd64
|
||||
build-args: |
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel,codex
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
sbom: true
|
||||
@@ -253,7 +253,7 @@ jobs:
|
||||
cache-from: type=gha,scope=docker-release-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-arm64
|
||||
build-args: |
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel,codex
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
sbom: true
|
||||
|
||||
121
.github/workflows/full-release-validation.yml
vendored
121
.github/workflows/full-release-validation.yml
vendored
@@ -88,6 +88,11 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
codex_plugin_spec:
|
||||
description: Optional Codex plugin install spec for live Docker package checks; blank derives from release_package_spec or packs the selected ref
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
npm_telegram_provider_mode:
|
||||
description: Provider mode for the package Telegram E2E lane
|
||||
required: false
|
||||
@@ -108,7 +113,7 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: full-release-validation-${{ inputs.ref }}-${{ inputs.rerun_group }}
|
||||
cancel-in-progress: ${{ inputs.ref == 'main' && inputs.rerun_group == 'all' }}
|
||||
cancel-in-progress: ${{ (inputs.ref == 'main' && inputs.rerun_group == 'all') || startsWith(inputs.ref, 'tideclaw/alpha/') }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
@@ -151,6 +156,7 @@ jobs:
|
||||
RELEASE_PACKAGE_SPEC: ${{ inputs.release_package_spec }}
|
||||
EVIDENCE_PACKAGE_SPEC: ${{ inputs.evidence_package_spec }}
|
||||
PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }}
|
||||
CODEX_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }}
|
||||
RELEASE_PROFILE: ${{ inputs.release_profile }}
|
||||
RUN_RELEASE_SOAK: ${{ inputs.run_release_soak || inputs.release_profile == 'full' }}
|
||||
RERUN_GROUP: ${{ inputs.rerun_group }}
|
||||
@@ -208,14 +214,43 @@ jobs:
|
||||
else
|
||||
echo "- Package Acceptance package spec: SHA-built release artifact"
|
||||
fi
|
||||
if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then
|
||||
echo "- Codex plugin spec: \`${CODEX_PLUGIN_SPEC}\`"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
docker_runtime_assets_preflight:
|
||||
name: Verify Docker runtime-assets prune path
|
||||
needs: [resolve_target]
|
||||
if: inputs.rerun_group == 'all'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout target SHA
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.resolve_target.outputs.sha }}
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify Docker runtime-assets prune path
|
||||
env:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
timeout --foreground --kill-after=30s 35m docker build \
|
||||
--target runtime-assets \
|
||||
--build-arg OPENCLAW_EXTENSIONS="matrix" \
|
||||
.
|
||||
|
||||
normal_ci:
|
||||
name: Run normal full CI
|
||||
needs: [resolve_target]
|
||||
if: contains(fromJSON('["all","ci"]'), inputs.rerun_group)
|
||||
needs: [resolve_target, docker_runtime_assets_preflight]
|
||||
if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","ci"]'), inputs.rerun_group) && (inputs.rerun_group != 'all' || needs.docker_runtime_assets_preflight.result == 'success') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: ${{ inputs.release_profile == 'full' && 240 || 60 }}
|
||||
timeout-minutes: ${{ inputs.release_profile != 'minimum' && 240 || 60 }}
|
||||
outputs:
|
||||
run_id: ${{ steps.dispatch.outputs.run_id }}
|
||||
url: ${{ steps.dispatch.outputs.url }}
|
||||
@@ -312,10 +347,10 @@ jobs:
|
||||
|
||||
plugin_prerelease:
|
||||
name: Run plugin prerelease validation
|
||||
needs: [resolve_target]
|
||||
if: contains(fromJSON('["all","plugin-prerelease"]'), inputs.rerun_group)
|
||||
needs: [resolve_target, docker_runtime_assets_preflight]
|
||||
if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","plugin-prerelease"]'), inputs.rerun_group) && (inputs.rerun_group != 'all' || needs.docker_runtime_assets_preflight.result == 'success') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: ${{ inputs.release_profile == 'full' && 300 || 60 }}
|
||||
timeout-minutes: ${{ inputs.release_profile == 'full' && 300 || inputs.release_profile == 'stable' && 240 || 60 }}
|
||||
outputs:
|
||||
run_id: ${{ steps.dispatch.outputs.run_id }}
|
||||
url: ${{ steps.dispatch.outputs.url }}
|
||||
@@ -412,10 +447,10 @@ jobs:
|
||||
|
||||
release_checks:
|
||||
name: Run release/live/Docker/QA validation
|
||||
needs: [resolve_target]
|
||||
if: contains(fromJSON('["all","release-checks","install-smoke","cross-os","live-e2e","package","qa","qa-parity","qa-live"]'), inputs.rerun_group)
|
||||
needs: [resolve_target, docker_runtime_assets_preflight]
|
||||
if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","release-checks","install-smoke","cross-os","live-e2e","package","qa","qa-parity","qa-live"]'), inputs.rerun_group) && (inputs.rerun_group != 'all' || needs.docker_runtime_assets_preflight.result == 'success') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: ${{ inputs.release_profile == 'full' && 240 || 60 }}
|
||||
timeout-minutes: ${{ inputs.release_profile != 'minimum' && 240 || 60 }}
|
||||
outputs:
|
||||
run_id: ${{ steps.dispatch.outputs.run_id }}
|
||||
url: ${{ steps.dispatch.outputs.url }}
|
||||
@@ -437,6 +472,7 @@ jobs:
|
||||
CROSS_OS_SUITE_FILTER: ${{ inputs.cross_os_suite_filter }}
|
||||
RELEASE_PACKAGE_SPEC: ${{ inputs.release_package_spec }}
|
||||
PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }}
|
||||
CODEX_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -532,6 +568,9 @@ jobs:
|
||||
if [[ -n "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then
|
||||
echo "- Package Acceptance package spec: \`${PACKAGE_ACCEPTANCE_PACKAGE_SPEC}\`"
|
||||
fi
|
||||
if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then
|
||||
echo "- Codex plugin spec: \`${CODEX_PLUGIN_SPEC}\`"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
child_rerun_group="$RERUN_GROUP"
|
||||
@@ -560,13 +599,16 @@ jobs:
|
||||
if [[ -n "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then
|
||||
args+=(-f package_acceptance_package_spec="$PACKAGE_ACCEPTANCE_PACKAGE_SPEC")
|
||||
fi
|
||||
if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then
|
||||
args+=(-f codex_plugin_spec="$CODEX_PLUGIN_SPEC")
|
||||
fi
|
||||
|
||||
dispatch_and_wait openclaw-release-checks.yml "${args[@]}"
|
||||
|
||||
prepare_release_package:
|
||||
name: Prepare release package artifact
|
||||
needs: [resolve_target]
|
||||
if: ${{ inputs.npm_telegram_package_spec == '' && inputs.release_package_spec == '' && inputs.rerun_group == 'all' && inputs.release_profile == 'full' }}
|
||||
needs: [resolve_target, docker_runtime_assets_preflight]
|
||||
if: ${{ always() && needs.resolve_target.result == 'success' && inputs.npm_telegram_package_spec == '' && inputs.release_package_spec == '' && inputs.rerun_group == 'all' && inputs.release_profile == 'full' && needs.docker_runtime_assets_preflight.result == 'success' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
@@ -735,7 +777,7 @@ jobs:
|
||||
|
||||
summary:
|
||||
name: Verify full validation
|
||||
needs: [resolve_target, normal_ci, plugin_prerelease, release_checks, npm_telegram]
|
||||
needs: [resolve_target, docker_runtime_assets_preflight, normal_ci, plugin_prerelease, release_checks, npm_telegram]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
@@ -751,6 +793,8 @@ jobs:
|
||||
PLUGIN_PRERELEASE_RESULT: ${{ needs.plugin_prerelease.result }}
|
||||
RELEASE_CHECKS_RESULT: ${{ needs.release_checks.result }}
|
||||
NPM_TELEGRAM_RESULT: ${{ needs.npm_telegram.result }}
|
||||
DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT: ${{ needs.docker_runtime_assets_preflight.result }}
|
||||
RERUN_GROUP: ${{ inputs.rerun_group }}
|
||||
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
|
||||
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
|
||||
run: |
|
||||
@@ -798,6 +842,7 @@ jobs:
|
||||
echo
|
||||
echo "| Child | Result | Minutes | Head SHA | Run |"
|
||||
echo "| --- | --- | ---: | --- | --- |"
|
||||
echo "| \`docker_runtime_assets_preflight\` | \`${DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT}\` | | current workflow | |"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
append_child_row() {
|
||||
@@ -933,23 +978,46 @@ jobs:
|
||||
}
|
||||
|
||||
failed=0
|
||||
normal_ci_required=0
|
||||
plugin_prerelease_required=0
|
||||
release_checks_required=0
|
||||
if [[ "$RERUN_GROUP" == "all" && "$DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT" != "success" ]]; then
|
||||
echo "::error::Docker runtime-assets preflight ended with ${DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT}."
|
||||
failed=1
|
||||
elif [[ "$RERUN_GROUP" == "all" ]]; then
|
||||
normal_ci_required=1
|
||||
plugin_prerelease_required=1
|
||||
release_checks_required=1
|
||||
else
|
||||
case "$RERUN_GROUP" in
|
||||
ci)
|
||||
normal_ci_required=1
|
||||
;;
|
||||
plugin-prerelease)
|
||||
plugin_prerelease_required=1
|
||||
;;
|
||||
release-checks|install-smoke|cross-os|live-e2e|package|qa|qa-parity|qa-live)
|
||||
release_checks_required=1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
append_child_overview
|
||||
|
||||
if [[ "$NORMAL_CI_RESULT" == "skipped" && -z "${NORMAL_CI_RUN_ID// }" ]]; then
|
||||
check_child "normal_ci" "" 0 || failed=1
|
||||
check_child "normal_ci" "" "$normal_ci_required" || failed=1
|
||||
else
|
||||
check_child "normal_ci" "$NORMAL_CI_RUN_ID" 1 || failed=1
|
||||
fi
|
||||
|
||||
if [[ "$PLUGIN_PRERELEASE_RESULT" == "skipped" && -z "${PLUGIN_PRERELEASE_RUN_ID// }" ]]; then
|
||||
check_child "plugin_prerelease" "" 0 || failed=1
|
||||
check_child "plugin_prerelease" "" "$plugin_prerelease_required" || failed=1
|
||||
else
|
||||
check_child "plugin_prerelease" "$PLUGIN_PRERELEASE_RUN_ID" 1 || failed=1
|
||||
fi
|
||||
|
||||
if [[ "$RELEASE_CHECKS_RESULT" == "skipped" && -z "${RELEASE_CHECKS_RUN_ID// }" ]]; then
|
||||
check_child "release_checks" "" 0 || failed=1
|
||||
check_child "release_checks" "" "$release_checks_required" || failed=1
|
||||
else
|
||||
check_child "release_checks" "$RELEASE_CHECKS_RUN_ID" 1 || failed=1
|
||||
fi
|
||||
@@ -994,9 +1062,17 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
evidence_package_spec="$PACKAGE_SPEC"
|
||||
if [[ -z "${evidence_package_spec// }" ]]; then
|
||||
tag_ref="${TARGET_REF#refs/tags/}"
|
||||
if [[ "$tag_ref" =~ ^v([0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-(alpha|beta)\.[1-9][0-9]*)|(-[1-9][0-9]*))?)$ ]]; then
|
||||
evidence_package_spec="openclaw@${BASH_REMATCH[1]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
release_id="${TARGET_REF#refs/tags/}"
|
||||
release_id="${release_id#v}"
|
||||
if [[ "$PACKAGE_SPEC" =~ ^openclaw@(.+)$ ]]; then
|
||||
if [[ "$evidence_package_spec" =~ ^openclaw@(.+)$ ]]; then
|
||||
release_id="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
release_id="$(printf '%s' "$release_id" | tr '/:@ ' '----' | tr -cd 'A-Za-z0-9._-')"
|
||||
@@ -1010,7 +1086,7 @@ jobs:
|
||||
--arg full_validation_run_id "$GITHUB_RUN_ID_VALUE" \
|
||||
--arg release_id "$release_id" \
|
||||
--arg release_ref "$TARGET_REF" \
|
||||
--arg package_spec "$PACKAGE_SPEC" \
|
||||
--arg package_spec "$evidence_package_spec" \
|
||||
--arg notes "Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE} after child workflows completed; the parent summary re-checks current child run conclusions." \
|
||||
'{
|
||||
event_type: "openclaw_full_release_validation_completed",
|
||||
@@ -1032,6 +1108,15 @@ jobs:
|
||||
https://api.github.com/repos/openclaw/releases-private/dispatches \
|
||||
-d "$payload"; then
|
||||
echo "::warning::Automatic private release evidence dispatch failed; child workflow validation remains authoritative."
|
||||
{
|
||||
echo "### Private release evidence dispatch failed"
|
||||
echo
|
||||
echo "Child workflow validation remains authoritative. Backfill durable evidence from \`openclaw/releases-private\`:"
|
||||
echo
|
||||
echo "\`\`\`bash"
|
||||
echo "gh workflow run openclaw-release-evidence-from-full-validation.yml --repo openclaw/releases-private --ref main -f full_validation_run_id=${GITHUB_RUN_ID_VALUE} -f release_id=${release_id} -f release_ref=${TARGET_REF} -f package_spec=${evidence_package_spec}"
|
||||
echo "\`\`\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Write release validation manifest
|
||||
|
||||
1
.github/workflows/labeler.yml
vendored
1
.github/workflows/labeler.yml
vendored
@@ -760,6 +760,7 @@ jobs:
|
||||
core.info(`Processed ${processed} pull requests.`);
|
||||
|
||||
label-issues:
|
||||
if: github.event_name == 'issues'
|
||||
permissions:
|
||||
issues: write
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
@@ -308,18 +308,36 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
current_created="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" --jq .created_at)"
|
||||
stale_before="$(date -u -d '8 hours ago' +%Y-%m-%dT%H:%M:%SZ)"
|
||||
run_has_active_jobs() {
|
||||
local run_id="$1"
|
||||
local run_state="$2"
|
||||
if [[ "$run_state" != "in_progress" ]]; then
|
||||
return 0
|
||||
fi
|
||||
local active_jobs
|
||||
active_jobs="$(gh run view "$run_id" --repo "$GITHUB_REPOSITORY" --json jobs --jq '[.jobs[] | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested")] | length')"
|
||||
[[ "$active_jobs" != "0" ]]
|
||||
}
|
||||
while true; do
|
||||
blockers="$(
|
||||
candidates="$(
|
||||
for workflow in mantis-telegram-desktop-proof.yml mantis-telegram-live.yml; do
|
||||
for status in queued in_progress waiting pending requested; do
|
||||
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --status "$status" --limit 100 --json databaseId,status,createdAt,url \
|
||||
| jq -r \
|
||||
--argjson current_id "$GITHUB_RUN_ID" \
|
||||
--arg current_created "$current_created" \
|
||||
'.[] | select(.databaseId != $current_id) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
|
||||
--arg stale_before "$stale_before" \
|
||||
'.[] | select(.databaseId != $current_id) | select(.createdAt >= $stale_before) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
|
||||
done
|
||||
done | sort -u
|
||||
)"
|
||||
blockers=""
|
||||
while IFS=$'\t' read -r created run_id run_state url; do
|
||||
if [[ -n "$run_id" ]] && run_has_active_jobs "${run_id#\#}" "$run_state"; then
|
||||
blockers+="${created}"$'\t'"${run_id}"$'\t'"${run_state}"$'\t'"${url}"$'\n'
|
||||
fi
|
||||
done <<<"$candidates"
|
||||
if [[ -z "$blockers" ]]; then
|
||||
break
|
||||
fi
|
||||
@@ -408,7 +426,7 @@ jobs:
|
||||
printf '%s\n' 'Defaults env_keep += "BASELINE_REF BASELINE_SHA CANDIDATE_REF CANDIDATE_SHA"'
|
||||
printf '%s\n' 'Defaults env_keep += "CRABBOX_ACCESS_CLIENT_ID CRABBOX_ACCESS_CLIENT_SECRET CRABBOX_COORDINATOR CRABBOX_COORDINATOR_TOKEN CRABBOX_LEASE_ID CRABBOX_PROVIDER"'
|
||||
printf '%s\n' 'Defaults env_keep += "GH_TOKEN MANTIS_CANDIDATE_TRUST MANTIS_INSTRUCTIONS MANTIS_OUTPUT_DIR MANTIS_PR_NUMBER"'
|
||||
printf '%s\n' 'Defaults env_keep += "OPENCLAW_BUILD_PRIVATE_QA OPENCLAW_ENABLE_PRIVATE_QA_CLI OPENCLAW_QA_CONVEX_SECRET_CI OPENCLAW_QA_CONVEX_SITE_URL OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN"'
|
||||
printf '%s\n' 'Defaults env_keep += "OPENCLAW_BUILD_PRIVATE_QA OPENCLAW_ENABLE_PRIVATE_QA_CLI OPENCLAW_QA_CONVEX_SECRET_CI OPENCLAW_QA_CONVEX_SITE_URL OPENCLAW_QA_CREDENTIAL_OWNER_ID OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN"'
|
||||
printf '%s\n' 'Defaults env_keep += "OPENCLAW_TELEGRAM_USER_CRABBOX_BIN OPENCLAW_TELEGRAM_USER_CRABBOX_PROVIDER OPENCLAW_TELEGRAM_USER_DRIVER_SCRIPT OPENCLAW_TELEGRAM_USER_PROOF_CMD"'
|
||||
} | sudo tee /etc/sudoers.d/mantis-codex-env >/dev/null
|
||||
sudo chmod 0440 /etc/sudoers.d/mantis-codex-env
|
||||
@@ -444,6 +462,7 @@ jobs:
|
||||
MANTIS_PR_NUMBER: ${{ needs.resolve_request.outputs.pr_number }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CREDENTIAL_OWNER_ID: mantis-telegram-desktop-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR: ${{ secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR }}
|
||||
OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN: ${{ secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN }}
|
||||
OPENCLAW_TELEGRAM_USER_CRABBOX_BIN: /usr/local/bin/crabbox
|
||||
@@ -462,6 +481,47 @@ jobs:
|
||||
codex-user: codex
|
||||
allow-bot-users: clawsweeper[bot]
|
||||
|
||||
- name: Release leaked Telegram proof leases
|
||||
if: ${{ always() }}
|
||||
env:
|
||||
CRABBOX_PROVIDER: ${{ needs.resolve_request.outputs.crabbox_provider }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! -d .artifacts/qa-e2e ]]; then
|
||||
exit 0
|
||||
fi
|
||||
status=0
|
||||
mapfile -d '' session_files < <(sudo find .artifacts/qa-e2e -path '*/telegram-user-crabbox/*/session.json' -type f -print0)
|
||||
for session_file in "${session_files[@]}"; do
|
||||
lease_file="${session_file%/session.json}/.session/lease.json"
|
||||
if [[ ! -f "$lease_file" ]]; then
|
||||
continue
|
||||
fi
|
||||
if ! sudo -u codex env \
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI="$OPENCLAW_QA_CONVEX_SECRET_CI" \
|
||||
OPENCLAW_QA_CONVEX_SITE_URL="$OPENCLAW_QA_CONVEX_SITE_URL" \
|
||||
OPENCLAW_TELEGRAM_USER_CRABBOX_BIN=/usr/local/bin/crabbox \
|
||||
OPENCLAW_TELEGRAM_USER_CRABBOX_PROVIDER="$CRABBOX_PROVIDER" \
|
||||
node --import tsx "$GITHUB_WORKSPACE/scripts/e2e/telegram-user-crabbox-proof.ts" \
|
||||
finish --session "$session_file" --preview-crop telegram-window; then
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
mapfile -d '' lease_files < <(sudo find .artifacts/qa-e2e -path '*/telegram-user-crabbox/*/.session/lease.json' -type f -print0)
|
||||
for lease_file in "${lease_files[@]}"; do
|
||||
if ! sudo -u codex env \
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI="$OPENCLAW_QA_CONVEX_SECRET_CI" \
|
||||
OPENCLAW_QA_CONVEX_SITE_URL="$OPENCLAW_QA_CONVEX_SITE_URL" \
|
||||
node --import tsx "$GITHUB_WORKSPACE/scripts/e2e/telegram-user-credential.ts" \
|
||||
release --lease-file "$lease_file"; then
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
- name: Inspect Mantis evidence manifest
|
||||
id: inspect
|
||||
if: ${{ always() }}
|
||||
|
||||
22
.github/workflows/mantis-telegram-live.yml
vendored
22
.github/workflows/mantis-telegram-live.yml
vendored
@@ -272,18 +272,36 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
current_created="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" --jq .created_at)"
|
||||
stale_before="$(date -u -d '8 hours ago' +%Y-%m-%dT%H:%M:%SZ)"
|
||||
run_has_active_jobs() {
|
||||
local run_id="$1"
|
||||
local run_state="$2"
|
||||
if [[ "$run_state" != "in_progress" ]]; then
|
||||
return 0
|
||||
fi
|
||||
local active_jobs
|
||||
active_jobs="$(gh run view "$run_id" --repo "$GITHUB_REPOSITORY" --json jobs --jq '[.jobs[] | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested")] | length')"
|
||||
[[ "$active_jobs" != "0" ]]
|
||||
}
|
||||
while true; do
|
||||
blockers="$(
|
||||
candidates="$(
|
||||
for workflow in mantis-telegram-desktop-proof.yml mantis-telegram-live.yml; do
|
||||
for status in queued in_progress waiting pending requested; do
|
||||
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --status "$status" --limit 100 --json databaseId,status,createdAt,url \
|
||||
| jq -r \
|
||||
--argjson current_id "$GITHUB_RUN_ID" \
|
||||
--arg current_created "$current_created" \
|
||||
'.[] | select(.databaseId != $current_id) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
|
||||
--arg stale_before "$stale_before" \
|
||||
'.[] | select(.databaseId != $current_id) | select(.createdAt >= $stale_before) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
|
||||
done
|
||||
done | sort -u
|
||||
)"
|
||||
blockers=""
|
||||
while IFS=$'\t' read -r created run_id run_state url; do
|
||||
if [[ -n "$run_id" ]] && run_has_active_jobs "${run_id#\#}" "$run_state"; then
|
||||
blockers+="${created}"$'\t'"${run_id}"$'\t'"${run_state}"$'\t'"${url}"$'\n'
|
||||
fi
|
||||
done <<<"$candidates"
|
||||
if [[ -z "$blockers" ]]; then
|
||||
break
|
||||
fi
|
||||
|
||||
@@ -68,6 +68,11 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
codex_plugin_spec:
|
||||
description: Optional Codex plugin install spec for the live package lane; blank packs extensions/codex from the selected ref
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
include_live_suites:
|
||||
description: Whether to run live-provider coverage
|
||||
required: false
|
||||
@@ -173,6 +178,11 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
codex_plugin_spec:
|
||||
description: Optional Codex plugin install spec for the live package lane; blank packs extensions/codex from the selected ref
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
include_live_suites:
|
||||
description: Whether to run live-provider coverage
|
||||
required: false
|
||||
@@ -631,7 +641,7 @@ jobs:
|
||||
profiles: stable full
|
||||
- chunk_id: package-update-openai
|
||||
label: package/update OpenAI install
|
||||
timeout_minutes: 20
|
||||
timeout_minutes: 45
|
||||
profiles: beta minimum stable full
|
||||
- chunk_id: package-update-anthropic
|
||||
label: package/update Anthropic install
|
||||
@@ -734,6 +744,7 @@ jobs:
|
||||
OPENCLAW_DOCKER_E2E_REPO_ROOT: ${{ github.workspace }}
|
||||
OPENCLAW_DOCKER_E2E_SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
OPENCLAW_DOCKER_ALL_RELEASE_PROFILE: ${{ inputs.release_test_profile }}
|
||||
OPENCLAW_CODEX_NPM_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }}
|
||||
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
|
||||
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: ${{ inputs.published_upgrade_survivor_baseline }}
|
||||
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS: ${{ inputs.published_upgrade_survivor_baselines }}
|
||||
@@ -974,6 +985,7 @@ jobs:
|
||||
OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
|
||||
OPENCLAW_DOCKER_E2E_REPO_ROOT: ${{ github.workspace }}
|
||||
OPENCLAW_DOCKER_E2E_SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
OPENCLAW_CODEX_NPM_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }}
|
||||
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
|
||||
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: ${{ inputs.published_upgrade_survivor_baseline }}
|
||||
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS: ${{ matrix.group.published_upgrade_survivor_baselines || inputs.published_upgrade_survivor_baselines }}
|
||||
|
||||
62
.github/workflows/openclaw-npm-release.yml
vendored
62
.github/workflows/openclaw-npm-release.yml
vendored
@@ -20,6 +20,10 @@ on:
|
||||
description: Successful Full Release Validation run id for this tag/SHA, required for real publish
|
||||
required: false
|
||||
type: string
|
||||
release_publish_run_id:
|
||||
description: Approved OpenClaw Release Publish workflow run id
|
||||
required: false
|
||||
type: string
|
||||
npm_dist_tag:
|
||||
description: npm dist-tag to publish to
|
||||
required: true
|
||||
@@ -32,7 +36,7 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: openclaw-npm-release-${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}', inputs.tag, inputs.npm_dist_tag) || github.ref }}
|
||||
cancel-in-progress: false
|
||||
cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' && inputs.preflight_only && inputs.npm_dist_tag == 'alpha' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
@@ -367,6 +371,7 @@ jobs:
|
||||
if: ${{ !inputs.preflight_only }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Require trusted workflow ref for publish
|
||||
@@ -389,6 +394,7 @@ jobs:
|
||||
env:
|
||||
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${PREFLIGHT_RUN_ID}" ]]; then
|
||||
@@ -399,6 +405,32 @@ jobs:
|
||||
echo "Real publish requires full_release_validation_run_id from a successful Full Release Validation run." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" && "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
|
||||
echo "Workflow-dispatched real publish requires release_publish_run_id from the approved OpenClaw Release Publish workflow." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate release publish approval run
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
|
||||
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
|
||||
if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
|
||||
echo "OpenClaw npm publish dispatched by another workflow must include release_publish_run_id." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Direct OpenClaw npm publish; relying on this workflow's npm-release environment approval."
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
|
||||
echo "OpenClaw npm publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
|
||||
exit 1
|
||||
fi
|
||||
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
|
||||
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
|
||||
|
||||
publish_openclaw_npm:
|
||||
# KEEP THE REAL RELEASE/PUBLISH PATH ON A GITHUB-HOSTED RUNNER.
|
||||
@@ -511,10 +543,25 @@ jobs:
|
||||
preferred_name="openclaw-npm-preflight-${RELEASE_TAG}"
|
||||
rm -rf preflight-tarball
|
||||
mkdir -p preflight-tarball
|
||||
if gh run download "${PREFLIGHT_RUN_ID}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--name "${preferred_name}" \
|
||||
--dir preflight-tarball; then
|
||||
|
||||
download_named_artifact() {
|
||||
local artifact_name="$1"
|
||||
for attempt in 1 2 3; do
|
||||
if gh run download "${PREFLIGHT_RUN_ID}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--name "${artifact_name}" \
|
||||
--dir preflight-tarball; then
|
||||
return 0
|
||||
fi
|
||||
if [[ "$attempt" != "3" ]]; then
|
||||
echo "::warning::Artifact download for ${artifact_name} failed on attempt ${attempt}; retrying."
|
||||
sleep $((attempt * 10))
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if download_named_artifact "${preferred_name}"; then
|
||||
echo "Downloaded ${preferred_name}."
|
||||
return 0
|
||||
fi
|
||||
@@ -530,10 +577,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
fallback_name="${matches[0]}"
|
||||
gh run download "${PREFLIGHT_RUN_ID}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--name "${fallback_name}" \
|
||||
--dir preflight-tarball
|
||||
download_named_artifact "${fallback_name}"
|
||||
echo "Downloaded fallback preflight artifact ${fallback_name}."
|
||||
}
|
||||
|
||||
|
||||
50
.github/workflows/openclaw-release-checks.yml
vendored
50
.github/workflows/openclaw-release-checks.yml
vendored
@@ -78,10 +78,15 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
codex_plugin_spec:
|
||||
description: Optional Codex plugin install spec for live Docker package checks; blank derives from release_package_spec or packs the selected ref
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: openclaw-release-checks-${{ inputs.expected_sha || inputs.ref }}-${{ inputs.rerun_group }}
|
||||
cancel-in-progress: false
|
||||
cancel-in-progress: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
@@ -112,6 +117,7 @@ jobs:
|
||||
qa_live_slack_enabled: ${{ steps.inputs.outputs.qa_live_slack_enabled }}
|
||||
release_package_spec: ${{ steps.inputs.outputs.release_package_spec }}
|
||||
package_acceptance_package_spec: ${{ steps.inputs.outputs.package_acceptance_package_spec }}
|
||||
codex_plugin_spec: ${{ steps.inputs.outputs.codex_plugin_spec }}
|
||||
steps:
|
||||
- name: Require trusted workflow ref for release checks
|
||||
env:
|
||||
@@ -262,6 +268,7 @@ jobs:
|
||||
RELEASE_QA_SLACK_LIVE_CI_ENABLED: ${{ vars.OPENCLAW_RELEASE_QA_SLACK_LIVE_CI_ENABLED || 'false' }}
|
||||
RELEASE_PACKAGE_SPEC_INPUT: ${{ inputs.release_package_spec }}
|
||||
RELEASE_PACKAGE_ACCEPTANCE_PACKAGE_SPEC_INPUT: ${{ inputs.package_acceptance_package_spec }}
|
||||
RELEASE_CODEX_PLUGIN_SPEC_INPUT: ${{ inputs.codex_plugin_spec }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
qa_live_matrix_enabled=true
|
||||
@@ -307,6 +314,10 @@ jobs:
|
||||
if [[ "$release_profile" == "full" ]]; then
|
||||
run_release_soak=true
|
||||
fi
|
||||
codex_plugin_spec="$RELEASE_CODEX_PLUGIN_SPEC_INPUT"
|
||||
if [[ -z "${codex_plugin_spec// }" && "$RELEASE_PACKAGE_SPEC_INPUT" =~ ^openclaw@(.+)$ ]]; then
|
||||
codex_plugin_spec="npm:@openclaw/codex@${BASH_REMATCH[1]}"
|
||||
fi
|
||||
|
||||
filter="$(printf '%s' "$RELEASE_LIVE_SUITE_FILTER_INPUT" | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ -n "${filter// }" ]]; then
|
||||
@@ -387,6 +398,7 @@ jobs:
|
||||
printf 'qa_live_slack_enabled=%s\n' "$qa_live_slack_enabled"
|
||||
printf 'release_package_spec=%s\n' "$RELEASE_PACKAGE_SPEC_INPUT"
|
||||
printf 'package_acceptance_package_spec=%s\n' "$RELEASE_PACKAGE_ACCEPTANCE_PACKAGE_SPEC_INPUT"
|
||||
printf 'codex_plugin_spec=%s\n' "$codex_plugin_spec"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Summarize validated ref
|
||||
@@ -403,6 +415,7 @@ jobs:
|
||||
RELEASE_CROSS_OS_SUITE_FILTER: ${{ inputs.cross_os_suite_filter }}
|
||||
RELEASE_PACKAGE_SPEC: ${{ inputs.release_package_spec }}
|
||||
PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }}
|
||||
CODEX_PLUGIN_SPEC: ${{ steps.inputs.outputs.codex_plugin_spec }}
|
||||
run: |
|
||||
{
|
||||
echo "## Release checks"
|
||||
@@ -432,6 +445,11 @@ jobs:
|
||||
else
|
||||
echo "- Package Acceptance package spec: prepared release artifact"
|
||||
fi
|
||||
if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then
|
||||
echo "- Codex plugin spec: \`${CODEX_PLUGIN_SPEC}\`"
|
||||
else
|
||||
echo "- Codex plugin spec: packed from selected ref"
|
||||
fi
|
||||
if [[ "$RUN_RELEASE_SOAK" == "true" ]]; then
|
||||
echo "- This run will execute blocking release validation plus exhaustive live/Docker soak coverage."
|
||||
else
|
||||
@@ -640,6 +658,7 @@ jobs:
|
||||
include_live_suites: false
|
||||
release_test_profile: ${{ needs.resolve_target.outputs.release_profile }}
|
||||
package_artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
|
||||
codex_plugin_spec: ${{ needs.resolve_target.outputs.codex_plugin_spec }}
|
||||
secrets: *live_e2e_release_secrets
|
||||
|
||||
package_acceptance_release_checks:
|
||||
@@ -926,6 +945,20 @@ jobs:
|
||||
--runtime-pair pi,codex \
|
||||
--output-dir ".artifacts/qa-e2e/runtime-parity-standard"
|
||||
|
||||
- name: Run soak runtime parity tier
|
||||
id: runtime_parity_soak_lane
|
||||
if: ${{ always() && needs.resolve_target.outputs.run_release_soak == 'true' && steps.runtime_parity_lane.outcome != 'skipped' && steps.runtime_parity_lane.outcome != 'cancelled' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm openclaw qa suite \
|
||||
--provider-mode mock-openai \
|
||||
--runtime-parity-tier soak \
|
||||
--concurrency "${QA_PARITY_CONCURRENCY}" \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "openai/gpt-5.5-alt" \
|
||||
--runtime-pair pi,codex \
|
||||
--output-dir ".artifacts/qa-e2e/runtime-parity-soak"
|
||||
|
||||
- name: Generate runtime parity report
|
||||
if: always()
|
||||
run: |
|
||||
@@ -946,6 +979,21 @@ jobs:
|
||||
--summary .artifacts/qa-e2e/runtime-parity-standard/qa-suite-summary.json \
|
||||
--output-dir .artifacts/qa-e2e/runtime-parity-standard-report
|
||||
|
||||
- name: Generate soak runtime parity report
|
||||
if: ${{ always() && needs.resolve_target.outputs.run_release_soak == 'true' && steps.runtime_parity_soak_lane.outcome != 'skipped' && steps.runtime_parity_soak_lane.outcome != 'cancelled' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
summary=".artifacts/qa-e2e/runtime-parity-soak/qa-suite-summary.json"
|
||||
if [[ ! -f "$summary" ]]; then
|
||||
echo "No soak runtime parity summary was produced."
|
||||
exit 0
|
||||
fi
|
||||
pnpm openclaw qa parity-report \
|
||||
--repo-root . \
|
||||
--runtime-axis \
|
||||
--summary "$summary" \
|
||||
--output-dir .artifacts/qa-e2e/runtime-parity-soak-report
|
||||
|
||||
- name: Upload runtime parity artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
212
.github/workflows/openclaw-release-publish.yml
vendored
212
.github/workflows/openclaw-release-publish.yml
vendored
@@ -153,10 +153,25 @@ jobs:
|
||||
preflight_dir="${RUNNER_TEMP}/openclaw-npm-preflight-manifest"
|
||||
rm -rf "${preflight_dir}"
|
||||
mkdir -p "${preflight_dir}"
|
||||
if gh run download "${PREFLIGHT_RUN_ID}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--name "${preferred_name}" \
|
||||
--dir "${preflight_dir}"; then
|
||||
|
||||
download_named_artifact() {
|
||||
local artifact_name="$1"
|
||||
for attempt in 1 2 3; do
|
||||
if gh run download "${PREFLIGHT_RUN_ID}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--name "${artifact_name}" \
|
||||
--dir "${preflight_dir}"; then
|
||||
return 0
|
||||
fi
|
||||
if [[ "$attempt" != "3" ]]; then
|
||||
echo "::warning::Artifact download for ${artifact_name} failed on attempt ${attempt}; retrying."
|
||||
sleep $((attempt * 10))
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if download_named_artifact "${preferred_name}"; then
|
||||
echo "name=${preferred_name}" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
@@ -172,10 +187,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
fallback_name="${matches[0]}"
|
||||
gh run download "${PREFLIGHT_RUN_ID}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--name "${fallback_name}" \
|
||||
--dir "${preflight_dir}"
|
||||
download_named_artifact "${fallback_name}"
|
||||
echo "name=${fallback_name}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download full release validation manifest
|
||||
@@ -336,6 +348,7 @@ jobs:
|
||||
needs: [resolve_release_target]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
environment: npm-release
|
||||
steps:
|
||||
- name: Checkout release SHA
|
||||
uses: actions/checkout@v6
|
||||
@@ -438,7 +451,7 @@ jobs:
|
||||
|
||||
pending_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" 2>/dev/null || true)"
|
||||
if [[ -z "${pending_json}" ]] || ! printf '%s' "${pending_json}" | jq -e 'length > 0' >/dev/null 2>&1; then
|
||||
return 0
|
||||
return 1
|
||||
fi
|
||||
|
||||
approved=0
|
||||
@@ -450,13 +463,15 @@ jobs:
|
||||
gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" \
|
||||
-F "environment_ids[]=${env_id}" \
|
||||
-f state=approved \
|
||||
-f comment="Approve release gate from OpenClaw Release Publish wrapper" >/dev/null
|
||||
-f comment="Approve child release gate after parent release approval" >/dev/null
|
||||
approved=1
|
||||
done < <(printf '%s' "${pending_json}" | jq -r '.[] | select(.current_user_can_approve == true) | [.environment.id, .environment.name] | @tsv')
|
||||
|
||||
if [[ "${approved}" == "1" ]]; then
|
||||
echo "${workflow}: approved available pending environment gates"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
print_failed_run_summary() {
|
||||
@@ -499,7 +514,7 @@ jobs:
|
||||
if [[ "$state" != "$last_state" ]]; then
|
||||
echo "${workflow} still ${status} (updated ${updated_at}): ${url}"
|
||||
print_pending_deployments "${workflow}" "${run_id}"
|
||||
approve_pending_deployments "${workflow}" "${run_id}"
|
||||
approve_pending_deployments "${workflow}" "${run_id}" || true
|
||||
last_state="$state"
|
||||
fi
|
||||
sleep 30
|
||||
@@ -546,6 +561,85 @@ jobs:
|
||||
wait_run_pid="$!"
|
||||
}
|
||||
|
||||
wait_for_job_success() {
|
||||
local workflow="$1"
|
||||
local run_id="$2"
|
||||
local job_name="$3"
|
||||
local jobs_json job_json run_status run_conclusion status conclusion url deadline
|
||||
|
||||
deadline=$((SECONDS + 900))
|
||||
while true; do
|
||||
jobs_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json status,conclusion,jobs)"
|
||||
run_status="$(printf '%s' "$jobs_json" | jq -r '.status')"
|
||||
run_conclusion="$(printf '%s' "$jobs_json" | jq -r '.conclusion // ""')"
|
||||
job_json="$(printf '%s' "$jobs_json" | jq -c --arg name "$job_name" '.jobs[]? | select(.name == $name) | {status, conclusion, url}' | head -n 1)"
|
||||
if [[ -n "$job_json" ]]; then
|
||||
status="$(printf '%s' "$job_json" | jq -r '.status')"
|
||||
conclusion="$(printf '%s' "$job_json" | jq -r '.conclusion // ""')"
|
||||
url="$(printf '%s' "$job_json" | jq -r '.url // ""')"
|
||||
if [[ "$status" == "completed" ]]; then
|
||||
if [[ "$conclusion" == "success" || "$conclusion" == "skipped" ]]; then
|
||||
echo "${workflow} ${job_name} ${conclusion}: ${url}"
|
||||
echo "- ${workflow} ${job_name}: ${conclusion} (${url})" >> "$GITHUB_STEP_SUMMARY"
|
||||
return 0
|
||||
fi
|
||||
echo "${workflow} ${job_name} failed: ${conclusion} ${url}" >&2
|
||||
print_failed_run_summary "${run_id}"
|
||||
return 1
|
||||
fi
|
||||
echo "${workflow} ${job_name} still ${status}: ${url}"
|
||||
elif [[ "$run_status" == "completed" ]]; then
|
||||
if [[ "$run_conclusion" == "success" ]]; then
|
||||
echo "${workflow} completed before ${job_name} was needed."
|
||||
echo "- ${workflow} ${job_name}: not needed" >> "$GITHUB_STEP_SUMMARY"
|
||||
return 0
|
||||
fi
|
||||
echo "${workflow} completed before ${job_name} with ${run_conclusion}." >&2
|
||||
print_failed_run_summary "${run_id}"
|
||||
return 1
|
||||
else
|
||||
echo "${workflow} waiting for ${job_name} to start: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
|
||||
fi
|
||||
if (( SECONDS >= deadline )); then
|
||||
echo "${workflow} ${job_name} did not complete within 15 minutes." >&2
|
||||
return 1
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
}
|
||||
|
||||
approve_child_publish_environment() {
|
||||
local workflow="$1"
|
||||
local run_id="$2"
|
||||
local run_json status conclusion deadline
|
||||
|
||||
deadline=$((SECONDS + 900))
|
||||
while true; do
|
||||
if approve_pending_deployments "${workflow}" "${run_id}"; then
|
||||
echo "- ${workflow}: child environment gate approved" >> "$GITHUB_STEP_SUMMARY"
|
||||
return 0
|
||||
fi
|
||||
run_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json status,conclusion,url)"
|
||||
status="$(printf '%s' "$run_json" | jq -r '.status')"
|
||||
conclusion="$(printf '%s' "$run_json" | jq -r '.conclusion // ""')"
|
||||
if [[ "$status" == "completed" ]]; then
|
||||
if [[ "$conclusion" == "success" ]]; then
|
||||
echo "${workflow}: completed before child environment approval was needed"
|
||||
return 0
|
||||
fi
|
||||
echo "${workflow}: completed before child environment approval with ${conclusion}" >&2
|
||||
print_failed_run_summary "${run_id}"
|
||||
return 1
|
||||
fi
|
||||
if (( SECONDS >= deadline )); then
|
||||
echo "${workflow}: child environment approval was not available within 15 minutes." >&2
|
||||
print_pending_deployments "${workflow}" "${run_id}"
|
||||
return 1
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
}
|
||||
|
||||
create_or_update_github_release() {
|
||||
local release_version notes_version title notes_file changelog_file latest_arg prerelease_args
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
@@ -645,10 +739,14 @@ jobs:
|
||||
--workflow-ref "${CHILD_WORKFLOW_REF}"
|
||||
--full-release-validation-run "${FULL_RELEASE_VALIDATION_RUN_ID}"
|
||||
--plugin-npm-run "${plugin_npm_run_id}"
|
||||
--plugin-clawhub-run "${plugin_clawhub_run_id}"
|
||||
--openclaw-npm-run "${openclaw_npm_run_id}"
|
||||
--evidence-out "${evidence_path}"
|
||||
)
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
|
||||
verify_args+=(--plugin-clawhub-run "${plugin_clawhub_run_id}")
|
||||
else
|
||||
verify_args+=(--skip-clawhub)
|
||||
fi
|
||||
if [[ -n "${PLUGINS// }" ]]; then
|
||||
verify_args+=(--plugins "${PLUGINS}")
|
||||
fi
|
||||
@@ -663,27 +761,97 @@ jobs:
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
}
|
||||
|
||||
append_release_proof_to_github_release() {
|
||||
local release_version body_file notes_file tarball integrity telegram_line clawhub_line
|
||||
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
body_file="${RUNNER_TEMP}/release-body.md"
|
||||
notes_file="${RUNNER_TEMP}/release-notes-with-proof.md"
|
||||
tarball="$(npm view "openclaw@${release_version}" dist.tarball --json | jq -r '.')"
|
||||
integrity="$(npm view "openclaw@${release_version}" dist.integrity --json | jq -r '.')"
|
||||
gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json body --jq .body > "${body_file}"
|
||||
|
||||
if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then
|
||||
telegram_line="- npm Telegram beta E2E: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${NPM_TELEGRAM_RUN_ID}"
|
||||
else
|
||||
telegram_line="- npm Telegram beta E2E: not supplied"
|
||||
fi
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
|
||||
clawhub_line="- plugin ClawHub publish: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
|
||||
else
|
||||
clawhub_line="- plugin ClawHub publish: dispatched separately, not awaited by this proof: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
|
||||
fi
|
||||
|
||||
RELEASE_BODY_FILE="${body_file}" \
|
||||
RELEASE_NOTES_FILE="${notes_file}" \
|
||||
RELEASE_VERSION="${release_version}" \
|
||||
RELEASE_TAG="${RELEASE_TAG}" \
|
||||
RELEASE_REPO="${GITHUB_REPOSITORY}" \
|
||||
RELEASE_TARBALL="${tarball}" \
|
||||
RELEASE_INTEGRITY="${integrity}" \
|
||||
RELEASE_PUBLISH_RUN_ID="${GITHUB_RUN_ID}" \
|
||||
PREFLIGHT_RUN_ID="${PREFLIGHT_RUN_ID}" \
|
||||
FULL_RELEASE_VALIDATION_RUN_ID="${FULL_RELEASE_VALIDATION_RUN_ID}" \
|
||||
PLUGIN_NPM_RUN_ID="${plugin_npm_run_id}" \
|
||||
OPENCLAW_NPM_RUN_ID="${openclaw_npm_run_id}" \
|
||||
CLAWHUB_LINE="${clawhub_line}" \
|
||||
TELEGRAM_LINE="${telegram_line}" \
|
||||
node --input-type=module <<'NODE'
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const bodyFile = process.env.RELEASE_BODY_FILE;
|
||||
const notesFile = process.env.RELEASE_NOTES_FILE;
|
||||
if (!bodyFile || !notesFile) {
|
||||
throw new Error("Missing release notes file paths.");
|
||||
}
|
||||
|
||||
const body = readFileSync(bodyFile, "utf8").trimEnd();
|
||||
const section = [
|
||||
"### Release verification",
|
||||
"",
|
||||
`- npm package: https://www.npmjs.com/package/openclaw/v/${process.env.RELEASE_VERSION}`,
|
||||
`- registry tarball: ${process.env.RELEASE_TARBALL}`,
|
||||
`- integrity: \`${process.env.RELEASE_INTEGRITY}\``,
|
||||
`- full release CI report: https://github.com/openclaw/releases-private/blob/main/evidence/${process.env.RELEASE_VERSION}/release-evidence.md`,
|
||||
`- release publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.RELEASE_PUBLISH_RUN_ID}`,
|
||||
`- npm preflight: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PREFLIGHT_RUN_ID}`,
|
||||
`- full release validation: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.FULL_RELEASE_VALIDATION_RUN_ID}`,
|
||||
`- plugin npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PLUGIN_NPM_RUN_ID}`,
|
||||
process.env.CLAWHUB_LINE,
|
||||
`- OpenClaw npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.OPENCLAW_NPM_RUN_ID}`,
|
||||
process.env.TELEGRAM_LINE,
|
||||
].join("\n");
|
||||
|
||||
const withoutOldProof = body.replace(/\n?### Release verification\n[\s\S]*?(?=\n### |\n## |$)/, "");
|
||||
writeFileSync(notesFile, `${withoutOldProof.trimEnd()}\n\n${section}\n`);
|
||||
NODE
|
||||
|
||||
gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --notes-file "${notes_file}"
|
||||
echo "- Release proof: appended to GitHub release" >> "$GITHUB_STEP_SUMMARY"
|
||||
}
|
||||
|
||||
{
|
||||
echo "### Publish sequence"
|
||||
echo
|
||||
echo "- Workflow ref: \`${CHILD_WORKFLOW_REF}\`"
|
||||
echo "- Release tag: \`${RELEASE_TAG}\`"
|
||||
echo "- Release SHA: \`${TARGET_SHA}\`"
|
||||
echo "- Release approval: this workflow job"
|
||||
echo "- Plugin npm and ClawHub publish: dispatched in parallel"
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
|
||||
echo "- OpenClaw npm publish: starts after plugin npm succeeds; final verification waits for ClawHub"
|
||||
echo "- OpenClaw npm publish: starts after plugin npm succeeds"
|
||||
else
|
||||
echo "- OpenClaw npm publish: skipped by input"
|
||||
fi
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" || "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
|
||||
echo "- Workflow completion waits for ClawHub"
|
||||
else
|
||||
echo "- Workflow completion does not wait for ClawHub; monitor the dispatched ClawHub run separately"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}")
|
||||
clawhub_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}")
|
||||
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
|
||||
clawhub_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
|
||||
if [[ -n "${PLUGINS}" ]]; then
|
||||
npm_args+=(-f plugins="${PLUGINS}")
|
||||
clawhub_args+=(-f plugins="${PLUGINS}")
|
||||
@@ -709,6 +877,7 @@ jobs:
|
||||
-f preflight_only=false \
|
||||
-f preflight_run_id="${PREFLIGHT_RUN_ID}" \
|
||||
-f full_release_validation_run_id="${FULL_RELEASE_VALIDATION_RUN_ID}" \
|
||||
-f release_publish_run_id="${GITHUB_RUN_ID}" \
|
||||
-f npm_dist_tag="${RELEASE_NPM_DIST_TAG}")"
|
||||
echo "- OpenClaw npm run ID: \`${openclaw_npm_run_id}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
@@ -717,13 +886,19 @@ jobs:
|
||||
|
||||
clawhub_result=""
|
||||
clawhub_pid=""
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" || "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
|
||||
clawhub_result="$RUNNER_TEMP/clawhub-result.txt"
|
||||
wait_run_pid=""
|
||||
wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}"
|
||||
clawhub_pid="${wait_run_pid}"
|
||||
else
|
||||
echo "- plugin-clawhub-release.yml: not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval"
|
||||
if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then
|
||||
:
|
||||
else
|
||||
echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
openclaw_result=""
|
||||
@@ -760,6 +935,7 @@ jobs:
|
||||
|
||||
if [[ "${failed}" == "0" && -n "${openclaw_npm_run_id}" ]]; then
|
||||
verify_published_release
|
||||
append_release_proof_to_github_release
|
||||
fi
|
||||
if [[ "${failed}" != "0" ]]; then
|
||||
exit 1
|
||||
|
||||
38
.github/workflows/plugin-clawhub-release.yml
vendored
38
.github/workflows/plugin-clawhub-release.yml
vendored
@@ -20,6 +20,10 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
release_publish_run_id:
|
||||
description: Approved OpenClaw Release Publish workflow run id
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: plugin-clawhub-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||
@@ -196,6 +200,37 @@ jobs:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
run: node --import tsx scripts/plugin-clawhub-owner-preflight.ts .local/plugin-clawhub-release-plan.json
|
||||
|
||||
validate_release_publish_approval:
|
||||
name: Validate release publish approval
|
||||
needs: preview_plugins_clawhub
|
||||
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Validate release publish approval run
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
|
||||
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
|
||||
if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
|
||||
echo "Plugin ClawHub publish dispatched by another workflow must include release_publish_run_id." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Direct Plugin ClawHub Release dispatch; relying on this workflow's clawhub-plugin-release environment approval."
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
|
||||
echo "Plugin ClawHub publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
|
||||
exit 1
|
||||
fi
|
||||
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
|
||||
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
|
||||
|
||||
preview_plugin_pack:
|
||||
needs: preview_plugins_clawhub
|
||||
if: needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
@@ -289,11 +324,12 @@ jobs:
|
||||
run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
|
||||
|
||||
publish_plugins_clawhub:
|
||||
needs: [preview_plugins_clawhub, preview_plugin_pack]
|
||||
needs: [preview_plugins_clawhub, preview_plugin_pack, validate_release_publish_approval]
|
||||
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: clawhub-plugin-release
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
|
||||
38
.github/workflows/plugin-npm-release.yml
vendored
38
.github/workflows/plugin-npm-release.yml
vendored
@@ -32,6 +32,10 @@ on:
|
||||
description: Comma-separated plugin package names to publish when publish_scope=selected
|
||||
required: false
|
||||
type: string
|
||||
release_publish_run_id:
|
||||
description: Approved OpenClaw Release Publish workflow run id
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: plugin-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||
@@ -173,6 +177,37 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
validate_release_publish_approval:
|
||||
name: Validate release publish approval
|
||||
needs: preview_plugins_npm
|
||||
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_npm.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Validate release publish approval run
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
|
||||
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
|
||||
if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
|
||||
echo "Plugin npm publish dispatched by another workflow must include release_publish_run_id." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Direct Plugin NPM Release dispatch; relying on this workflow's npm-release environment approval."
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
|
||||
echo "Plugin npm publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
|
||||
exit 1
|
||||
fi
|
||||
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
|
||||
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
|
||||
|
||||
preview_plugin_pack:
|
||||
needs: preview_plugins_npm
|
||||
if: needs.preview_plugins_npm.outputs.has_candidates == 'true'
|
||||
@@ -205,11 +240,12 @@ jobs:
|
||||
run: bash scripts/plugin-npm-publish.sh --pack-dry-run "${{ matrix.plugin.packageDir }}"
|
||||
|
||||
publish_plugins_npm:
|
||||
needs: [preview_plugins_npm, preview_plugin_pack]
|
||||
needs: [preview_plugins_npm, preview_plugin_pack, validate_release_publish_approval]
|
||||
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_npm.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-release
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
|
||||
4
.github/workflows/website-installer-sync.yml
vendored
4
.github/workflows/website-installer-sync.yml
vendored
@@ -76,11 +76,9 @@ jobs:
|
||||
- name: install.sh in Docker
|
||||
run: |
|
||||
docker run --rm \
|
||||
-e OPENCLAW_NO_ONBOARD=1 \
|
||||
-e OPENCLAW_NO_PROMPT=1 \
|
||||
-v "$PWD/scripts/install.sh:/tmp/install.sh:ro" \
|
||||
node:24-bookworm-slim \
|
||||
bash -lc 'bash /tmp/install.sh --no-prompt --no-onboard --version latest && openclaw --version'
|
||||
bash -lc 'bash /tmp/install.sh --version latest && openclaw --version'
|
||||
|
||||
- name: install-cli.sh in Docker
|
||||
run: |
|
||||
|
||||
@@ -84,6 +84,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
## GitHub / PRs
|
||||
|
||||
- Use `$openclaw-pr-maintainer` immediately for maintainer-side OpenClaw issue/PR review, triage, duplicates, labels, comments, close, land, or evidence. Contributor PR creation/refresh follows the requested contributor workflow; linked refs alone do not require maintainer archive tooling.
|
||||
- Pasted GitHub issue/PR: first `git status -sb`; if dirty, yell; then `git push` + `git pull --ff-only`.
|
||||
- PR refs: `gh pr view/diff` or `gh api`, not web search. Prefer `gitcrawl` for maintainer discovery; missing/stale `gitcrawl` falls through to live `gh`, not contributor setup. Verify live with `gh` before mutation.
|
||||
- Bare issue/PR URL/number means review/report in chat. Suggest comment/close/merge when appropriate; mutate only when asked.
|
||||
- No unsolicited PR comments/reviews/labels/retitles/rebases/fixups/landing. Exception: close/duplicate action that needs a reason comment after explicit close/sweep/landing request.
|
||||
@@ -171,6 +172,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- "restart iOS/Android apps" = rebuild/reinstall/relaunch, not kill/launch.
|
||||
- SwiftUI: Observation (`@Observable`, `@Bindable`) over new `ObservableObject`.
|
||||
- Mac gateway: dev watch = `pnpm gateway:watch`; managed installs = `openclaw gateway restart/status --deep`; logs = `./scripts/clawlog.sh`. No launchd/ad-hoc tmux.
|
||||
- Mac app permission testing: stable app path + real signing identity required. No `--no-sign`, `SIGN_IDENTITY=-`, or raw debug binary; TCC prompts/listing won't stick.
|
||||
- Version bump surfaces live in `$openclaw-release-maintainer`.
|
||||
- Parallels: `$openclaw-parallels-smoke`; Discord roundtrip: `$parallels-discord-roundtrip`.
|
||||
- Crabbox/WebVNC human demos: keep remote desktop visible/windowed; no fullscreen remote browser unless video/capture-style output.
|
||||
|
||||
188
CHANGELOG.md
188
CHANGELOG.md
@@ -4,15 +4,187 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- Discord: allow configuring a bounded `agentComponents.ttlMs` callback registry lifetime for long-running component workflows, with per-account overrides and a 24-hour cap. (#84189) Thanks @100menotu001.
|
||||
- Plugin SDK: add row-level session workflow helpers and deprecate `loadSessionStore` so plugins can read and patch sessions without depending on the legacy whole-store shape. (#84693) Thanks @efpiva.
|
||||
- Gateway/plugins: reuse a compatible Gateway startup plugin registry during dispatch so safe plugin dispatches avoid redundant registry loading. (#84324) Thanks @ai-hpc.
|
||||
- Plugins/SDK: add a general `embeddingProviders` capability contract and registration API so embeddings can become a reusable provider surface outside memory-specific adapters.
|
||||
- Dependencies: refresh provider, plugin, UI, and tooling packages, update `protobufjs` to 8.4.0 to clear the current npm advisory, and carry the Claude ACP completion patch forward to `@agentclientprotocol/claude-agent-acp` 0.36.1.
|
||||
- Agents/tools: remove the old sender-owner tool gating path so configured tools stay visible for trusted sessions while command and channel-action auth still carry real sender identity.
|
||||
- QA-Lab: add curated mock JSONL replay fixtures and first-drift reporting for runtime-parity audits. (#80323, refs #80176) Thanks @100yenadmin.
|
||||
- QA-Lab: include the optional 100-turn runtime parity soak in release-soak artifacts so long-run Codex/Pi transcript drift stays visible outside the default gate. (#80395) Thanks @100yenadmin.
|
||||
- QA-Lab: add a live-only long-context progress watchdog scenario for Codex app-server timeout and stalled-run sentinels. (#80323) Thanks @100yenadmin.
|
||||
- QA-Lab: tag gateway restart recovery and streaming final-integrity scenarios as live-only runtime parity lanes. (#80323) Thanks @100yenadmin.
|
||||
- QA-Lab: add a personal-agent failure recovery scenario that checks honest partial status, retry boundaries, and local recovery artifacts. (#83872) Thanks @iFiras-Max1.
|
||||
- QA-Lab: include an opt-in `update.run` package self-upgrade sentinel for destructive latest-package recovery checks.
|
||||
- QA-Lab: add Codex plugin lifecycle and auth-profile fixture coverage for missing installs, pinned-version drift, first-turn install ordering, and doctor migration safety. (#80323, refs #80174) Thanks @100yenadmin.
|
||||
- Models/perf: pre-warm the provider auth-state map at gateway startup so `/models` and every model-listing call short-circuits the per-provider plugin / external-CLI discovery on the hot path. Per-call cost drops from ~20 s to ~5 ms (~4,100×); the one-time startup warm resets and re-warms after hot reloads. (#84816) Thanks @sjf.
|
||||
- Tests/perf: isolate doctor core health check unit coverage from real skills/workspace discovery so `doctor-core-checks` no longer dominates unit perf while keeping one real skills-readiness smoke. (#84493) Thanks @frankekn.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agents/Anthropic: preserve unsafe integer tool-call input values in streamed Anthropic tool-use JSON, preventing Discord-style IDs from being rounded before dispatch. Fixes #47229. (#83063) Thanks @leno23.
|
||||
- CLI/agents: allow `openclaw agent --session-key` to target explicit session keys, including agent-scoped legacy keys. (#85121) Thanks @Kaspre.
|
||||
- Auto-reply/ACP: wait for same-channel block reply delivery before starting tool work, while still honoring ACP dispatch aborts so stopped turns do not wait on slow channel sends. (#83722) Thanks @IWhatsskill.
|
||||
- Codex/ACP: mark required child-run completions that only report progress, omit a final deliverable, or fail requester delivery as blocked while preserving real final reports. (#85110) Thanks @IWhatsskill.
|
||||
- Agents/subagents: surface blocked child-run completions as errors instead of successful subagent finishes. (#80886) Thanks @TurboTheTurtle.
|
||||
- Agents/Pi: treat accepted embedded `sessions_spawn` child-session handoffs as terminal progress so parent turns no longer report false non-deliverable failures. (#85054) Thanks @samzong.
|
||||
- WhatsApp: update Baileys to `7.0.0-rc13` and drop the obsolete logger type patch.
|
||||
- Install/update: reject OpenClaw GitHub source package targets early and point moving-main users at the dev/git install path instead of the broken npm source-install flow.
|
||||
- Gateway: mirror successful same-source message-tool sends into session transcripts so delivered replies stay in later history/context. (#84837) Thanks @iFiras-Max1.
|
||||
- Infra/json: retry transient `File changed during read` races while loading JSON state so config and state reads recover instead of failing the turn. (#84285)
|
||||
- Gateway/chat: surface message-tool-only room-event failures in chat diagnostics and session transcripts so suppressed source replies stay debuggable. Thanks @amknight.
|
||||
- Plugins/providers: fail closed for workspace provider plugins during setup-mode discovery unless explicitly trusted, preventing untrusted workspace plugin code from running during provider setup. (#81069) Thanks @mmaps.
|
||||
- Providers/Ollama: resolve configured Ollama Cloud `OLLAMA_API_KEY` markers to the real discovery key so cloud provider entries keep authenticated model catalog access. (#85037)
|
||||
- Discord: keep persistent component registry fallback warnings actionable by forwarding structured error and cause metadata through the runtime logger. Fixes #84185. (#84190) Thanks @100menotu001.
|
||||
- Gateway/sessions: preserve compatible session auth profile overrides when switching models within the same provider, including provider-auth aliases. Fixes #81837. (#81886) Thanks @TurboTheTurtle.
|
||||
- Gateway/status: surface inbound delivery telemetry counters and transport-liveness warnings in `openclaw status --all`. Fixes #49577. (#72724)
|
||||
- Docker: prune package-excluded plugin source workspaces and dependency closures so runtime images do not keep packages for plugins that were not opted in.
|
||||
- Providers/Ollama: treat Docker/OrbStack host aliases as local Ollama endpoints so `ollama-local` marker auth works when OpenClaw runs inside a VM/container and Ollama runs on the host. Fixes #84875.
|
||||
- QA-Lab: keep explicitly searchable/deferred OpenClaw dynamic tool rows report-only by default so tool-coverage gates do not treat mock discovery gaps as hard product failures. (#80319) Thanks @100yenadmin.
|
||||
- Agents/config: keep non-Google provider model refs from being rewritten by Google Gemini preview-id normalization. (#84762) Thanks @zhangguiping-xydt.
|
||||
- Installer: require a real controlling terminal before launching onboarding so headless `curl | bash` installs finish cleanly after installing the CLI.
|
||||
- Agents/Codex: promote a completed final assistant response when a prompt timeout races Codex app-server completion instead of returning an empty timeout envelope. Refs #84516.
|
||||
- Agents: cap heartbeat model bleed context hints by the stored session window when runtime model metadata is unavailable, so overflow recovery advice does not suggest a larger window than the active session actually has.
|
||||
- Control UI/Web Push: use `https://openclaw.ai` as the generated default VAPID subject instead of the old localhost mailbox so iOS PWA push setup uses an Apple-acceptable subject when `OPENCLAW_VAPID_SUBJECT` is unset. Fixes #83134. (#83317) Thanks @IWhatsskill.
|
||||
- Agents/Pi: keep embedded session transcript writes from tripping false takeover detection after packaged npm onboarding agent turns.
|
||||
- Codex/TUI: surface Codex-native post-turn compaction failures instead of continuing uncompacted, and keep successful native compaction serialized before local idle/next-turn handling. Fixes #84305. (#85160) Thanks @joshavant.
|
||||
- Memory/search: stop recall tracking from writing dreaming side-effect artifacts when `dreaming.enabled=false`, while preserving normal search results. Fixes #84436. (#84444) Thanks @NianJiuZst.
|
||||
- Diffs: render viewer toolbar icons from a closed icon-name map instead of HTML strings, removing the toolbar icon XSS sink. (#83955) Thanks @tanshanshan.
|
||||
- QA: keep `pnpm qa:e2e` self-check runs inside the private QA runtime envelope even when inherited shell env disables bundled plugins.
|
||||
- fix(config): validate browser sandbox bind sources [AI]. (#84799) Thanks @pgondhi987.
|
||||
- doctor: constrain legacy plugin cleanup paths [AI]. (#84801) Thanks @pgondhi987.
|
||||
- Update/doctor: prune stale local bundled plugin install records that point at old compiled bundled output so current bundled plugin schemas win after upgrade. (#84863) Thanks @fuller-stack-dev.
|
||||
- Providers/Ollama: preserve native Ollama tool-call IDs across assistant replay so Gemini over Ollama Cloud can keep its hidden function-call thought-signature handle.
|
||||
- Discord: keep session recovery and `/stop` abort ownership on the source dispatch lane while bound ACP turns continue routing to their target session, so stalled pre-run work and late replies are cleared instead of leaking after stop. Fixes #84477. (#85100) Thanks @joshavant.
|
||||
- Codex app-server: mark missing turn completion after observed execution as replay-unsafe and release the session so follow-up turns can run. Fixes #84076. (#85107) Thanks @joshavant.
|
||||
- Codex app-server: add a dedicated post-tool raw assistant completion idle timeout config so trusted heavy turns can wait longer after tool handoff without weakening final assistant release.
|
||||
- Matrix: keep explicitly configured two-person rooms on the room route before stale `m.direct` or strict two-member DM fallback can bypass mention gating. Fixes #85017. (#85137) Thanks @joshavant.
|
||||
- Agents/subagents: require explicit subagent allowlist targets to be configured agents so stale deleted-agent ids are omitted from `agents_list` and rejected by `sessions_spawn`. Fixes #84811. (#85154) Thanks @joshavant.
|
||||
- PDF tool: time out idle remote PDF body reads after 120 seconds so stalled remote documents return an error instead of wedging the session. Fixes #68649. (#84768) Thanks @luoyanglang.
|
||||
- Diagnostics/OpenTelemetry plugin: suppress handled OTLP exporter promise rejections so collector shutdowns no longer crash the Gateway. (#81085) Thanks @luoyanglang.
|
||||
- Agents/exec: omit raw command text and env values from denied exec failure logs while keeping safe correlation metadata. Fixes #85049. (#85140) Thanks @joshavant.
|
||||
- Media/audio: skip empty structured sherpa-onnx transcripts instead of treating the raw JSON payload as spoken text. (#84667) Thanks @TurboTheTurtle.
|
||||
- Agents/exec: preserve inherited XDG base-directory environment values for subprocesses while still rejecting agent-supplied XDG overrides. Fixes #84854. (#85139) Thanks @joshavant.
|
||||
- Node/Linux: keep `OPENCLAW_GATEWAY_TOKEN` out of generated systemd unit files by writing node service token values to a node-specific env file. (#84408)
|
||||
- Memory-core/dreaming: reuse stable narrative subagent session keys per workspace and phase while keeping per-run idempotency and bounded cleanup, so stale `dreaming-narrative-*` sessions do not accumulate. Fixes #68252, #69187, and #70402. (#70464) Thanks @chiyouYCH.
|
||||
- Trajectory/support: tolerate partial skill snapshot entries when building support metadata so rejected skill path scans no longer abort trajectory capture. (#71185) Thanks @lukeboyett.
|
||||
- TUI: coalesce repeated idle Esc abort notices into a single `no active run xN` system row instead of appending duplicate rows.
|
||||
- Telegram: honor `channels.telegram.pollingStallThresholdMs` in the default isolated polling path, restarting silent workers instead of leaving inbound updates wedged. Fixes #83950. (#84861) Thanks @joshavant.
|
||||
- Slack: suppress reasoning payloads before reply delivery and dispatch accounting, so Slack monitor, slash-command, fallback, and direct reply paths do not leak model reasoning. Fixes #84319. (#84322) Thanks @ffluk3 and @joshavant.
|
||||
- Slack: deliver native plugin approval prompts and updates when Slack native approvals are enabled, while keeping plugin approval authorization separate from exec approvers.
|
||||
- Agents/Pi: disable the embedded pi-coding-agent runtime auto-retry so OpenClaw's own retry and failover loop does not replay failed tool calls through a nested SDK retry. Fixes #73781. (#74434) Thanks @yelog.
|
||||
- CLI/perf: keep `setup --help`, `onboard --help`, and `configure --help` out of the full wizard runtime while preserving the existing help output. (#84488) Thanks @frankekn.
|
||||
- CLI/perf: keep `agents --help` out of agents action/runtime imports so help, completion, and command discovery paths avoid loading the full agents runtime. (#84483) Thanks @frankekn.
|
||||
- CLI/perf: keep `secrets --help` and `nodes --help` on the precomputed help path so parent help avoids loading action-heavy command runtime modules. (#84818) Thanks @frankekn.
|
||||
- CLI/perf: serve `doctor`, `gateway`, `models`, and `plugins` parent help from startup metadata so common subcommand help avoids full CLI program construction. (#84786) Thanks @frankekn.
|
||||
- Codex/Lossless: keep context-engine history on the canonical run session when Telegram DMs use per-peer runtime policy keys. Fixes #84936. (#84954) Thanks @neeravmakwana.
|
||||
- Auth/OAuth: skip the refresh adapter when a stored OAuth credential has no refresh token so agent turns fail fast on missing-key instead of waiting on the 120s refresh timeout. Thanks @romneyda.
|
||||
- Auth/Codex: load legacy OAuth sidecar credentials in the embedded runner's secrets-runtime auth loaders so Telegram replies, cron-triggered turns, and other isolated sub-agent lanes can reach the existing #83312 refresh-and-rewrite migration instead of failing with `No API key found for provider "openai-codex"` until the user runs `openclaw doctor`. Thanks @Totalsolutionsync and @romneyda.
|
||||
- Codex/failover: classify `deactivated_workspace` as a permanent auth failure so configured fallback models can advance when a Codex workspace is deactivated. (#55893) Thanks @litang9.
|
||||
- Exec: keep configured `tools.exec.pathPrepend` entries ahead of user shell startup PATH changes on POSIX gateway runs. (#81403) Thanks @medns.
|
||||
- Gateway/sessions: allow shared-secret bearer callers to read and stream session history without an explicit scope header. (#81815) Thanks @medns.
|
||||
- Agents/embedded runner: classify HTML auth provider responses as `auth_html` and return a re-authentication hint instead of the CDN-blocked copy that `upstream_html` returns. Cloudflare Access login pages, nginx basic-auth challenges, and gateway login walls all produce HTML auth bodies that were previously misdiagnosed as transient CDN blocks. (#79900) Thanks @martingarramon.
|
||||
- Agents/Pi: tolerate OpenClaw-owned transcript writes while embedded prompts are released for model I/O, keeping long-running Feishu, Slack, Telegram, and cron turns from failing with false session-takeover errors. Fixes #84059. (#84250) Thanks @tianxiaochannel-oss88.
|
||||
|
||||
## 2026.5.20
|
||||
|
||||
### Changes
|
||||
|
||||
- Exec approvals: remove the old `cat SKILL.md && printf ... && <skill-wrapper>` allowlist compatibility path so skill files must be loaded with the read tool and only the real skill executable is auto-allowed.
|
||||
- Discord: let voice sessions follow configured Discord users into voice channels, with allowed-channel checks, multi-user handoff, bounded reconciliation, and DAVE recovery preservation. (#84264) Thanks @fuller-stack-dev.
|
||||
- Discord/voice: include bounded `IDENTITY.md`, `USER.md`, and `SOUL.md` profile context in realtime voice session instructions by default, with `voice.realtime.bootstrapContextFiles: []` available to disable it. (#84499) Thanks @fuller-stack-dev.
|
||||
- Dependencies: bump the bundled Codex harness to `@openai/codex` `0.132.0` and refresh the app-server model-list docs for the new catalog.
|
||||
- CLI/policy: add the bundled Policy plugin for policy-backed channel conformance checks, doctor lint findings, and opt-in workspace repair. (#80407) Thanks @giodl73-repo.
|
||||
- Agents/config: allow `agents.list[].experimental.localModelLean` so lean local-model mode can be enabled for one configured agent instead of globally. (#84073) Thanks @dutifulbob.
|
||||
- Providers/xAI: add device-code OAuth login so remote and headless setups can authorize xAI without a localhost browser callback. (#84005) Thanks @fuller-stack-dev.
|
||||
- Providers/OpenRouter: honor provider-level `params.provider` routing policy for OpenRouter requests, with model and agent params overriding the defaults. Thanks @amknight.
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI/tasks: include stale-running task maintenance decisions in `openclaw tasks maintenance --json` so retained and reconcile candidates explain backing-session, cron, CLI, and wedged-subagent state. (#84691) Thanks @efpiva.
|
||||
- Codex app-server: keep system-prompt reports working when bootstrap hooks provide workspace files with only a path and content, so hook-supplied SOUL/IDENTITY/TOOLS/USER context still reports injected characters correctly. (#84736) Thanks @JARVIS-Glasses.
|
||||
- Providers/MiniMax music: stop advertising `durationSeconds` control and remove prompt-injected duration hints, so `music_generate` reports MiniMax duration as an unsupported override instead of suggesting MiniMax can enforce track length. Fixes #84508. Thanks @neeravmakwana.
|
||||
- Doctor: warn when sandbox tool policy hides configured MCP server tools before provider requests. (#84699) Thanks @nxmxbbd.
|
||||
- WhatsApp: update Baileys to `7.0.0-rc12`.
|
||||
- Build: suppress per-locale `rolldown-plugin-dts:fake-js` CommonJS dts warnings emitted while bundling the intentionally-inlined `zod/v4/locales/*.d.cts` files, so `pnpm build` output stays readable after the 0.25.1 plugin bump. Thanks @romneyda.
|
||||
- CLI/nodes: route lazy plugin-registration logs to stderr for JSON-mode `openclaw nodes` commands so stdout stays parseable. (#84684) Thanks @TurboTheTurtle.
|
||||
- Approvals: route manual `/approve` decisions through the trusted approval runtime so active exec and plugin approvals no longer look unknown or expired.
|
||||
- Mac app: update the About settings copyright year to 2026. (#84385) Thanks @pejmanjohn.
|
||||
- Dependencies: update `@openclaw/fs-safe` to `0.2.7` so OpenClaw's default Python-helper-off policy keeps best-effort Node write fallbacks for private stores, secret writes, run logs, and media attachments on Linux/macOS.
|
||||
- Infra/secrets: restore the fail-closed contract for `tryReadSecretFileSync` so credential loaders that pass `rejectSymlink: true` (Telegram, LINE, Zalo, IRC, Nextcloud Talk tokens) refuse symlinked credential files instead of silently accepting them, and the infra-state CI shard's secret-file symlink test passes again. Thanks @romneyda.
|
||||
- Browser: honor the configured image sanitization limit for screenshots and labeled snapshots so browser-captured images follow the same resize policy as other image results. (#84595)
|
||||
- Doctor: remove unrecognized `models.providers.*.models[*].compat.thinkingFormat` values during `doctor --fix` so stale provider model config can validate after upgrade. Fixes #77803.
|
||||
- Doctor: warn when `openclaw.json` stores plaintext secret-bearing config fields, including model provider API keys and sensitive provider headers. (#84718) Thanks @lukaIvanic.
|
||||
- Status: show the configured default, session-selected model, reason, clear hint, and docs link when a session remains pinned to a model that differs from `agents.defaults.model.primary`.
|
||||
- WebChat: clear stale typing indicators when session change events mark the active chat run complete.
|
||||
- Mac app: keep local packaging signed with a stable app identity for permission testing and fix Control UI production builds under current Vite/Highlight.js exports.
|
||||
- macOS app: update the embedded Peekaboo bridge to 3.2.1 so OpenClaw-hosted UI automation works with current Peekaboo CLI capture flows.
|
||||
- Cron: deliver preferred final assistant output for successful scheduled runs when trailing plain tool warnings remain in diagnostics instead of marking the run failed.
|
||||
- fix(mattermost): fail closed on missing channel type [AI]. (#84091) Thanks @pgondhi987.
|
||||
- Recheck rebuilt system.run argv [AI]. (#84090) Thanks @pgondhi987.
|
||||
- CLI: keep the private QA subcommand out of exported command descriptors unless `OPENCLAW_ENABLE_PRIVATE_QA_CLI=1`, so root help and subcommand markers match runtime registration. (#84519)
|
||||
- CLI/cron: bound `openclaw cron show` job lookup pagination so non-advancing or unbounded `cron.list` responses fail instead of hanging the command. Fixes #83856. (#83989)
|
||||
- Agents/messages: stop message-tool-only turns after a successful source-channel `message` send while keeping transcript mirrors under the session write lock. (#84289)
|
||||
- Agents: filter silent heartbeat response-tool transcript artifacts out of embedded context snapshots so later user turns are not polluted by heartbeat no-op messages. (#83477) Thanks @fuller-stack-dev.
|
||||
- Agents/OpenAI: log repeated strict tool-schema downgrade diagnostics once per provider/model/tool signature, reducing duplicate debug noise while preserving `strict=false` fallback behavior. Fixes #82930. (#82933) Thanks @galiniliev.
|
||||
- Agents/code mode: spell out the `exec` tool's JavaScript/TypeScript, no Node module, and catalog-bridge constraints in model-visible schema text so agents can use enabled tools without trial-and-error. (#84269) Thanks @Kaspre.
|
||||
- Codex: give `image_generate` dynamic-tool calls a 120s default watchdog when no per-call or configured image timeout is set, so image generation no longer falls back to the generic 30s bridge timeout. (#84254) Thanks @moritzmmayerhofer.
|
||||
- Codex: avoid duplicate dynamic tool terminal diagnostics while large diagnostic backlogs drain without blocking tool responses. (#82937) Thanks @galiniliev.
|
||||
- CLI/message: include a stable top-level `messageId` in `openclaw message --json` output when channel sends return one. (#84191) Thanks @100menotu001.
|
||||
- Cron: preserve legacy top-level array `jobs.json` stores when loading or adding scheduled jobs so old cron jobs are no longer treated as an empty store during upgrade. Fixes #60799. (#84433) Thanks @IWhatsskill.
|
||||
- Gateway/agents: use an agent's `identity.name` in Gateway agent summaries when `agents.list[].name` is unset, so configured agent labels remain visible in clients. (#84355; refs #57835) Thanks @luoyanglang.
|
||||
- Channels/replies: keep normal `/verbose` failed-tool progress compact in message-tool replies and prevent late text-only tool output from appearing after the final answer. (#84303) Thanks @VACInc.
|
||||
- Plugins/hooks: apply a default 30-second timeout to `before_compaction` and `after_compaction` hooks so a hung plugin handler no longer blocks compaction completion. (#84153)
|
||||
- Discord: preserve reusable presentation buttons through portable conversion and Discord component registration. (#84187) Thanks @100menotu001.
|
||||
- Discord: preserve disabled presentation buttons when adapting and rendering Discord message controls. (#84188) Thanks @100menotu001.
|
||||
- Twitch: add a test-only client-manager registry reset helper so non-isolated Twitch tests can clear cached managers between cases. Fixes #83887. (#84244) Thanks @hclsys.
|
||||
- Cron: run main-session scheduled work on a cron-owned wake lane while preserving reply delivery context, so background cron turns no longer block human main-session chat. Fixes #82766. (#82767) Thanks @galiniliev.
|
||||
- Cron: use structured embedded-run denial metadata for isolated scheduled tasks so blocked exec requests fail the job without treating ordinary assistant prose as a denial. (#84067) Thanks @abnershang.
|
||||
- Cron: keep recovered tool warnings diagnostic for successful scheduled runs so final cron output is delivered instead of being replaced by a post-processing warning. (#84045) Thanks @abnershang.
|
||||
- Plugins/perf: thread explicit plugin discovery results through `loadBundledCapabilityRuntimeRegistry`, `resolveBundledPluginSources`, and `listChannelCatalogEntries` so callers that already hold a discovery result skip redundant filesystem walks. Thanks @SebTardif.
|
||||
- harden update restart script creation [AI]. (#84088) Thanks @pgondhi987.
|
||||
- Docker: keep the bundled Codex plugin in official release image keep lists so the default OpenAI agent harness remains available after Docker pruning. Fixes #83613. (#83626) Thanks @YuanHanzhong.
|
||||
- CLI/channels: preserve the first line of `openclaw channels logs` output when the rolling tail window starts exactly on a line boundary, mirroring the already-fixed `readLogSlice` behavior in `src/logging/log-tail.ts`.
|
||||
- Control UI: treat terminal session status as authoritative over stale active-run flags so completed terminal runs stop showing abort/live UI. (#84057)
|
||||
- CLI: preserve embedded equals signs in inline root option values instead of truncating after the second separator. (#83995) Thanks @ThiagoCAltoe.
|
||||
- Matrix/config: accept `messages.queue.byChannel.matrix` queue overrides and keep queue provider schema/type keys aligned for Matrix, Google Chat, and Mattermost. Thanks @bdjben.
|
||||
- CLI: format `openclaw acp client` failures through the shared error formatter so object-shaped errors stay readable instead of printing `[object Object]`. Fixes #83904. (#84080)
|
||||
- Providers/Ollama: default unknown-capabilities models to tool-capable so discovered native Ollama models can use tools when `/api/show` omits capabilities. (#84055) Thanks @dutifulbob.
|
||||
- Codex app-server: disable native Code Mode, user MCP, and app-backed plugin execution while OpenClaw sandboxing is active, routing shell access through `sandbox_exec`/`sandbox_process` instead. (#84388) Thanks @joshavant.
|
||||
- Installer/Windows: launch `install.ps1` onboarding as an attached child process so fresh native Windows installs do not freeze visibly at `Starting setup...` or corrupt the wizard's terminal rendering.
|
||||
- CLI/update: keep restart health checks working across one-version CLI/Gateway protocol skew and use the managed Gateway service Node for all follow-up commands even when the package root is unchanged, so `openclaw update` no longer silently switches the gateway to a different Node binary when multiple Node installations are present. Thanks @amknight.
|
||||
- CLI/gateway: include the running Gateway version in `gateway status` JSON output, preserving existing server metadata while falling back to status RPC data for read probes. Fixes #56222. Thanks @galiniliev.
|
||||
- Memory/search: close local embedding providers when active-memory searches time out so pending local model loads and embedding contexts are aborted and released. (#83858) Thanks @brokemac79.
|
||||
- CLI/nodes: request pending node surface approval scopes before `openclaw nodes approve` so exec-capable node approval can use admin-scoped Gateway credentials instead of failing with `missing scope: operator.admin`. (#84392) Thanks @joshavant.
|
||||
- Gateway: reject slow node event sends before outbound buffers grow unbounded and log the rejected payload diagnostic. (#84387) Thanks @samzong.
|
||||
- Agents: include bounded trajectory queued-writer diagnostics in `pi-trajectory-flush` timeout warnings so flush stalls show pending writes, queued bytes, and append state. Fixes #82961. (#82962) Thanks @galiniliev.
|
||||
- Agents/subagents: recover stale completion announces by retrying unsupported transcript-wait wakes without transcript waiting and forcing a message-tool handoff when the requester run is already stale. Fixes #83699. (#83700) Thanks @galiniliev.
|
||||
- Agents/subagents: constrain wildcard subagent target allowlists to configured agents while preserving explicitly listed compatibility targets. Fixes #84040. (#84357) Thanks @joshavant.
|
||||
- Providers/Anthropic: route Anthropic model refs selected with Claude CLI auth through the Claude CLI runtime so shorthand refs such as `anthropic/opus-4.7` no longer fall back to embedded Anthropic billing. Fixes #84222. (#84374) Thanks @joshavant.
|
||||
- Agents: honor explicit `models.providers.<id>.timeoutSeconds` values above the default idle watchdog for cloud and self-hosted providers, so long first-token waits no longer fall back at ~120s when the provider timeout is higher. (#83979) Thanks @yujiawei.
|
||||
- Agents/Codex: keep encrypted Responses reasoning replay provenance-bound so stale mirrored Codex transcripts drop invalid encrypted content before request assembly while preserving matching same-session replay. Fixes #83836. (#84367) Thanks @joshavant.
|
||||
- Agents/subagents: skip stale embedded-run wake probes for dormant completion requesters, so late subagent completions go straight to requester-agent/direct handoff instead of producing `reason=no_active_run` queue noise. (#82964) Thanks @galiniliev.
|
||||
- CLI: retry config snapshot reads after a transient failure so one rejected read no longer poisons later commands in the same process. (#83931) Thanks @honor2030.
|
||||
- Media: decode URL path basenames before using them as remote media fallback filenames, so files like `My%20Report.pdf` are surfaced as `My Report.pdf`. Fixes #84050. (#84052) Thanks @jbetala7.
|
||||
- WhatsApp: clarify inbound group diagnostics so observed but unregistered groups point to `channels.whatsapp.groups` without changing routing or sender authorization. (#83846) Thanks @neeravmakwana.
|
||||
- WhatsApp: drain pending outbound deliveries on a 30s periodic timer in addition to the reconnect handler, so messages enqueued while the provider is already connected no longer wait for the next reconnect to send. (#79083) Thanks @Oviemudiaga.
|
||||
- CLI/TUI: include gateway plugin slash commands in TUI autocomplete, so connected sessions can suggest plugin-owned commands exposed by the running Gateway. (#83640) Thanks @se7en-agent.
|
||||
- Gateway/mobile: restore QR setup-code handoff of bounded operator tokens for iOS and Android onboarding while keeping admin and pairing scopes out of bootstrap. (#83684) Thanks @ngutman.
|
||||
- iOS: repair Release archive compilation for the TestFlight build. (#84255) Thanks @ngutman.
|
||||
- Agents/compaction: bound plugin-owned CLI transcript compaction with the host safety timeout so a hung context engine can no longer stall post-turn cleanup. (#84083) Thanks @100yenadmin.
|
||||
- Control UI/usage: truncate long context skill, tool, and file names in the usage panel while keeping the full name available on hover. (#42197) Thanks @Rain120.
|
||||
- Codex: respect explicit `models auth order set` and `config.auth.order` precedence over stale `lastGood` in `/codex account`, and show `no working credential` when every explicit-order profile is ineligible instead of marking a lower-ranked profile as active. Fixes #84386. (#84412) Thanks @openperf.
|
||||
- Agents: honor `messages.suppressToolErrors` for mutating tool failures so configured chat surfaces do not receive separate warning payloads. (#81561) Thanks @moeedahmed.
|
||||
- Agents/fallback: surface billing guidance for mixed rate-limit plus billing fallback exhaustion instead of generic failure copy. Fixes #79396. (#79489) Thanks @aayushprsingh.
|
||||
|
||||
## 2026.5.19
|
||||
|
||||
### Changes
|
||||
|
||||
- Agents: clarify that fixes should default to clean bounded refactors, lean internals, and explicit plugin SDK/API deprecation paths.
|
||||
- Agents/tools: normalize Swagger/OpenAPI refs and OpenAPI schema annotations when preparing tool parameter schemas.
|
||||
- Dependencies: update `@openclaw/proxyline` to 0.3.3.
|
||||
- Dependencies: update Pi packages to 0.75.1 and raise the minimum supported Node.js 22 line to 22.19.
|
||||
- Docker/Podman: add `OPENCLAW_IMAGE_APT_PACKAGES` as the runtime-neutral image build arg for extra apt packages while keeping `OPENCLAW_DOCKER_APT_PACKAGES` as a legacy fallback. (#62431) Thanks @urtabajev.
|
||||
@@ -34,6 +206,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/skills: tighten bundled skill prompts and metadata, quote skill descriptions, refresh current CLI/API guidance, and update embedded sherpa-onnx runtime downloads.
|
||||
- Skills: update the Obsidian skill to target the official `obsidian` CLI and require its registered binary instead of the third-party `obsidian-cli`.
|
||||
- Skills: add a Python debugging skill for pdb, breakpoint(), post-mortem inspection, and debugpy remote attach.
|
||||
- Codex: add `/codex plugins list`, `enable`, and `disable` for managing configured native Codex plugins from chat without editing config by hand.
|
||||
- Plugins/messages: add presentation capability limits for channel renderers, adapt rich message controls before native rendering, and mark legacy `interactive`/Slack directive producer APIs as deprecated.
|
||||
- Plugins/subagents: store channel delivery routes as canonical session metadata and deprecate ad hoc subagent hook delivery-origin fields in favor of core route projection.
|
||||
- Proxy: support HTTPS managed forward-proxy endpoints and scoped `proxy.tls.caFile` CA trust for proxy endpoint TLS. (#79171) Thanks @jesse-merhi.
|
||||
@@ -57,6 +230,8 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI/node: reject invalid explicit `node run --port` values instead of silently falling back to the configured or default port. Fixes #83923. Thanks @davinci282828.
|
||||
- CLI: reject explicit port numbers above 65535 before they reach Gateway or Node bind paths. Fixes #83900. (#84008) Thanks @hclsys.
|
||||
- Codex app-server: preserve plugin tool auth profiles when Codex owns model transport so OpenClaw dynamic tools can resolve their provider credentials. (#83603) Thanks @rubencu.
|
||||
- Memory/search: scan the JS-side fallback vector path (used when the sqlite-vec index is unavailable or has a mismatched dimension) in bounded rowid batches and yield to the event loop between batches so large chunk tables can no longer pin the Node.js main thread for multi-second windows. Also keeps the SQL prepared statement rooted in a local so node:sqlite cannot finalize it mid-scan under heap pressure. Fixes #81172. Thanks @dev23xyz-oss.
|
||||
- Memory Wiki: preserve fs-safe diagnostics when bridge source page writes fail for non-symlink filesystem safety reasons, so directory collisions are reported with the underlying error code. (#83776) Thanks @TurboTheTurtle.
|
||||
@@ -64,12 +239,16 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram: keep queued forum-topic follow-up messages from inheriting superseded source abort signals, so later same-topic user turns can still run and reply after an active turn is replaced. (#83827) Thanks @VACInc.
|
||||
- CLI/update: bypass npm freshness filters consistently during managed package and plugin installs so freshly published release plugins remain installable. Thanks @jalehman.
|
||||
- CLI/update: guide root-owned npm install EACCES recovery by stopping the managed Gateway before manual package replacement, then reinstalling and restarting the service. Fixes #83747. (#83757) Thanks @brokemac79.
|
||||
- Twitch: register refreshing chat tokens with Twurple's chat intent so automatic token refresh keeps chat access available. (#83750) Thanks @TurboTheTurtle.
|
||||
- Agents/subagents: keep collect-mode announce queues batching unresolved-origin items with compatible same-route messages and resume collection after a true cross-channel drain when a later compatible batch remains. Fixes #83577.
|
||||
- CLI/config: preserve numeric-looking record keys such as Discord guild IDs when creating missing config containers with `config set`. (#83769) Thanks @TurboTheTurtle.
|
||||
- Skills: refresh existing session skill snapshots when watched skill roots change, so changed extra skill directories take effect without starting a new session. Fixes #83782. (#83800) Thanks @hclsys.
|
||||
- Providers/Anthropic: preserve native image input for current Claude model rows when stale local catalog data marks them text-only. (#83756) Thanks @TurboTheTurtle.
|
||||
- Providers/Anthropic: preserve Claude 4 image capability when configured model refs resolve through a stale local catalog row. (#83756) Thanks @TurboTheTurtle.
|
||||
- Providers/DeepSeek: normalize MCP tool schemas with `anyOf`/`oneOf` unions before normal and compaction requests reach DeepSeek, preventing union-shaped parameters from being rejected. (#83766) Thanks @TurboTheTurtle.
|
||||
- Control UI: render live tool progress from session-scoped `session.tool` Gateway events so externally started runs show their tool cards in the active session. (#83734) Thanks @TurboTheTurtle.
|
||||
- Outbound: resolve send-capable channel plugins from the active runtime registry when the pinned startup registry only has setup metadata. (#83733) Thanks @TurboTheTurtle.
|
||||
- Discord: preserve streamed reply previews when recovered tool-warning finals are delivered before or after the assistant's final reply. (#84169) Thanks @neeravmakwana.
|
||||
- Control UI: keep the chat delete confirmation popover clamped inside the visible viewport on small screens. (#83804) Thanks @ThiagoCAltoe.
|
||||
- Browser: enforce current-tab URL allowlist checks for `/act` evaluate/batch actions and `/highlight` routes while leaving tab-management actions unblocked. (#78523)
|
||||
- CI: require real-behavior-proof verdict markers to come from the ClawSweeper GitHub App before accepting exact-head proof. (#83692)
|
||||
@@ -85,11 +264,15 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram: keep verbose tool progress visible without mirroring non-final progress into active session transcripts, preventing embedded provider replies from aborting mid-run. (#83631) Thanks @kurplunkin.
|
||||
- Telegram: log successful outbound text and media deliveries with account, chat, message, operation, thread, reply, silent, and chunk metadata while keeping message bodies out of logs. Fixes #83196. (#83247) Thanks @jrwrest.
|
||||
- Cron: link isolated scheduled task runs to their stable cron session so task status and cleanup can follow the backing agent run. (#83606) Thanks @jai.
|
||||
- Codex app-server: mark Codex-native subagent task mirrors terminal when blocked or failed spawn-agent calls arrive with stale initializing child state, preventing task registry entries from staying running. Fixes #83852. (#83945) Thanks @joshavant.
|
||||
- CLI: enforce the documented Node.js 22.19 runtime floor in the source launcher.
|
||||
- Release stability: repair broad-gate regressions in requester-agent completion handoff, QA-Lab mock spawn attribution, Slack monitor test isolation, plugin uninstall peer fixtures, and Node-floor launcher contract coverage.
|
||||
- Agents/replies: persist queued follow-up user messages and assistant error stubs only once across model-fallback retries, preventing repeated provider rejections from corrupted same-role session transcripts. Fixes #83404. (#83417) Thanks @yetval.
|
||||
- Telegram: preserve reply-target context for bare mention replies on runtime-only turns so the model sees the replied-to message body. Fixes #83767. (#83953) Thanks @joshavant.
|
||||
- ClawHub: preserve configured base URL path prefixes when building API request URLs, so self-hosted ClawHub instances mounted under a subpath keep routing correctly. (#83982) Thanks @ThiagoCAltoe.
|
||||
- Slack: persist delivered inbound message IDs and fail closed when same-channel thread replies lose their thread context, preventing delayed duplicate replies and accidental channel-root posts. Fixes #83521. Thanks @shannon0430.
|
||||
- Codex app-server: complete OpenClaw dynamic tool diagnostics at the request boundary so successful, failed, timed out, aborted, and blocked tool calls do not leave active tool state behind. Fixes #83474. Thanks @rozmiarD.
|
||||
- Doctor/Codex: warn when Linux host policy blocks the Codex bwrap user or network namespace path used by sandboxed app-server turns, with Ubuntu/AppArmor repair guidance. Refs #83018.
|
||||
- Gateway/config: keep config writes from failing on unrelated unresolved auth-profile SecretRefs while preserving live auth-profile runtime snapshots.
|
||||
- Gateway/sessions: clear stored CLI provider resume bindings on non-subagent `/reset` so the next turn starts a fresh provider-side CLI conversation instead of resuming old context. (#83448) Thanks @jasonyliu.
|
||||
- Doctor: preserve legacy whole-agent Claude CLI intent by moving matching Anthropic model selections to model-scoped runtime policy before removing stale runtime pins. Fixes #83491. Thanks @danielcrick.
|
||||
@@ -101,6 +284,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents: guard final-delivery fresh session routing against mismatched logical sessions before reusing recovered delivery context. (#83928) Thanks @joshavant.
|
||||
- Media: prevent image metadata probing from invoking external decoder delegates on unrecognized image bytes, and stop fallback chaining after real processing errors.
|
||||
- Media: install Sharp with the root package and fall back to sips, Windows native imaging, ImageMagick, GraphicsMagick, or ffmpeg for image resizing/conversion when Sharp is unavailable. Fixes #83401. Thanks @scotthuang.
|
||||
- Channels/bundled: append `openclaw doctor --fix` guidance to the bundled-channel load warnings emitted on `ERR_MODULE_NOT_FOUND` / `MODULE_NOT_FOUND` (including those wrapped on `.cause` by the native-require loader), so users hitting unstaged plugin runtime deps (e.g. `nostr-tools`) see an actionable repair hint instead of a bare module-not-found warning. (#76974) Thanks @BSG2000.
|
||||
- Telegram: deliver generated media completions back into forum topics by preserving topic IDs across requester-agent handoff. (#83556) Thanks @fuller-stack-dev.
|
||||
- Gateway: defer update-check startup until after readiness so package update checks no longer block sidecar-ready startup, while preserving update broadcasts and shutdown cleanup. (#83520) Thanks @samzong.
|
||||
- Telegram: keep `/btw` and read-only status commands from aborting active runs, and avoid retaining raw update payloads in timed-out spool tombstones. Refs #83272.
|
||||
@@ -229,6 +413,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/OpenAI: preserve deterministic tool payload ordering for prompt-cache reuse across OpenAI Responses and chat completions calls. (#82940) Thanks @galiniliev.
|
||||
- ACP/Codex: honor terminal ACP turn results so failed Codex/acpx runs are not recorded as successful after only progress text. Fixes #79522. Thanks @dudaefj.
|
||||
- Telegram: warn when a media group drops photos that fail to download, including albums where every photo is skipped. Fixes #55216. (#82987) Thanks @eldar702.
|
||||
- Agents/diagnostics: treat repeated same-handle embedded-run cleanup as idempotent while preserving true replacement-handle mismatch diagnostics. Fixes #82959. (#82960) Thanks @galiniliev.
|
||||
- Agents/subagents: preserve high-priority `AGENTS.md` policy in bootstrap context when oversized files are trimmed, and warn agents to read the full policy file before relying on scoped rules. Fixes #82920. (#82921) Thanks @galiniliev.
|
||||
- Agents/skills: apply the full effective tool policy pipeline to inline `command-dispatch: tool` skill dispatch before owner-only filtering, preserving configured allow, deny, sandbox, sender, group, and subagent restrictions. (#78525)
|
||||
- Codex: avoid spawning native hook relay subprocesses for post-tool/finalize events with no registered hook handlers while preserving pre-tool safety and approval relays. Fixes #76552. (#78004) Thanks @evgyur.
|
||||
- Channel accounts: keep top-level default channel accounts visible when named accounts are added alongside default credential material, so mixed legacy/new account configs keep resolving `default` instead of silently dropping it.
|
||||
@@ -265,6 +451,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Signal: preserve mixed-case group IDs through routing and session persistence so group auto-replies keep delivering after updates. Fixes #82827.
|
||||
- Agents/tools: keep the `message` tool available in embedded runs when it is explicitly allowed through `tools.alsoAllow` or runtime tool allowlists, so channel plugins with custom reply delivery can still use configured message sends. Fixes #82833. Thanks @cn1313113.
|
||||
- WhatsApp: honor forced document delivery for outbound image, GIF, and video media so `forceDocument`/`asDocument` sends preserve original media bytes instead of using compressed media payloads. (#79272) Thanks @itsuzef.
|
||||
- WhatsApp: reject symlinked Web credential files across auth checks and socket startup so unsafe `creds.json` paths cannot be read through. Thanks @mcaxtr.
|
||||
- WhatsApp: name outbound document attachments from their MIME type when no filename is provided, so PDF and CSV sends arrive as `file.pdf` and `file.csv` instead of an extensionless `file`. Thanks @mcaxtr.
|
||||
- Process/diagnostics: report active lane blockers in lane wait warnings so `queueAhead=0` no longer hides commands waiting behind active work. Fixes #82791. (#82792) Thanks @galiniliev.
|
||||
- Process/diagnostics: stop counting the active processing turn as queued backlog in liveness warnings so transient max-only event-loop spikes do not surface as gateway warnings.
|
||||
@@ -644,6 +831,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Require canonical node platform IDs [AI]. (#81880) Thanks @pgondhi987.
|
||||
- Agents/Azure OpenAI Responses: default unset Azure OpenAI API versions to `preview` so `/openai/v1/responses` calls use Azure's current Responses API route. (#82026) Thanks @leoge007.
|
||||
- Control UI/WebChat: compact the desktop chat header controls into a single aligned row so the session, model, thinking, and action controls no longer waste vertical space. Thanks @BunsDev.
|
||||
- Control UI/settings: widen the Personal quick-settings card to a 3/1 desktop split and keep Appearance/Automations below it on narrower layouts. Thanks @BunsDev.
|
||||
- Agents/model catalog: reuse manifest model-id normalization metadata while loading persisted read-only catalog rows, avoiding repeated metadata scans.
|
||||
- Agents: retry empty final turns for generic `anthropic-messages` providers instead of limiting non-visible recovery to Kimi, so custom/proxied Anthropic-compatible routes can recover with a visible answer. Addresses #46080. Thanks @wmgx, @w1tv, and @iFwu.
|
||||
- Agents/replies: strip workflow `<function_response>` scaffolding from user-visible sanitizer paths so raw tool output does not leak into chat history, transcript mirrors, or channel replies. Fixes #47444. Thanks @5toCode.
|
||||
|
||||
@@ -72,7 +72,8 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/sto
|
||||
NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile \
|
||||
--config.supportedArchitectures.os=linux \
|
||||
--config.supportedArchitectures.cpu="$(node -p 'process.arch')" \
|
||||
--config.supportedArchitectures.libc=glibc
|
||||
--config.supportedArchitectures.libc=glibc && \
|
||||
pnpm store add source-map@0.6.1
|
||||
|
||||
# pnpm v10+ may append peer-resolution hashes to virtual-store folder names; do not hardcode `.pnpm/...`
|
||||
# paths. Matrix's native downloader can hit transient release CDN errors while
|
||||
@@ -116,8 +117,8 @@ ENV OPENCLAW_PREFER_PNPM=1
|
||||
RUN pnpm_config_verify_deps_before_run=false pnpm ui:build
|
||||
RUN pnpm_config_verify_deps_before_run=false pnpm qa:lab:build
|
||||
|
||||
# Prune dev dependencies and strip build-only metadata before copying
|
||||
# runtime assets into the final image.
|
||||
# Prune dev dependencies, omitted plugin runtime packages, and build-only
|
||||
# metadata before copying runtime assets into the final image.
|
||||
FROM build AS runtime-assets
|
||||
ARG OPENCLAW_EXTENSIONS
|
||||
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
|
||||
@@ -127,8 +128,8 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/sto
|
||||
--config.supportedArchitectures.os=linux \
|
||||
--config.supportedArchitectures.cpu="$(node -p 'process.arch')" \
|
||||
--config.supportedArchitectures.libc=glibc && \
|
||||
OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" OPENCLAW_BUNDLED_PLUGIN_DIR="$OPENCLAW_BUNDLED_PLUGIN_DIR" node scripts/prune-docker-plugin-dist.mjs && \
|
||||
node scripts/postinstall-bundled-plugins.mjs && \
|
||||
OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" node scripts/prune-docker-plugin-dist.mjs && \
|
||||
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete && \
|
||||
node scripts/check-package-dist-imports.mjs /app
|
||||
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Peter Steinberger
|
||||
Copyright (c) 2026 OpenClaw Foundation
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
913
appcast.xml
913
appcast.xml
@@ -2,6 +2,398 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.5.20</title>
|
||||
<pubDate>Thu, 21 May 2026 21:19:52 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026052090</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.5.20</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.5.20</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Exec approvals: remove the old <code>cat SKILL.md && printf ... && <skill-wrapper></code> allowlist compatibility path so skill files must be loaded with the read tool and only the real skill executable is auto-allowed.</li>
|
||||
<li>Discord: let voice sessions follow configured Discord users into voice channels, with allowed-channel checks, multi-user handoff, bounded reconciliation, and DAVE recovery preservation. (#84264) Thanks @fuller-stack-dev.</li>
|
||||
<li>Discord/voice: include bounded <code>IDENTITY.md</code>, <code>USER.md</code>, and <code>SOUL.md</code> profile context in realtime voice session instructions by default, with <code>voice.realtime.bootstrapContextFiles: []</code> available to disable it. (#84499) Thanks @fuller-stack-dev.</li>
|
||||
<li>Dependencies: bump the bundled Codex harness to <code>@openai/codex</code> <code>0.132.0</code> and refresh the app-server model-list docs for the new catalog.</li>
|
||||
<li>CLI/policy: add the bundled Policy plugin for policy-backed channel conformance checks, doctor lint findings, and opt-in workspace repair. (#80407) Thanks @giodl73-repo.</li>
|
||||
<li>Agents/config: allow <code>agents.list[].experimental.localModelLean</code> so lean local-model mode can be enabled for one configured agent instead of globally.</li>
|
||||
<li>Providers/xAI: add device-code OAuth login so remote and headless setups can authorize xAI without a localhost browser callback. (#84005) Thanks @fuller-stack-dev.</li>
|
||||
<li>Providers/OpenRouter: honor provider-level <code>params.provider</code> routing policy for OpenRouter requests, with model and agent params overriding the defaults. Thanks @amknight.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>CLI/tasks: include stale-running task maintenance decisions in <code>openclaw tasks maintenance --json</code> so retained and reconcile candidates explain backing-session, cron, CLI, and wedged-subagent state. (#84691) Thanks @efpiva.</li>
|
||||
<li>Codex app-server: keep system-prompt reports working when bootstrap hooks provide workspace files with only a path and content, so hook-supplied SOUL/IDENTITY/TOOLS/USER context still reports injected characters correctly. (#84736) Thanks @JARVIS-Glasses.</li>
|
||||
<li>Providers/MiniMax music: stop advertising <code>durationSeconds</code> control and remove prompt-injected duration hints, so <code>music_generate</code> reports MiniMax duration as an unsupported override instead of suggesting MiniMax can enforce track length. Fixes #84508. Thanks @neeravmakwana.</li>
|
||||
<li>Doctor: warn when sandbox tool policy hides configured MCP server tools before provider requests. (#84699) Thanks @nxmxbbd.</li>
|
||||
<li>WhatsApp: update Baileys to <code>7.0.0-rc12</code>.</li>
|
||||
<li>Build: suppress per-locale <code>rolldown-plugin-dts:fake-js</code> CommonJS dts warnings emitted while bundling the intentionally-inlined <code>zod/v4/locales/*.d.cts</code> files, so <code>pnpm build</code> output stays readable after the 0.25.1 plugin bump. Thanks @romneyda.</li>
|
||||
<li>CLI/nodes: route lazy plugin-registration logs to stderr for JSON-mode <code>openclaw nodes</code> commands so stdout stays parseable. (#84684) Thanks @TurboTheTurtle.</li>
|
||||
<li>Approvals: route manual <code>/approve</code> decisions through the trusted approval runtime so active exec and plugin approvals no longer look unknown or expired.</li>
|
||||
<li>Mac app: update the About settings copyright year to 2026. (#84385) Thanks @pejmanjohn.</li>
|
||||
<li>Dependencies: update <code>@openclaw/fs-safe</code> to <code>0.2.7</code> so OpenClaw's default Python-helper-off policy keeps best-effort Node write fallbacks for private stores, secret writes, run logs, and media attachments on Linux/macOS.</li>
|
||||
<li>Infra/secrets: restore the fail-closed contract for <code>tryReadSecretFileSync</code> so credential loaders that pass <code>rejectSymlink: true</code> (Telegram, LINE, Zalo, IRC, Nextcloud Talk tokens) refuse symlinked credential files instead of silently accepting them, and the infra-state CI shard's secret-file symlink test passes again. Thanks @romneyda.</li>
|
||||
<li>Browser: honor the configured image sanitization limit for screenshots and labeled snapshots so browser-captured images follow the same resize policy as other image results. (#84595)</li>
|
||||
<li>Doctor: remove unrecognized <code>models.providers.*.models[*].compat.thinkingFormat</code> values during <code>doctor --fix</code> so stale provider model config can validate after upgrade. Fixes #77803.</li>
|
||||
<li>Doctor: warn when <code>openclaw.json</code> stores plaintext secret-bearing config fields, including model provider API keys and sensitive provider headers. (#84718) Thanks @lukaIvanic.</li>
|
||||
<li>Status: show the configured default, session-selected model, reason, clear hint, and docs link when a session remains pinned to a model that differs from <code>agents.defaults.model.primary</code>.</li>
|
||||
<li>WebChat: clear stale typing indicators when session change events mark the active chat run complete.</li>
|
||||
<li>Mac app: keep local packaging signed with a stable app identity for permission testing and fix Control UI production builds under current Vite/Highlight.js exports.</li>
|
||||
<li>macOS app: update the embedded Peekaboo bridge to 3.2.1 so OpenClaw-hosted UI automation works with current Peekaboo CLI capture flows.</li>
|
||||
<li>Cron: deliver preferred final assistant output for successful scheduled runs when trailing plain tool warnings remain in diagnostics instead of marking the run failed.</li>
|
||||
<li>fix(mattermost): fail closed on missing channel type [AI]. (#84091) Thanks @pgondhi987.</li>
|
||||
<li>Recheck rebuilt system.run argv [AI]. (#84090) Thanks @pgondhi987.</li>
|
||||
<li>CLI: keep the private QA subcommand out of exported command descriptors unless <code>OPENCLAW_ENABLE_PRIVATE_QA_CLI=1</code>, so root help and subcommand markers match runtime registration. (#84519)</li>
|
||||
<li>CLI/cron: bound <code>openclaw cron show</code> job lookup pagination so non-advancing or unbounded <code>cron.list</code> responses fail instead of hanging the command. Fixes #83856. (#83989)</li>
|
||||
<li>Agents/messages: stop message-tool-only turns after a successful source-channel <code>message</code> send while keeping transcript mirrors under the session write lock. (#84289)</li>
|
||||
<li>Agents: filter silent heartbeat response-tool transcript artifacts out of embedded context snapshots so later user turns are not polluted by heartbeat no-op messages. (#83477) Thanks @fuller-stack-dev.</li>
|
||||
<li>Agents/OpenAI: log repeated strict tool-schema downgrade diagnostics once per provider/model/tool signature, reducing duplicate debug noise while preserving <code>strict=false</code> fallback behavior. Fixes #82930. (#82933) Thanks @galiniliev.</li>
|
||||
<li>Agents/code mode: spell out the <code>exec</code> tool's JavaScript/TypeScript, no Node module, and catalog-bridge constraints in model-visible schema text so agents can use enabled tools without trial-and-error. (#84269) Thanks @Kaspre.</li>
|
||||
<li>Codex: give <code>image_generate</code> dynamic-tool calls a 120s default watchdog when no per-call or configured image timeout is set, so image generation no longer falls back to the generic 30s bridge timeout. (#84254) Thanks @moritzmmayerhofer.</li>
|
||||
<li>Codex: avoid duplicate dynamic tool terminal diagnostics while large diagnostic backlogs drain without blocking tool responses. (#82937) Thanks @galiniliev.</li>
|
||||
<li>CLI/message: include a stable top-level <code>messageId</code> in <code>openclaw message --json</code> output when channel sends return one. (#84191) Thanks @100menotu001.</li>
|
||||
<li>Cron: preserve legacy top-level array <code>jobs.json</code> stores when loading or adding scheduled jobs so old cron jobs are no longer treated as an empty store during upgrade. Fixes #60799. (#84433) Thanks @IWhatsskill.</li>
|
||||
<li>Gateway/agents: use an agent's <code>identity.name</code> in Gateway agent summaries when <code>agents.list[].name</code> is unset, so configured agent labels remain visible in clients. (#84355; refs #57835) Thanks @luoyanglang.</li>
|
||||
<li>Channels/replies: keep normal <code>/verbose</code> failed-tool progress compact in message-tool replies and prevent late text-only tool output from appearing after the final answer. (#84303) Thanks @VACInc.</li>
|
||||
<li>Plugins/hooks: apply a default 30-second timeout to <code>before_compaction</code> and <code>after_compaction</code> hooks so a hung plugin handler no longer blocks compaction completion. (#84153)</li>
|
||||
<li>Discord: preserve disabled presentation buttons when adapting and rendering Discord message controls. (#84188) Thanks @100menotu001.</li>
|
||||
<li>Twitch: add a test-only client-manager registry reset helper so non-isolated Twitch tests can clear cached managers between cases. Fixes #83887. (#84244) Thanks @hclsys.</li>
|
||||
<li>Cron: run main-session scheduled work on a cron-owned wake lane while preserving reply delivery context, so background cron turns no longer block human main-session chat. Fixes #82766. (#82767) Thanks @galiniliev.</li>
|
||||
<li>Cron: use structured embedded-run denial metadata for isolated scheduled tasks so blocked exec requests fail the job without treating ordinary assistant prose as a denial. (#84067) Thanks @abnershang.</li>
|
||||
<li>Cron: keep recovered tool warnings diagnostic for successful scheduled runs so final cron output is delivered instead of being replaced by a post-processing warning. (#84045) Thanks @abnershang.</li>
|
||||
<li>Plugins/perf: thread explicit plugin discovery results through <code>loadBundledCapabilityRuntimeRegistry</code>, <code>resolveBundledPluginSources</code>, and <code>listChannelCatalogEntries</code> so callers that already hold a discovery result skip redundant filesystem walks. Thanks @SebTardif.</li>
|
||||
<li>harden update restart script creation [AI]. (#84088) Thanks @pgondhi987.</li>
|
||||
<li>Docker: keep the bundled Codex plugin in official release image keep lists so the default OpenAI agent harness remains available after Docker pruning. Fixes #83613. (#83626) Thanks @YuanHanzhong.</li>
|
||||
<li>CLI/channels: preserve the first line of <code>openclaw channels logs</code> output when the rolling tail window starts exactly on a line boundary, mirroring the already-fixed <code>readLogSlice</code> behavior in <code>src/logging/log-tail.ts</code>.</li>
|
||||
<li>Control UI: treat terminal session status as authoritative over stale active-run flags so completed terminal runs stop showing abort/live UI. (#84057)</li>
|
||||
<li>CLI: preserve embedded equals signs in inline root option values instead of truncating after the second separator. (#83995) Thanks @ThiagoCAltoe.</li>
|
||||
<li>Matrix/config: accept <code>messages.queue.byChannel.matrix</code> queue overrides and keep queue provider schema/type keys aligned for Matrix, Google Chat, and Mattermost. Thanks @bdjben.</li>
|
||||
<li>CLI: format <code>openclaw acp client</code> failures through the shared error formatter so object-shaped errors stay readable instead of printing <code>[object Object]</code>. Fixes #83904. (#84080)</li>
|
||||
<li>Providers/Ollama: default unknown-capabilities models to tool-capable so discovered native Ollama models can use tools when <code>/api/show</code> omits capabilities. (#84055) Thanks @dutifulbob.</li>
|
||||
<li>Installer/Windows: launch <code>install.ps1</code> onboarding as an attached child process so fresh native Windows installs do not freeze visibly at <code>Starting setup...</code> or corrupt the wizard's terminal rendering.</li>
|
||||
<li>CLI/update: keep restart health checks working across one-version CLI/Gateway protocol skew and use the managed Gateway service Node for all follow-up commands even when the package root is unchanged, so <code>openclaw update</code> no longer silently switches the gateway to a different Node binary when multiple Node installations are present. Thanks @amknight.</li>
|
||||
<li>CLI/gateway: include the running Gateway version in <code>gateway status</code> JSON output, preserving existing server metadata while falling back to status RPC data for read probes. Fixes #56222. Thanks @galiniliev.</li>
|
||||
<li>Memory/search: close local embedding providers when active-memory searches time out so pending local model loads and embedding contexts are aborted and released. (#83858) Thanks @brokemac79.</li>
|
||||
<li>CLI/nodes: request pending node surface approval scopes before <code>openclaw nodes approve</code> so exec-capable node approval can use admin-scoped Gateway credentials instead of failing with <code>missing scope: operator.admin</code>. (#84392) Thanks @joshavant.</li>
|
||||
<li>Gateway: reject slow node event sends before outbound buffers grow unbounded and log the rejected payload diagnostic. (#84387) Thanks @samzong.</li>
|
||||
<li>Agents: include bounded trajectory queued-writer diagnostics in <code>pi-trajectory-flush</code> timeout warnings so flush stalls show pending writes, queued bytes, and append state. Fixes #82961. (#82962) Thanks @galiniliev.</li>
|
||||
<li>Agents/subagents: recover stale completion announces by retrying unsupported transcript-wait wakes without transcript waiting and forcing a message-tool handoff when the requester run is already stale. Fixes #83699. (#83700) Thanks @galiniliev.</li>
|
||||
<li>Agents/subagents: constrain wildcard subagent target allowlists to configured agents while preserving explicitly listed compatibility targets. Fixes #84040. (#84357) Thanks @joshavant.</li>
|
||||
<li>Providers/Anthropic: route Anthropic model refs selected with Claude CLI auth through the Claude CLI runtime so shorthand refs such as <code>anthropic/opus-4.7</code> no longer fall back to embedded Anthropic billing. Fixes #84222. (#84374) Thanks @joshavant.</li>
|
||||
<li>Agents: honor explicit <code>models.providers.<id>.timeoutSeconds</code> values above the default idle watchdog for cloud and self-hosted providers, so long first-token waits no longer fall back at ~120s when the provider timeout is higher. (#83979) Thanks @yujiawei.</li>
|
||||
<li>Agents/Codex: keep encrypted Responses reasoning replay provenance-bound so stale mirrored Codex transcripts drop invalid encrypted content before request assembly while preserving matching same-session replay. Fixes #83836. (#84367) Thanks @joshavant.</li>
|
||||
<li>Agents/subagents: skip stale embedded-run wake probes for dormant completion requesters, so late subagent completions go straight to requester-agent/direct handoff instead of producing <code>reason=no_active_run</code> queue noise. (#82964) Thanks @galiniliev.</li>
|
||||
<li>CLI: retry config snapshot reads after a transient failure so one rejected read no longer poisons later commands in the same process. (#83931) Thanks @honor2030.</li>
|
||||
<li>Media: decode URL path basenames before using them as remote media fallback filenames, so files like <code>My%20Report.pdf</code> are surfaced as <code>My Report.pdf</code>. Fixes #84050. (#84052) Thanks @jbetala7.</li>
|
||||
<li>WhatsApp: clarify inbound group diagnostics so observed but unregistered groups point to <code>channels.whatsapp.groups</code> without changing routing or sender authorization. (#83846) Thanks @neeravmakwana.</li>
|
||||
<li>WhatsApp: drain pending outbound deliveries on a 30s periodic timer in addition to the reconnect handler, so messages enqueued while the provider is already connected no longer wait for the next reconnect to send. (#79083) Thanks @Oviemudiaga.</li>
|
||||
<li>CLI/TUI: include gateway plugin slash commands in TUI autocomplete, so connected sessions can suggest plugin-owned commands exposed by the running Gateway. (#83640) Thanks @se7en-agent.</li>
|
||||
<li>Gateway/mobile: restore QR setup-code handoff of bounded operator tokens for iOS and Android onboarding while keeping admin and pairing scopes out of bootstrap. (#83684) Thanks @ngutman.</li>
|
||||
<li>iOS: repair Release archive compilation for the TestFlight build. (#84255) Thanks @ngutman.</li>
|
||||
<li>Agents/compaction: bound plugin-owned CLI transcript compaction with the host safety timeout so a hung context engine can no longer stall post-turn cleanup. (#84083) Thanks @100yenadmin.</li>
|
||||
<li>Control UI/usage: truncate long context skill, tool, and file names in the usage panel while keeping the full name available on hover. (#42197) Thanks @Rain120.</li>
|
||||
<li>Codex: respect explicit <code>models auth order set</code> and <code>config.auth.order</code> precedence over stale <code>lastGood</code> in <code>/codex account</code>, and show <code>no working credential</code> when every explicit-order profile is ineligible instead of marking a lower-ranked profile as active. Fixes #84386. (#84412) Thanks @openperf.</li>
|
||||
<li>Agents: honor <code>messages.suppressToolErrors</code> for mutating tool failures so configured chat surfaces do not receive separate warning payloads. (#81561) Thanks @moeedahmed.</li>
|
||||
<li>Agents/fallback: surface billing guidance for mixed rate-limit plus billing fallback exhaustion instead of generic failure copy. Fixes #79396. (#79489) Thanks @aayushprsingh.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.20/OpenClaw-2026.5.20.zip" length="54396392" type="application/octet-stream" sparkle:edSignature="Ufz+twYjgj5NDg29tG3Ttx/JNyT3/a3EKLciBGvsa38C6Dwqp4yFYC5jSBiSlubwBXhrq8OQDMgavMKtSsclBQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.5.19</title>
|
||||
<pubDate>Wed, 20 May 2026 21:27:21 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026051990</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.5.19</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.5.19</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Agents: clarify that fixes should default to clean bounded refactors, lean internals, and explicit plugin SDK/API deprecation paths.</li>
|
||||
<li>Dependencies: update <code>@openclaw/proxyline</code> to 0.3.3.</li>
|
||||
<li>Dependencies: update Pi packages to 0.75.1 and raise the minimum supported Node.js 22 line to 22.19.</li>
|
||||
<li>Docker/Podman: add <code>OPENCLAW_IMAGE_APT_PACKAGES</code> as the runtime-neutral image build arg for extra apt packages while keeping <code>OPENCLAW_DOCKER_APT_PACKAGES</code> as a legacy fallback. (#62431) Thanks @urtabajev.</li>
|
||||
<li>Gateway/ACPX: attribute startup probe, config, runtime, and resource-count costs in restart traces without changing readiness behavior. (#83300) Thanks @samzong.</li>
|
||||
<li>Gateway: overlap startup logging and plugin-service startup with channel sidecars to reduce restart ready latency while preserving <code>/readyz</code> sidecar gating. (#83301) Thanks @samzong.</li>
|
||||
<li>Plugins/admin-http-rpc: allow trusted admin HTTP RPC clients to start and wait for web QR login flows. (#83259) Thanks @liorb-mountapps.</li>
|
||||
<li>Mac app: redesign Settings pages with consistent card layouts, cached navigation, cleaner permissions/voice/skills/cron/exec/debug panes, and steadier spacing around the native sidebar.</li>
|
||||
<li>Mac app: refine Voice & Talk recognition-language and wake-phrase settings so they use the same compact card rows as the rest of Settings.</li>
|
||||
<li>Skills: rename the repo-local Codex closeout review skill and helper to <code>autoreview</code> while preserving the Codex-first fallback behavior.</li>
|
||||
<li>Skills: add a meme-maker skill for curated template search, local SVG/PNG rendering, Imgflip hosted rendering, and Know Your Meme provenance links.</li>
|
||||
<li>Skills CLI: allow <code>openclaw skills install</code> and <code>openclaw skills update</code> to target shared managed skills with <code>--global</code>. (#74466) Thanks @Marvae.</li>
|
||||
<li>Browser: surface pending and recently handled modal dialogs in snapshots, return <code>blockedByDialog</code> when an action opens a modal, and allow <code>browser dialog --dialog-id</code> to answer pending dialogs.</li>
|
||||
<li>Browser CLI: add <code>openclaw browser evaluate --timeout-ms</code> so long-running page functions can extend both the evaluate action and request timeout budgets. (#83447) Thanks @eefreenyc.</li>
|
||||
<li>Codex app-server: scope OpenClaw prompt guidance by runtime surface so native Codex keeps Codex-owned base/personality instructions while OpenClaw contributes only runtime context, delivery guidance, and explicitly scoped command hints. (#83454) Thanks @100yenadmin.</li>
|
||||
<li>Docker/Podman: add <code>OPENCLAW_IMAGE_PIP_PACKAGES</code> for opt-in Python package installation in local image builds. (#83771) Thanks @stephenredmond-straiteis.</li>
|
||||
<li>Agents/tools: shorten built-in tool descriptions and schema hints across media, messaging, sessions, cron, Gateway, web, image/PDF, TTS, nodes, and plan tools while preserving routing guardrails.</li>
|
||||
<li>Skills: add node inspector debugging, fused diagram generation, and throwaway spike workflow skills.</li>
|
||||
<li>CLI/plugins: add <code>defineToolPlugin</code> plus <code>openclaw plugins build</code>, <code>validate</code>, and <code>init</code> for typed simple tool plugins with generated manifest metadata, optional tool declarations, and context factories.</li>
|
||||
<li>Agents/skills: tighten bundled skill prompts and metadata, quote skill descriptions, refresh current CLI/API guidance, and update embedded sherpa-onnx runtime downloads.</li>
|
||||
<li>Skills: update the Obsidian skill to target the official <code>obsidian</code> CLI and require its registered binary instead of the third-party <code>obsidian-cli</code>.</li>
|
||||
<li>Skills: add a Python debugging skill for pdb, breakpoint(), post-mortem inspection, and debugpy remote attach.</li>
|
||||
<li>Codex: add <code>/codex plugins list</code>, <code>enable</code>, and <code>disable</code> for managing configured native Codex plugins from chat without editing config by hand.</li>
|
||||
<li>Plugins/messages: add presentation capability limits for channel renderers, adapt rich message controls before native rendering, and mark legacy <code>interactive</code>/Slack directive producer APIs as deprecated.</li>
|
||||
<li>Plugins/subagents: store channel delivery routes as canonical session metadata and deprecate ad hoc subagent hook delivery-origin fields in favor of core route projection.</li>
|
||||
<li>Proxy: support HTTPS managed forward-proxy endpoints and scoped <code>proxy.tls.caFile</code> CA trust for proxy endpoint TLS. (#79171) Thanks @jesse-merhi.</li>
|
||||
<li>QA-Lab: add first-hour 20-turn and optional 100-turn runtime parity scenarios, with tier metadata for standard and soak QA gates. Fixes #80338; refs #80337. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: add <code>openclaw qa suite --runtime-parity-tier</code> and wire the standard Codex-vs-Pi tier into release checks separately from optional/live-only/soak lanes. Fixes #80337. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: add a live-only Codex Pi-shaped Read vocabulary canary so runtime parity catches native workspace-read prompt compatibility drift. (#80323) Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: add live-only harness self-health scenarios for plugin hook crashes, manifest contract errors, and WebChat direct-reply self-message routing. (#80323) Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: add runtime tool fixture scenarios and coverage reporting for Codex-native workspace tools, OpenClaw dynamic tools, and optional plugin-backed tools. Fixes #80173. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: expose runtime tool fixture coverage through <code>openclaw qa coverage --tools</code>, with optional suite-summary evaluation for parity gate artifacts. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: schedule a live-frontier Codex-vs-Pi runtime token-efficiency artifact lane in the all-lanes QA workflow. Fixes #80175. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: hard-gate required OpenClaw dynamic runtime-tool drift in the standard Codex-vs-Pi tier with a blocking release-check verifier and publish the tool coverage report artifact. Fixes #80339; refs #80319. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: add the personal-agent approval-denial scenario so the benchmark pack verifies denied local reads stop cleanly without tool progress or fixture leaks. (#83150) Thanks @iFiras-Max1.</li>
|
||||
<li>QA-Lab: extend the personal-agent benchmark pack with a local task followthrough scenario for proof-backed pending, blocked, and done status reporting. Thanks @iFiras-Max1.</li>
|
||||
<li>QA-Lab: add a report-only dreaming shadow-trial scenario so candidate memory promotion can be evaluated without mutating <code>MEMORY.md</code>. Thanks @iFiras-Max1.</li>
|
||||
<li>Gateway/performance: add <code>pnpm test:restart:gateway</code> benchmark tooling for repeated restart readiness, downtime, trace, and resource-slope evidence. (#83299) Thanks @samzong.</li>
|
||||
<li>Android: switch Talk Mode to realtime Gateway relay voice sessions with streaming mic input, realtime audio playback, tool-result bridging, and on-screen transcripts. (#83130) Thanks @sliekens.</li>
|
||||
<li>Gateway/config: expose config lookup reload metadata so tools can distinguish restart-required, hot-reloadable, and no-op fields before applying config edits. Fixes #81409. (#81612) Thanks @LLagoon3.</li>
|
||||
<li>Telegram: add allowlisted native DM draft previews for transient tool progress while keeping final answers on the normal persistent delivery path. (#83622) Thanks @akrimm702.</li>
|
||||
<li>QA-Lab: add a personal-agent share-safe diagnostics artifact scenario so support handoffs keep useful status while omitting raw personal content. Thanks @iFiras-Max1.</li>
|
||||
<li>QA-Lab: add a personal-agent no-fake-progress scenario so completion claims stay tied to local evidence instead of unsupported external progress. (#83824) Thanks @iFiras-Max1.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>CLI: reject explicit port numbers above 65535 before they reach Gateway or Node bind paths. Fixes #83900. (#84008) Thanks @hclsys.</li>
|
||||
<li>Codex app-server: preserve plugin tool auth profiles when Codex owns model transport so OpenClaw dynamic tools can resolve their provider credentials. (#83603) Thanks @rubencu.</li>
|
||||
<li>Memory/search: scan the JS-side fallback vector path (used when the sqlite-vec index is unavailable or has a mismatched dimension) in bounded rowid batches and yield to the event loop between batches so large chunk tables can no longer pin the Node.js main thread for multi-second windows. Also keeps the SQL prepared statement rooted in a local so node:sqlite cannot finalize it mid-scan under heap pressure. Fixes #81172. Thanks @dev23xyz-oss.</li>
|
||||
<li>Memory Wiki: preserve fs-safe diagnostics when bridge source page writes fail for non-symlink filesystem safety reasons, so directory collisions are reported with the underlying error code. (#83776) Thanks @TurboTheTurtle.</li>
|
||||
<li>Telegram: keep forum topics from blocking sibling topic traffic by routing inbound serialization, media/text buffers, and account API queues on topic-aware lanes. (#83829)</li>
|
||||
<li>Telegram: keep queued forum-topic follow-up messages from inheriting superseded source abort signals, so later same-topic user turns can still run and reply after an active turn is replaced. (#83827) Thanks @VACInc.</li>
|
||||
<li>CLI/update: bypass npm freshness filters consistently during managed package and plugin installs so freshly published release plugins remain installable. Thanks @jalehman.</li>
|
||||
<li>CLI/update: guide root-owned npm install EACCES recovery by stopping the managed Gateway before manual package replacement, then reinstalling and restarting the service. Fixes #83747. (#83757) Thanks @brokemac79.</li>
|
||||
<li>Twitch: register refreshing chat tokens with Twurple's chat intent so automatic token refresh keeps chat access available. (#83750) Thanks @TurboTheTurtle.</li>
|
||||
<li>Agents/subagents: keep collect-mode announce queues batching unresolved-origin items with compatible same-route messages and resume collection after a true cross-channel drain when a later compatible batch remains. Fixes #83577.</li>
|
||||
<li>Skills: refresh existing session skill snapshots when watched skill roots change, so changed extra skill directories take effect without starting a new session. Fixes #83782. (#83800) Thanks @hclsys.</li>
|
||||
<li>Providers/Anthropic: preserve native image input for current Claude model rows when stale local catalog data marks them text-only. (#83756) Thanks @TurboTheTurtle.</li>
|
||||
<li>Providers/Anthropic: preserve Claude 4 image capability when configured model refs resolve through a stale local catalog row. (#83756) Thanks @TurboTheTurtle.</li>
|
||||
<li>Providers/DeepSeek: normalize MCP tool schemas with <code>anyOf</code>/<code>oneOf</code> unions before normal and compaction requests reach DeepSeek, preventing union-shaped parameters from being rejected. (#83766) Thanks @TurboTheTurtle.</li>
|
||||
<li>Control UI: render live tool progress from session-scoped <code>session.tool</code> Gateway events so externally started runs show their tool cards in the active session. (#83734) Thanks @TurboTheTurtle.</li>
|
||||
<li>Outbound: resolve send-capable channel plugins from the active runtime registry when the pinned startup registry only has setup metadata. (#83733) Thanks @TurboTheTurtle.</li>
|
||||
<li>Discord: preserve streamed reply previews when recovered tool-warning finals are delivered before or after the assistant's final reply. (#84169) Thanks @neeravmakwana.</li>
|
||||
<li>Control UI: keep the chat delete confirmation popover clamped inside the visible viewport on small screens. (#83804) Thanks @ThiagoCAltoe.</li>
|
||||
<li>Browser: enforce current-tab URL allowlist checks for <code>/act</code> evaluate/batch actions and <code>/highlight</code> routes while leaving tab-management actions unblocked. (#78523)</li>
|
||||
<li>CI: require real-behavior-proof verdict markers to come from the ClawSweeper GitHub App before accepting exact-head proof. (#83692)</li>
|
||||
<li>Models: show the effective OpenAI/Codex auth profile in <code>/models</code> provider headers instead of falling back to the OpenAI env-key label. (#83697) Thanks @yu-xin-c.</li>
|
||||
<li>CLI: include active bundled loopback MCP tools in CLI system prompts and reset provider-side CLI sessions when that prompt-visible tool surface changes. (#83785) Thanks @TurboTheTurtle.</li>
|
||||
<li>Browser: keep a profile <code>cdpPort</code> when its <code>cdpUrl</code> omits a port, while still letting explicitly written URL ports win. (#82166) Thanks @Marvae.</li>
|
||||
<li>Agents/image generation: allow distinct <code>image_generate</code> prompts to start separate session-backed background tasks while same-prompt retries still return the active task status. (#83614) Thanks @Elarwei001.</li>
|
||||
<li>Gateway/WebChat: honor configured <code>channels.webchat.textChunkLimit</code> and <code>chunkMode</code> overrides when chunking WebChat replies. (#83713)</li>
|
||||
<li>Control UI: stop the chat reading indicator from sticking after an assistant response finishes. (#83515) Thanks @njuboy11.</li>
|
||||
<li>Skills: reject empty or whitespace-only skill names and descriptions during quick validation. (#27061)</li>
|
||||
<li>Sessions: skip trailing custom transcript entries when checking tail assistant replies so embedded CLI gap-fill does not duplicate canonical assistant output. (#83635) Thanks @yaoyi1222.</li>
|
||||
<li>Memory Wiki: keep <code>wiki_lint</code> tool output path-safe by reporting vault-internal lint reports as relative paths in tool text and details while preserving absolute report paths for CLI/file callers. (#83439) Thanks @LLagoon3.</li>
|
||||
<li>Telegram: keep verbose tool progress visible without mirroring non-final progress into active session transcripts, preventing embedded provider replies from aborting mid-run. (#83631) Thanks @kurplunkin.</li>
|
||||
<li>Telegram: log successful outbound text and media deliveries with account, chat, message, operation, thread, reply, silent, and chunk metadata while keeping message bodies out of logs. Fixes #83196. (#83247) Thanks @jrwrest.</li>
|
||||
<li>Cron: link isolated scheduled task runs to their stable cron session so task status and cleanup can follow the backing agent run. (#83606) Thanks @jai.</li>
|
||||
<li>Codex app-server: mark Codex-native subagent task mirrors terminal when blocked or failed spawn-agent calls arrive with stale initializing child state, preventing task registry entries from staying running. Fixes #83852. (#83945) Thanks @joshavant.</li>
|
||||
<li>CLI: enforce the documented Node.js 22.19 runtime floor in the source launcher.</li>
|
||||
<li>Release stability: repair broad-gate regressions in requester-agent completion handoff, QA-Lab mock spawn attribution, Slack monitor test isolation, plugin uninstall peer fixtures, and Node-floor launcher contract coverage.</li>
|
||||
<li>Agents/replies: persist queued follow-up user messages and assistant error stubs only once across model-fallback retries, preventing repeated provider rejections from corrupted same-role session transcripts. Fixes #83404. (#83417) Thanks @yetval.</li>
|
||||
<li>Telegram: preserve reply-target context for bare mention replies on runtime-only turns so the model sees the replied-to message body. Fixes #83767. (#83953) Thanks @joshavant.</li>
|
||||
<li>ClawHub: preserve configured base URL path prefixes when building API request URLs, so self-hosted ClawHub instances mounted under a subpath keep routing correctly. (#83982) Thanks @ThiagoCAltoe.</li>
|
||||
<li>Slack: persist delivered inbound message IDs and fail closed when same-channel thread replies lose their thread context, preventing delayed duplicate replies and accidental channel-root posts. Fixes #83521. Thanks @shannon0430.</li>
|
||||
<li>Codex app-server: complete OpenClaw dynamic tool diagnostics at the request boundary so successful, failed, timed out, aborted, and blocked tool calls do not leave active tool state behind. Fixes #83474. Thanks @rozmiarD.</li>
|
||||
<li>Gateway/config: keep config writes from failing on unrelated unresolved auth-profile SecretRefs while preserving live auth-profile runtime snapshots.</li>
|
||||
<li>Gateway/sessions: clear stored CLI provider resume bindings on non-subagent <code>/reset</code> so the next turn starts a fresh provider-side CLI conversation instead of resuming old context. (#83448) Thanks @jasonyliu.</li>
|
||||
<li>Doctor: preserve legacy whole-agent Claude CLI intent by moving matching Anthropic model selections to model-scoped runtime policy before removing stale runtime pins. Fixes #83491. Thanks @danielcrick.</li>
|
||||
<li>Discord/OpenAI: keep realtime Discord voice sessions hearing follow-up turns with OpenAI realtime and prebuffer assistant playback to avoid choppy starts. (#80505) Thanks @Solvely-Colin.</li>
|
||||
<li>LM Studio: resolve env-template API keys like <code>${LMSTUDIO_API_KEY}</code> through the standard SecretInput path instead of sending the raw template as the bearer token, and preserve header-auth and discovery-key precedence when the template is unset. Fixes #80495. (#80568) Thanks @MonkeyLeeT.</li>
|
||||
<li>Discord/subagents: route the initial reply from thread-bound delegated sessions into the bound Discord thread instead of the parent channel. Fixes #83170. (#83172) Thanks @100menotu001.</li>
|
||||
<li>Gateway/sessions: rotate failed agent sessions when their transcript file is missing instead of wedging per-channel lanes. Fixes #83488. (#83553) Thanks @LLagoon3.</li>
|
||||
<li>Agents: refresh final-delivery routing from fresh session state before declaring a no-send failure, keeping recovered runs on the normal durable delivery path. (#83835) Thanks @joshavant.</li>
|
||||
<li>Agents: guard final-delivery fresh session routing against mismatched logical sessions before reusing recovered delivery context. (#83928) Thanks @joshavant.</li>
|
||||
<li>Media: prevent image metadata probing from invoking external decoder delegates on unrecognized image bytes, and stop fallback chaining after real processing errors.</li>
|
||||
<li>Media: install Sharp with the root package and fall back to sips, Windows native imaging, ImageMagick, GraphicsMagick, or ffmpeg for image resizing/conversion when Sharp is unavailable. Fixes #83401. Thanks @scotthuang.</li>
|
||||
<li>Telegram: deliver generated media completions back into forum topics by preserving topic IDs across requester-agent handoff. (#83556) Thanks @fuller-stack-dev.</li>
|
||||
<li>Gateway: defer update-check startup until after readiness so package update checks no longer block sidecar-ready startup, while preserving update broadcasts and shutdown cleanup. (#83520) Thanks @samzong.</li>
|
||||
<li>Telegram: keep <code>/btw</code> and read-only status commands from aborting active runs, and avoid retaining raw update payloads in timed-out spool tombstones. Refs #83272.</li>
|
||||
<li>Agents: log strict-agentic execution contract diagnostics only when the planning-only retry path actually triggers.</li>
|
||||
<li>Agents: stop embedded session takeover and session write-lock errors from consuming model fallbacks while preserving provider fallback metadata. Fixes #83510. Thanks @luyao618.</li>
|
||||
<li>Agents/video: hide <code>video_generate</code> reference-audio parameters unless a registered video provider supports audio inputs.</li>
|
||||
<li>Plugins: fall back to npm for official ClawHub updates when artifact downloads are unavailable, including beta-to-default fallback and dry-run version reporting.</li>
|
||||
<li>Plugins/xAI: echo PKCE challenge fields during OAuth authorization-code token exchange for xAI token-endpoint compatibility. (#83499) Thanks @fuller-stack-dev.</li>
|
||||
<li>Codex app-server: hydrate current inbound image attachments before queued runs so Responses-backed agents receive Discord and other channel images as native vision input. Fixes #83466. Thanks @iannwu.</li>
|
||||
<li>Codex app-server: keep native code mode available without forcing code-mode-only so OpenClaw dynamic tool turns complete through the app-server tool bridge. Fixes #83109. Thanks @daswass.</li>
|
||||
<li>Codex app-server: expose OpenClaw's sandbox-routed shell as <code>sandbox_exec</code>/<code>sandbox_process</code> for non-Docker sandbox backends so SSH sandbox agents keep a correctly routed shell path without shadowing Codex native shell. Fixes #80322. Thanks @keramblock.</li>
|
||||
<li>Release stability: recover stale session diagnostics and Codex OAuth fallback state so stuck runs and reused refresh tokens clear without blocking follow-up work. (#83503) Thanks @100yenadmin.</li>
|
||||
<li>Messages/TTS: apply TTS directives before message-tool sends reach core, gateway, or plugin delivery so opt-in message-tool rooms and proactive sends attach voice notes instead of leaking raw tags. Fixes #81598. Thanks @CG-Intelligence-Agent-Jack and @CoronovirusG10.</li>
|
||||
<li>Messages/Codex: keep Codex direct/source chats on message-tool visible delivery by default while documenting and testing <code>messages.visibleReplies: "automatic"</code> as the old-mode opt-out; channel wildcard model overrides now apply to direct chats before harness delivery defaults.</li>
|
||||
<li>Memory/QMD: keep archived session transcript hits visible after QMD export while preserving normal <code>.md</code> session ids that only resemble archive names. (#83518; fixes #83506) Thanks @tanshanshan.</li>
|
||||
<li>Codex app-server: preserve network access for sandboxed Codex code-mode turns when the OpenClaw sandbox allows outbound egress. Fixes #83347. Thanks @YusukeIt0.</li>
|
||||
<li>Codex app-server: honor writable Docker bind mounts for sandboxed workspace-write turns while disabling native Code Mode when container-path aliases or read-only bind shadows cannot be represented safely host-side. Fixes #83737. (#83849) Thanks @joshavant.</li>
|
||||
<li>QA-Lab: keep the OTLP smoke decoder independent of removed OpenTelemetry generated-root internals.</li>
|
||||
<li>Messages: default group/channel visible replies to automatic final delivery again, keeping <code>message_tool</code> opt-in for ambient/shared rooms and tool-reliable models.</li>
|
||||
<li>CLI/TUI: force standalone <code>/exit</code> runs to terminate after <code>runTui</code> returns so onboarding-launched TUI children do not stay alive invisibly. (#83501) Thanks @fuller-stack-dev.</li>
|
||||
<li>Agents/code mode: honor per-agent code-mode config in schema, runtime catalog activation, and model payload filtering. Fixes #83388. Thanks @Kaspre.</li>
|
||||
<li>Agents/code mode: preserve agent, session, run, and channel context in <code>before_tool_call</code> hooks for top-level <code>exec</code>/<code>wait</code> dispatches. Fixes #83387.</li>
|
||||
<li>QQBot: shorten C2C typing indicators to a 10-second window renewed every 5 seconds, capped to keep a final passive-reply slot available. (#83469)</li>
|
||||
<li>Replies: keep final payload delivery after live preview updates so channels can finalize or send the completed answer instead of losing preview-only drafts. (#83468)</li>
|
||||
<li>Discord: deliver final replies in progress-mode preview streams instead of deduplicating the final visible message. (#83443) Thanks @compoodment.</li>
|
||||
<li>Providers/Xiaomi: replay MiMo Anthropic-compatible <code>reasoning_content</code> as provider-required thinking blocks even when OpenClaw thinking is disabled, fixing follow-up tool turns for <code>mimo-v2-flash</code>. Fixes #83407. Thanks @Xgenious7.</li>
|
||||
<li>Agents/exec approvals: forward approval-runtime credentials on agent-owned Gateway approval calls so approved async commands complete through the existing runtime path instead of stalling on unauthenticated follow-up calls. Thanks @IWhatsskill, @Patrick-Erichsen, and @jesse-merhi.</li>
|
||||
<li>Gateway/skills: preflight remote macOS skill-bin refreshes with a WebSocket connectivity check so stale node sessions skip quickly instead of logging slow <code>system.which</code> timeout warnings.</li>
|
||||
<li>CLI/config: keep broken discovered plugins that are not referenced by active config from failing <code>openclaw config validate</code>, while preserving fatal errors for explicitly configured plugin entries.</li>
|
||||
<li>GitHub Copilot: drop unsafe native Responses reasoning replay items with non-replayable IDs before dispatch, preventing affected Copilot sessions from failing with <code>invalid_request_body</code>. Fixes #83220. Thanks @galiniliev.</li>
|
||||
<li>Agents/Codex: fail closed when an explicitly requested Codex harness is not registered instead of silently trying configured model fallbacks. Fixes #83349. Thanks @r2-vibes.</li>
|
||||
<li>QA-Lab: make runtime tool coverage fail on missing required tool exercise instead of treating pass/pass parity envelope drift as missing coverage.</li>
|
||||
<li>Core/plugins: harden clawpatch-reported edge cases across gateway auth cleanup, Claude session id paths, plugin activation policy, apply-patch hunk handling, diagnostic redaction, and plugin metadata validation.</li>
|
||||
<li>UI: show reasoning choices as plain labels instead of leaking internal override wording in session and chat pickers.</li>
|
||||
<li>Mac app: avoid repeating the Configuration heading inside channel quick settings.</li>
|
||||
<li>Mac app: keep the Settings sidebar always visible and remove the redundant titlebar hide/show control.</li>
|
||||
<li>Mac app: normalize Settings pane content margins so pages share the same left and right rail.</li>
|
||||
<li>Mac app: prefer explicit private/Tailscale/LAN Gateway endpoints over SSH tunnels, preserve legacy loopback tunnel configs, persist transport choices, and show captured SSH stderr when tunneling really fails.</li>
|
||||
<li>Gateway/sessions: keep ACP/acpx and runtime child sessions visible in configured-only session lists when their owner or parent session belongs to a configured agent.</li>
|
||||
<li>Mac app: keep app-level menu commands and Dashboard failure states reachable when the remote Gateway is disconnected.</li>
|
||||
<li>Mac app: allow longer Gateway and Context errors to wrap in the menu instead of truncating the useful failure detail.</li>
|
||||
<li>Mac app: tighten remote Gateway fields in Settings so the Connection pane keeps readable labels and full action button text.</li>
|
||||
<li>Mac app: keep custom Settings card rows left-aligned and full-width so Discovery and status sections no longer appear centered or detached.</li>
|
||||
<li>Mac app: align Location permission controls to the same trailing column as the rest of Settings.</li>
|
||||
<li>Mac app: add Dashboard, Chat, Canvas, and Settings shortcuts to the Dock icon menu.</li>
|
||||
<li>Mac app: replace the Settings window's native split-view sidebar with an explicit layout so page content keeps its leading gutter when the sidebar is shown or hidden.</li>
|
||||
<li>Mac app: render channel quick config as aligned Settings rows and hide schema-only variants that cannot be edited safely from the quick pane.</li>
|
||||
<li>Gateway/webchat: hide internal runtime-context and other <code>display: false</code> transcript messages from Chat history and live message events. Fixes #83216. Thanks @EmpireCreator.</li>
|
||||
<li>CLI/help: keep <code>gateway</code>, <code>doctor</code>, <code>status</code>, and <code>health</code> help registration out of action/runtime imports so subcommand <code>--help</code> stays lightweight in constrained terminals. Fixes #83228. Thanks @dfguerrerom.</li>
|
||||
<li>CLI/help: show plugin-owned command help based on the active memory slot so LanceDB memory users see <code>ltm</code> instead of unavailable <code>memory</code> commands. Fixes #83745. (#83841) Thanks @joshavant.</li>
|
||||
<li>Cron/Discord: keep explicit announce runs in message-tool-only source-reply mode so scheduled agent turns post once instead of also echoing through automatic visible replies. Fixes #83261. Thanks @Theralley.</li>
|
||||
<li>Telegram: preserve forum-topic origin targets in inbound, audio-preflight, and skipped-message hook contexts so follow-up delivery stays bound to the originating topic. Fixes #83302. Thanks @M00zyx.</li>
|
||||
<li>Telegram: retry HTTP 421 Misdirected Request send failures on a fresh fallback transport so transient edge-node routing errors no longer drop outbound replies. Fixes #48892. (#48908) Thanks @MarsDoge.</li>
|
||||
<li>Telegram: fail topic sends closed when Telegram reports <code>message thread not found</code> instead of retrying without <code>message_thread_id</code> into the base chat. Refs #83302.</li>
|
||||
<li>Config/subagents: remove ignored agent-model <code>timeoutMs</code> keys, keep subagent model config to primary/fallback selection, and clean shipped stale config through doctor. Fixes #83291. Thanks @giodl73-repo.</li>
|
||||
<li>Mac app: align the Sessions settings pane with the standard Settings page gutter and row spacing.</li>
|
||||
<li>OpenAI/Codex: stop rejecting available <code>openai-codex</code> GPT-5.1, GPT-5.2, and GPT-5.3 model refs during config validation, while keeping removed Spark aliases suppressed. Fixes #83303.</li>
|
||||
<li>Plugins/xAI: complete OAuth-backed xAI login and sidecar auth fixes, including guarded loopback callback CORS handling, video generation polling/defaults, and native-host User-Agent attribution. (#83322) Thanks @Jaaneek.</li>
|
||||
<li>Codex app-server: preserve streamed native command output in mirrored transcripts and trajectory exports when final snapshots omit aggregated output. (#83200) Thanks @rozmiarD.</li>
|
||||
<li>Codex app-server: fail closed when chat or sender policy denies tools, disabling native code, app, environment, and user MCP surfaces for restricted turns. (#82374) Thanks @VACInc.</li>
|
||||
<li>Codex app-server: keep recent context-engine messages when oversized projected history is truncated, so short follow-ups in long channel sessions do not fall back to stale earlier turns. (#83127) Thanks @VACInc.</li>
|
||||
<li>Codex app-server: keep OpenClaw session spawning searchable while steering Codex-native delegation through native subagents, avoiding duplicate direct subagent surfaces. (#83329) Thanks @fuller-stack-dev.</li>
|
||||
<li>Codex app-server: recover stale childless Codex-native subagent task mirrors during maintenance and allow their registry rows to be cancelled without an OpenClaw child session. (#82836) Thanks @yshimadahrs-ship-it and @joshavant.</li>
|
||||
<li>Feishu: return bound subagent delivery origins from session thread setup so Feishu subagent completions route back to the same DM or topic. (#83190) Thanks @100menotu001.</li>
|
||||
<li>CLI/update: tailor post-update Gateway recovery hints by platform, showing systemd, LaunchAgent, Scheduled Task, or generic service-manager guidance instead of macOS-only recovery text. (#83096) Thanks @rubencu.</li>
|
||||
<li>Plugins: apply a default 15-second timeout to legacy <code>before_agent_start</code> hooks so hung plugin handlers no longer block agent startup. Fixes #48534. (#83136) Thanks @therahul-yo.</li>
|
||||
<li>Feishu: refresh inbound session delivery context for DM, group, and broadcast turns so later replies do not inherit stale WebChat routing. Fixes #78274.</li>
|
||||
<li>Agents/subagents: require the initial subagent registry save before reporting spawn accepted, returning a spawn error instead of losing an untracked run when the registry write fails. (#83146) Thanks @yetval.</li>
|
||||
<li>QA-Lab/qa-channel: attach redacted agent tool-start traces to outbound <code>QaBusMessage</code> records so scenarios can assert actual tool use instead of relying only on reply text. Fixes #67637. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: fail live runtime parity reports when assistant-message usage is missing, preventing <code>0 vs 0</code> live token rows from being reported as passing proof. Fixes #80411. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: add a runtime token-efficiency sidecar report that classifies Codex savings separately from regressions and fails only positive Codex-over-Pi live token deltas above threshold. Fixes #81093. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: fail Codex-backed OpenAI live runtime-pair runs before launching isolated workers when no portable Codex auth is available, while staging API-key fallbacks and configured Codex keys for isolated QA agents. Fixes #80412. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: refresh parity gates, mock frontier fixtures, model scenarios, and workflow artifact lanes to compare GPT-5.5 against Claude Opus 4.7. Fixes #74262. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: make mock parity dispatch provider-aware for source discovery and subagent scenarios so OpenAI and Anthropic lanes no longer share identical canned plans. Fixes #64879. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: stop returning Control UI bearer tokens from unauthenticated bootstrap payloads and bind Docker harness ports to loopback-only host addresses. (#66355) Thanks @pgondhi987.</li>
|
||||
<li>Mac app: avoid a SwiftUI metadata crash when rendering the Cron Jobs settings pane.</li>
|
||||
<li>Agents/subagents: preserve run-mode keep subagent registry entries past the session sweep TTL, so kept subagent runs remain visible after cleanup completes. Fixes #83132. (#83168) Thanks @yetval.</li>
|
||||
<li>Agents/OpenAI streams: yield via <code>setTimeout(0)</code> instead of <code>setImmediate</code> between bursty Responses chunks so abort timers can fire during the yield, keeping cancel-on-timeout responsive on hot streams. Refs #82462.</li>
|
||||
<li>Agents/Codex: keep legacy <code>oauthRef</code>-backed OAuth profiles usable while <code>openclaw doctor --fix</code> migrates them back to inline credentials, without creating new sidecar credentials. (#83312) Thanks @joshavant.</li>
|
||||
<li>Agents/Codex: load the selected provider owner alongside the Codex harness runtime so <code>openai-codex</code> models resolve when plugin allowlists scope runtime loading. Fixes #83380. (#83519) Thanks @joshavant.</li>
|
||||
<li>Telegram: fail stalled isolated-ingress handlers into tombstones and abort same-lane reply work before restarting, so later same-chat updates drain after a hung turn. Fixes #83272. (#83505) Thanks @joshavant.</li>
|
||||
<li>CLI/config: send SecretRef diagnostics to stderr so JSON command stdout remains parseable.</li>
|
||||
<li>CLI/doctor: seed Control UI allowed origins when migrating legacy non-loopback gateway bind host aliases like <code>0.0.0.0</code>. Fixes #83286. Thanks @giodl73-repo.</li>
|
||||
<li>CLI/plugins: ship the bundled memory CLI as a package entry so package-installed <code>openclaw memory</code> commands register correctly.</li>
|
||||
<li>CLI/update: defer doctor-time plugin package installs during package swaps and seed post-core repair from the updated install registry, preventing duplicate reinstall failures.</li>
|
||||
<li>CLI/update: preserve old-parent-readable config metadata during legacy package handoffs, fall back only to official <code>@openclaw/*</code> npm plugin packages when ClawHub plugin artifacts are unavailable, and keep managed service package roots authoritative during updates.</li>
|
||||
<li>Feishu: detect SecretRef top-level credentials as a configured default account instead of treating object-backed app secrets as missing.</li>
|
||||
<li>Gateway/restart: keep ordinary unmanaged SIGUSR1/config restarts in-process instead of detach-spawning an orphaned child, preserving custom supervisor PID tracking while leaving update restarts on the fresh-process path. Fixes #65668.</li>
|
||||
<li>CLI/completion: resolve concrete PowerShell profile paths and reload commands during setup and doctor completion installation. Fixes #44296. (#83059) Thanks @yu-xin-c.</li>
|
||||
<li>Telegram: keep isolated long polling below the hard <code>getUpdates</code> request guard so idle bot accounts with high <code>timeoutSeconds</code> do not false-disconnect and restart-loop. Fixes #83264. Thanks @riccodecarvalho.</li>
|
||||
<li>Providers/Google: preserve and recover Gemini 3 tool-call thought signatures during native replay so function-calling turns no longer fail with missing <code>thought_signature</code> 400s. Fixes #72879. (#80358) Thanks @abnershang.</li>
|
||||
<li>Telegram: skip transcript-only delivery mirrors and gateway-injected rows when resolving latest assistant text, preventing retained previews from replacing final replies with stale fragments. Fixes #83159. (#83362) Thanks @joshavant.</li>
|
||||
<li>Memory/QMD: keep lexical search on raw hyphenated queries while normalizing semantic QMD sub-searches, avoiding fallback to the builtin index for dashed identifiers and dates. Fixes #81328.</li>
|
||||
<li>Memory-core: distinguish sqlite-vec load failures from missing semantic vector embeddings in degraded <code>memory index</code> warnings, so vector recall diagnostics point at unresolved dimensions instead of blaming sqlite-vec when the store is ready. Fixes #75624. (#83056) Thanks @xuruiray and @Noah3521.</li>
|
||||
<li>Agents/subagents: preserve sandbox-peer controller ownership while routing completion announcements back to the originating run session, keeping subagent control and completion delivery scoped correctly. Fixes #80201. (#80242) Thanks @Jerry-Xin.</li>
|
||||
<li>Gateway: continue restarting remaining channels when one hot-reload channel restart fails, while still reporting aggregate reload failure and rolling back plugin pre-replace stops. Fixes #83054. Thanks @zqchris.</li>
|
||||
<li>Gateway/plugins: bind admin HTTP RPC dispatch to the accepting gateway instance so multi-gateway processes cannot execute plugin HTTP control-plane calls against another live gateway. Fixes #83486. (#83487) Thanks @coygeek.</li>
|
||||
<li>Telegram: keep hot-reload restarts from marking polling accounts manually stopped and restart isolated ingress cleanly after worker shutdown, preserving Telegram replies across config reloads. Fixes #83008. (#83410) Thanks @joshavant.</li>
|
||||
<li>Telegram/Ollama: pass current Telegram image attachments into native PI/Ollama vision turns so live photo prompts reach Ollama as native images. Fixes #83023. (#83516) Thanks @joshavant.</li>
|
||||
<li>Gateway/secrets: split the lightweight secrets runtime state and auth-store cache from the full secrets runtime and take a startup fast path when the gateway startup config has no SecretRef values, speeding up secrets startup while preserving cleanup and refresh semantics.</li>
|
||||
<li>Codex app-server: rotate oversized native Codex threads before resume and cap dynamic tool-result text entering native Codex sessions, preventing stale oversized context from surviving OpenClaw compaction. (#82981) Thanks @hansolo949.</li>
|
||||
<li>Gateway/restart: drain pending replies and active chat runs during restart shutdown before sockets and channels close, aborting timed-out chat runs through the normal cleanup path. (#69121) Thanks @alexlomt.</li>
|
||||
<li>Agents/Codex: use the Codex runtime context window for OpenAI-model preflight compaction and memory flush checks, so GPT-5.5 Codex sessions compact before hitting the smaller native context limit. Fixes #82982. Thanks @vliuyt.</li>
|
||||
<li>QA-Lab: clean orphaned gateway temp roots when a suite parent exits and wait on gateway plus transport readiness after config restarts, reducing stale <code>qa-channel</code> noise from interrupted runs. Fixes #65506. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: wake qa-bus long polls that arrive with stale future cursors after a bus restart, preserving reconnect readiness for harness clients. (#67142) Thanks @hxy91819.</li>
|
||||
<li>QA-Lab: stage Multipass transfer scripts under OpenClaw's preferred temp root instead of raw OS temp paths, keeping the VM runner inside temp-path guardrails. (#64098) Thanks @ImLukeF.</li>
|
||||
<li>Agents/replies: keep surviving reply media and append a warning when other media references fail, so partial media normalization no longer drops failures silently. Thanks @Jerry-Xin.</li>
|
||||
<li>Config/models: accept <code>thinkingFormat: "together"</code> in model compat config so Together routes can opt into the Together-specific thinking response shape.</li>
|
||||
<li>Plugins/tokenjuice: bump the bundled tokenjuice runtime to 0.7.1, bringing Codex hook approval compatibility, pre-tool command wrapping fixes, and Rolldown/Vitest output compaction improvements into the OpenClaw plugin.</li>
|
||||
<li>Agents/OpenAI: stop post-processing GPT-5 final replies with hardcoded brevity caps, preserving full channel responses instead of appending synthetic ellipses, and log when strict-agentic GPT-5 execution activates. Fixes #82910.</li>
|
||||
<li>Mac app: refine the Settings General and Connection panes with cleaner status panels, card rows, and a single native titlebar sidebar toggle.</li>
|
||||
<li>Agents/media: deliver failed async image, music, and video generation completions directly when requester-session completion handoff fails, so channel users see provider errors instead of silent fallback stalls.</li>
|
||||
<li>Browser/CDP: keep loopback proxy bypass active across both <code>NO_PROXY</code> casings and redact home-relative Chrome MCP profile paths in attach-failure diagnostics.</li>
|
||||
<li>Agents/music: steer song, jingle, beat, anthem, and instrumental requests toward <code>music_generate</code> audio creation instead of lyric-only replies, and reserve <code>lyrics</code> for exact sung words.</li>
|
||||
<li>Codex app-server: record native Codex tool calls and results into trajectory artifacts so debug/trajectory exports capture the full Codex-native tool history, not just OpenClaw-bridged turns. Thanks @vyctorbrzezowski.</li>
|
||||
<li>Codex/app-server: keep bound conversation sessions on the owning agent runtime so native Codex control and follow-up turns do not fall back to the default agent client. Fixes #82954. (#82993)</li>
|
||||
<li>CLI/infer: run gateway model probes in fresh explicit sessions so one-shot provider checks do not inherit default agent transcript state. (#82861) Thanks @Kaspre.</li>
|
||||
<li>Providers/Together: send video-generation requests to Together's v2 video API even when shared text-model config still points at the v1 base URL. (#82992)</li>
|
||||
<li>Browser CLI: preserve browser-level options on nested commands, skip option values during lazy command registration, and keep long-running wait/download/dialog hooks open for their advertised wait window.</li>
|
||||
<li>CLI/sessions: accept <code>openclaw sessions list</code> as an alias for <code>openclaw sessions</code>, matching other list-style commands. Fixes #81139. (#81163) Thanks @YB0y.</li>
|
||||
<li>Channels/stream previews: widen compact progress draft lines and cut prose at word boundaries while preserving command/path suffixes, with <code>streaming.progress.maxLineChars</code> for channel-specific tuning.</li>
|
||||
<li>CLI/plugins: have <code>openclaw plugins doctor</code> warn when a configured runtime needs a missing owner plugin, sharing the same install mapping as <code>openclaw doctor --fix</code>. Fixes #81326. (#81674) Thanks @Zavianx.</li>
|
||||
<li>Agents/Codex: route OpenAI runs that resolve to <code>openai-codex</code> through the Codex provider and bootstrap OpenClaw's stored OAuth profile into the Codex harness when the harness owns transport, so <code>openai/*</code> model refs no longer fail with <code>No API key found for openai-codex</code> despite an existing Codex OAuth profile. (#82864) Thanks @ragesaq.</li>
|
||||
<li>Agents/ACP: distinguish prompt-submitted and runtime-active child stalls from true interactive waits, including redacted proxy-env diagnostics for Codex ACP no-output runs. Fixes #44810.</li>
|
||||
<li>Agents/memory: explain that memory-triggered compaction exposes only <code>read</code> and append-only <code>write</code> when configured core tools are unavailable in <code>tools.allow</code> warnings. Fixes #82941. Thanks @galiniliev.</li>
|
||||
<li>Agents/OpenAI: preserve deterministic tool payload ordering for prompt-cache reuse across OpenAI Responses and chat completions calls. (#82940) Thanks @galiniliev.</li>
|
||||
<li>ACP/Codex: honor terminal ACP turn results so failed Codex/acpx runs are not recorded as successful after only progress text. Fixes #79522. Thanks @dudaefj.</li>
|
||||
<li>Telegram: warn when a media group drops photos that fail to download, including albums where every photo is skipped. Fixes #55216. (#82987) Thanks @eldar702.</li>
|
||||
<li>Agents/diagnostics: treat repeated same-handle embedded-run cleanup as idempotent while preserving true replacement-handle mismatch diagnostics. Fixes #82959. (#82960) Thanks @galiniliev.</li>
|
||||
<li>Agents/subagents: preserve high-priority <code>AGENTS.md</code> policy in bootstrap context when oversized files are trimmed, and warn agents to read the full policy file before relying on scoped rules. Fixes #82920. (#82921) Thanks @galiniliev.</li>
|
||||
<li>Agents/skills: apply the full effective tool policy pipeline to inline <code>command-dispatch: tool</code> skill dispatch before owner-only filtering, preserving configured allow, deny, sandbox, sender, group, and subagent restrictions. (#78525)</li>
|
||||
<li>Codex: avoid spawning native hook relay subprocesses for post-tool/finalize events with no registered hook handlers while preserving pre-tool safety and approval relays. Fixes #76552. (#78004) Thanks @evgyur.</li>
|
||||
<li>Channel accounts: keep top-level default channel accounts visible when named accounts are added alongside default credential material, so mixed legacy/new account configs keep resolving <code>default</code> instead of silently dropping it.</li>
|
||||
<li>Agents/CLI: reject empty successful CLI subprocess replies as <code>empty_response</code> and keep them out of shared auth-profile health, so blank Claude CLI results no longer become green no-payload turns. Fixes #83231. (#83421) Thanks @joshavant.</li>
|
||||
<li>Codex/Telegram: synthesize native Codex tool progress from final turn snapshots so Telegram <code>/verbose</code> stays visible when command events arrive only at completion.</li>
|
||||
<li>Codex/Telegram: deliver Codex verbose tool summaries in direct message-tool-only turns while suppressing message-send and activity-log noise. (#83186) Thanks @kurplunkin.</li>
|
||||
<li>Mac app: make Channels settings open faster by deferring config-schema work, avoiding startup channel probes, caching decoded channel status rows, and showing only compact quick settings instead of the full generated channel schema.</li>
|
||||
<li>Control UI: include the Control UI and Gateway protocol versions in protocol-mismatch errors so stale app/dashboard pairings identify which side needs rebuilding or restarting.</li>
|
||||
<li>Gateway/protocol: restore Gateway WS protocol v4 and keep <code>message.action</code> room-event metadata on the existing <code>inboundTurnKind</code> wire field while preserving internal inbound-event classification.</li>
|
||||
<li>Agents/tools: prefer non-webchat session-key routes when the message tool has stale webchat context, so message-tool-only replies keep delivering to the originating channel. Fixes #82911. (#83004) Thanks @joshavant.</li>
|
||||
<li>Channels: keep direct-message last-route writes on isolated <code>per-channel-peer</code> sessions instead of contaminating the agent main session with channel delivery context. Fixes #36614. Thanks @aspenas.</li>
|
||||
<li>Mac app: move the Settings sidebar toggle into the native titlebar and tighten the General pane width.</li>
|
||||
<li>Mac app: keep visited Settings panes mounted so switching tabs no longer blanks and reloads their content.</li>
|
||||
<li>Mac app: make Config settings open from shallow schema lookups and load selected paths on demand instead of fetching and rendering the full generated config schema up front.</li>
|
||||
<li>Codex: sanitize inline image payloads before Codex app-server and OpenAI Responses replay, and clear poisoned Codex thread bindings after invalid image errors. Fixes #82878.</li>
|
||||
<li>Providers/GitHub Copilot: request identity-encoded Copilot API responses across token exchange, catalog, model calls, usage, and embeddings so compressed Business-account error payloads no longer reach JSON parsers as gzip bytes. Fixes #82871. Thanks @tonyfe01.</li>
|
||||
<li>Telegram: redact nested raw-update identifiers and user metadata before verbose raw update logging, preserving useful update/message ids without exposing chat, user, command, or profile details. (#82945) Thanks @galiniliev and @joshavant.</li>
|
||||
<li>Telegram: preserve replied-to bot messages, captions, and media metadata in group reply chains so follow-up replies understand what the user is reacting to. (#82863)</li>
|
||||
<li>Providers/Together: update PI runtime packages to 0.74.1 and emit Together-style <code>reasoning.enabled</code>/<code>max_tokens</code> controls for reasoning-capable OpenAI-completions models.</li>
|
||||
<li>Agents/diagnostics: split slow embedded-run <code>attempt-dispatch</code> startup summaries into workspace, prompt, runtime-plan, and final dispatch subspans so traces identify the delayed setup phase. Fixes #82782. (#82783) Thanks @galiniliev.</li>
|
||||
<li>Agents/Codex: flatten nested tool-result middleware blocks into bounded text so successful message sends are no longer replaced with <code>Tool output unavailable due to post-processing error</code>. Fixes #82912. Thanks @joeykrug.</li>
|
||||
<li>CLI/media: accept HTTP(S) URLs in <code>openclaw infer image describe --file</code>, fetching remote images through the guarded media path instead of treating URLs as local files. Fixes #82837. (#82854) Thanks @neeravmakwana.</li>
|
||||
<li>Agents/subagents: keep session-backed parent runs active when the child wait call times out before the child session has actually settled, so late subagent completions are reconciled instead of being lost. Fixes #82787. Thanks @ramitrkar-hash.</li>
|
||||
<li>Control UI: advertise shared Gateway protocol constants in browser connect frames, fixing protocol mismatch handshakes after protocol constant drift. Fixes #82882. Thanks @galiniliev.</li>
|
||||
<li>Gateway: add rollback protocol-mismatch diagnostics, including client protocol ranges in Gateway logs and deep status/doctor hints for stale client processes. Fixes #82841. (#82908)</li>
|
||||
<li>Agents/subagents: keep successful keep-mode completion payloads pending after final-delivery retry exhaustion, so requester recovery no longer loses final subagent results. Fixes #82583. (#82999) Thanks @joshavant.</li>
|
||||
<li>Gateway/auth: allow same-host trusted-proxy callers to use the documented local direct <code>gateway.auth.password</code> fallback after revisiting the #78684 fail-closed policy, while keeping token fallback rejected and forwarded-header requests on the trusted-proxy path. Fixes #82607. (#82953) Thanks @joshavant.</li>
|
||||
<li>Agents/subagents: wait for queued completion handoffs to reach the parent transcript before marking them announced, preventing busy parent runs from cleaning up before observing child results. Fixes #82913. (#83039) Thanks @joshavant.</li>
|
||||
<li>Agents/subagents: route group/channel subagent completions through message-tool-only handoffs when required and keep active-requester wake failures from dropping completion delivery. Fixes #82803. Thanks @galiniliev, @yozakura-ava, and @moeedahmed.</li>
|
||||
<li>Memory-core: scan persisted memory source sessions on startup, comparing on-disk transcripts against the index and marking only missing/newer/resized files dirty for incremental sync. Fixes #82341. (#82341) Thanks @giodl73-repo.</li>
|
||||
<li>Telegram: keep the top-level default account in the account list when named accounts or bindings are added alongside top-level credentials, preserving default polling while still letting named-only configs resolve to a single account. Fixes #82794. (#82794) Thanks @giodl73-repo.</li>
|
||||
<li>CLI/models: reuse command-scoped plugin metadata across model listing, provider catalog, auth, and synthetic-auth checks, restoring fast <code>openclaw models</code> runs for plugin-heavy installs. Fixes #82881. (#83033) Thanks @joshavant.</li>
|
||||
<li>CLI/channels: show configured official external channels such as Discord in <code>openclaw channels list</code> when their plugin package is missing, including the install and doctor repair command instead of reporting no configured channels. Fixes #82813.</li>
|
||||
<li>Signal: preserve mixed-case group IDs through routing and session persistence so group auto-replies keep delivering after updates. Fixes #82827.</li>
|
||||
<li>Agents/tools: keep the <code>message</code> tool available in embedded runs when it is explicitly allowed through <code>tools.alsoAllow</code> or runtime tool allowlists, so channel plugins with custom reply delivery can still use configured message sends. Fixes #82833. Thanks @cn1313113.</li>
|
||||
<li>WhatsApp: honor forced document delivery for outbound image, GIF, and video media so <code>forceDocument</code>/<code>asDocument</code> sends preserve original media bytes instead of using compressed media payloads. (#79272) Thanks @itsuzef.</li>
|
||||
<li>WhatsApp: name outbound document attachments from their MIME type when no filename is provided, so PDF and CSV sends arrive as <code>file.pdf</code> and <code>file.csv</code> instead of an extensionless <code>file</code>. Thanks @mcaxtr.</li>
|
||||
<li>Process/diagnostics: report active lane blockers in lane wait warnings so <code>queueAhead=0</code> no longer hides commands waiting behind active work. Fixes #82791. (#82792) Thanks @galiniliev.</li>
|
||||
<li>Process/diagnostics: stop counting the active processing turn as queued backlog in liveness warnings so transient max-only event-loop spikes do not surface as gateway warnings.</li>
|
||||
<li>Agents/replies: classify provider conversation-state rejections and return a clear message-channel error instead of auto-resetting or falling back to a generic runner failure. (#82616) Thanks @dutifulbob.</li>
|
||||
<li>Browser plugin: trust managed Chrome CDP diagnostics when launch HTTP probes race cold-start readiness, avoiding false startup failures. Fixes #82904. (#82986) Thanks @kmanan and @hclsys.</li>
|
||||
<li>Android: prompt before replacing a changed Gateway TLS thumbprint, showing the old and new SHA-256 fingerprints so users can accept expected certificate rotations instead of hard failing on pin mismatch. (#83077) Thanks @sliekens.</li>
|
||||
<li>CLI/status: render extra gateway-like service diagnostics as warning/info output instead of error output. Fixes #46930. (#82922) thanks @giodl73-repo.</li>
|
||||
<li>Agents/failover: classify Moonshot/Kimi exhausted-balance HTTP 429 payloads as billing instead of generic rate limits, preserving billing guidance and fallback behavior. Fixes #43447. (#83079) Thanks @leno23.</li>
|
||||
<li>Plugin SDK: bundle <code>openclaw/plugin-sdk/zod</code> into the published package artifact and verify the packed zod subpath stays self-contained, so pnpm global installs can register plugins without a package-local <code>zod</code> symlink. Fixes #78398. (#78515) Thanks @ggzeng.</li>
|
||||
<li>Providers/Google: drop compaction-truncated Gemini thought signatures before replay so malformed Base64 no longer aborts the next assistant turn. (#82995) Thanks @wAngByg.</li>
|
||||
<li>Gateway/mobile: allow paired iOS and Android clients to refresh same-family OS metadata on authenticated reconnect instead of requiring a new approval. (#83490) Thanks @ngutman.</li>
|
||||
<li>WhatsApp: treat <code>upload-file</code> as a supported media send intent by lowering path/URL uploads through the channel's normal send-media transport. (#81883) Thanks @ngutman.</li>
|
||||
<li>iOS: end Live Activities when OpenClaw is connected, idle, or disconnected, and show compact attention states for approval-required reconnects. (#83597) Thanks @ngutman.</li>
|
||||
<li>Control UI: hide child nav items when collapsing the active sidebar group. Fixes #42167. (#42223) Thanks @Aroool.</li>
|
||||
<li>CI/proof: skip the real-behavior-proof gate for private org maintainers by minting a least-privilege (<code>members: read</code>) GitHub App token and checking active membership in the <code>maintainer</code> team, instead of treating <code>author_association=CONTRIBUTOR</code> as definitively external. (#83418) Thanks @romneyda.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.19/OpenClaw-2026.5.19.zip" length="54062201" type="application/octet-stream" sparkle:edSignature="7bVi6rv+TjhrUfi32V62BW2VgyV17jm7x+H6p10PRClCdXKZjhM7AX6MyvAz2+e7kzXIknj1Y9X7q43/E9fBBw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.5.18</title>
|
||||
<pubDate>Mon, 18 May 2026 22:41:13 +0000</pubDate>
|
||||
@@ -218,526 +610,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.18/OpenClaw-2026.5.18.zip" length="53924201" type="application/octet-stream" sparkle:edSignature="cU0TfUmBZbVOpgwou+GS7RQiDhEGVUxjK+bwsl1RXiqvJi9ErsYebZIxVayH8++v5PeycoK5+LQF5gLiXQa2AA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.5.12</title>
|
||||
<pubDate>Fri, 15 May 2026 13:25:16 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026051290</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.5.12</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.5.12</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Amazon Bedrock: externalize the Bedrock and Bedrock Mantle provider packages so core installs no longer pull AWS SDK dependencies unless those providers are installed.</li>
|
||||
<li>Plugins: externalize Slack, OpenShell sandbox, and Anthropic Vertex so their runtime dependency cones install only when those plugins are installed.</li>
|
||||
<li>Control UI/WebChat: add a persisted auto-scroll mode selector so users can keep the current near-bottom behavior, always follow streaming output, or turn automatic streaming scroll off and use the New messages button manually. Fixes #7648 and #81287. Thanks @BunsDev.</li>
|
||||
<li>ACP: add <code>acp.fallbacks</code> so ACP turns can try configured backup runtime backends when the primary backend is unavailable before any output is emitted. (#69542) Thanks @kaseonedge.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Doctor/Codex: stop warning that the message tool is unavailable for source-reply paths where OpenClaw grants <code>message</code> at runtime, keeping update and doctor output aligned with the OpenAI happy path. Thanks @pashpashpash.</li>
|
||||
<li>Channels/Weixin: bump the external Weixin catalog entry to <code>@tencent-weixin/openclaw-weixin@2.4.3</code> with the matching package integrity. (#81730) Thanks @scotthuang.</li>
|
||||
<li>Agents/subagents: apply <code>agents.defaults.subagents.model</code> before target agent primary models during <code>sessions_spawn</code>, so model-scoped runtimes such as <code>claude-cli</code> stay attached to default child runs. Fixes #81395. (#81783) Thanks @joshavant.</li>
|
||||
<li>Telegram: keep Bot API polling alive during main event-loop stalls by moving ingress to an isolated worker with a durable local spool. Fixes #81132. (#81746) Thanks @joshavant.</li>
|
||||
<li>Telegram: preserve rendered HTML formatting through lazy cron announce delivery so Markdown links stay clickable instead of falling back to literal anchor tags. Fixes #81742. (#81758)</li>
|
||||
<li>Telegram: skip unmentioned group media before download when <code>requireMention</code> is active, avoiding failed media-download replies for messages that should be ignored. Fixes #81181. (#81785) Thanks @joshavant.</li>
|
||||
<li>CLI/plugins: keep bare plugin and parent-command help on the lightweight path, avoiding plugin registry discovery before rendering help.</li>
|
||||
<li>Gateway/session history: carry monotonic transcript message sequence through live updates and refresh SSE history when stale sequence input would otherwise append bad incremental state. (#81474) Thanks @samzong.</li>
|
||||
<li>Security/sandbox: include Windows <code>USERPROFILE</code> in the sandbox blocked home roots so credential-bearing binds (such as <code>.codex</code>, <code>.openclaw</code>, or <code>.ssh</code> under the Windows user profile) are denied even when <code>HOME</code> points at a different shell home. (#63074) Thanks @luoyanglang.</li>
|
||||
<li>Models config/auth: stop inferring provider env-var markers from broad <code>^[A-Z_][A-Z0-9_]*$</code> strings, and resolve config-backed provider <code>apiKey</code> values only through structured env SecretRefs (<code>secrets.providers[id]</code> / <code>secrets.defaults</code>), so unrelated env vars cannot accidentally become provider credentials. Thanks @sallyom.</li>
|
||||
<li>Media fetch: skip allocating and buffering the response body for bodyless media responses (HEAD probes and 204-style empty bodies), avoiding wasted heap on streams that carry no payload. Thanks @shakkernerd.</li>
|
||||
<li>CLI/onboarding: forward provider-specific auth flags (e.g. <code>--openai-api-key</code>) through the onboarding wizard so they reach provider auth methods via <code>ctx.opts</code>, letting <code>--openai-api-key "$OPENAI_API_KEY"</code> skip the redundant "use existing env var?" prompt in non-interactive harnesses. (#81669) Thanks @sjf.</li>
|
||||
<li>CLI/migrate: drop trailing periods from Codex migrate item messages and <code>REASON_CODE_MESSAGES</code> strings so plan/result rows read as labels instead of sentence fragments. (#81705) Thanks @sjf.</li>
|
||||
<li>Slack: treat malformed private-file redirect <code>Location</code> headers as unfollowable redirects instead of failing Slack media downloads.</li>
|
||||
<li>Plugins: discover provider plugins from <code>setup.providers[].envVars</code> credentials during provider discovery while keeping the deprecated <code>providerAuthEnvVars</code> fallback. (#81542) Thanks @JARVIS-Glasses.</li>
|
||||
<li>Docs/Codex harness: clarify that per-agent <code>CODEX_HOME</code> isolates <code>~/.codex</code> while inherited <code>HOME</code> intentionally keeps <code>.agents</code> discovery and subprocess user-home state available.</li>
|
||||
<li>Auth: reclaim dead-owner stale file locks before retrying locked writes, so crashed OAuth refreshes no longer wedge <code>auth-profiles.json</code> until manual cleanup.</li>
|
||||
<li>CLI tables: preserve muted/color styling on wrapped continuation lines after multiline cells, keeping <code>openclaw plugins list</code> descriptions readable.</li>
|
||||
<li>Process execution: collapse case-insensitive duplicate child environment keys on Windows so caller-provided overrides such as <code>PATH</code> cannot be shadowed by host <code>Path</code>.</li>
|
||||
<li>Gateway/diagnostics: suppress cold-start liveness warnings during the startup grace window while still sampling liveness metrics. Fixes #79915. (#81699) Thanks @joshavant.</li>
|
||||
<li>Codex harness: keep <code>oauthRef</code>-backed Codex OAuth profiles usable and stop high-confidence app-server OAuth refresh invalidation from retry-spamming raw token-refresh errors without turning entitlement or usage-limit payloads into re-auth prompts.</li>
|
||||
<li>Browser CLI: request the existing <code>operator.admin</code> gateway scope explicitly for browser control commands, avoiding unnecessary scope-upgrade approval loops. Fixes #81555. (#81716) Thanks @joshavant.</li>
|
||||
<li>Gateway/diagnostics: suppress cold-start liveness warnings during the startup grace window while still sampling liveness metrics. Fixes #79915. (#81699) Thanks @joshavant.</li>
|
||||
<li>Plugin SDK: restore the deprecated <code>openclaw/plugin-sdk/memory-core</code> package subpath as an alias of <code>memory-host-core</code>, so published memory companion plugins that still import it resolve on current hosts.</li>
|
||||
<li>Control UI/i18n: use the installed workspace pi runtime for locale refreshes, update the fallback package pin, prefer the Anthropic CI provider when available, and skip invalid provider credentials instead of failing main.</li>
|
||||
<li>Codex harness: classify native app-server token-refresh logout and relogin failures as authentication refresh errors, so users get re-authentication guidance instead of a raw runtime failure.</li>
|
||||
<li>Codex startup: treat selectable configured OpenAI agent models as Codex runtime requirements during plugin auto-enable, startup planning, and doctor install repair, so Anthropic-primary configs can still switch to OpenAI/Codex cleanly.</li>
|
||||
<li>Agents: preserve source-reply delivery metadata when merging tool-returned media into the final reply, keeping message-tool-only replies deliverable and mirrored. Thanks @pashpashpash and @vincentkoc.</li>
|
||||
<li>Replies: treat rich presentation, interactive controls, and channel-native payload data as outbound content across follow-up, heartbeat, cron, ACP, and block-streaming delivery paths, preventing card/button-only replies from being dropped as empty.</li>
|
||||
<li>WebChat/TUI: route Codex <code>tools.message</code> source replies to the active internal UI turn and mirror them to session history, so message-tool-only harness replies, including rich presentation and button-only replies, no longer disappear while WebChat and TUI remain non-targetable outbound channels. (#81586) Thanks @pashpashpash.</li>
|
||||
<li>Replies: deliver rich-only block replies even when block-streaming coalescing is enabled, keeping card and button payloads from being dropped by the text coalescer. Thanks @pashpashpash.</li>
|
||||
<li>macOS/companion: require system TLS trust before pinning a first-use direct <code>wss://</code> gateway certificate and honor <code>gateway.remote.tlsFingerprint</code> as the explicit pin for remote node-mode sessions, so fresh endpoints fail closed when macOS cannot trust the certificate unless configured out of band. Fixes #50642. Thanks @BunsDev.</li>
|
||||
<li>Update: snapshot config before update-time repair and restart writes, preserve plugin install records through doctor cleanup, and keep update-time config size drops from blocking the update while pointing users to the pre-update backup. Fixes #80077. (#80257) Thanks @Jerry-Xin and @vincentkoc.</li>
|
||||
<li>Sessions/status: classify ACP spawn-child sessions as <code>kind: "spawn-child"</code> instead of <code>"direct"</code> in <code>openclaw sessions</code> and status output; extract the duplicated session-kind classifier into a shared helper (<code>src/sessions/classify-session-kind.ts</code>) so both surfaces stay in sync. Fixes catalog #19. (#79544)</li>
|
||||
<li>Sessions/Gateway: report <code>agentRuntime.id: "acpx"</code> (or stored backend id) with <code>source: "session-key"</code> for ACP control-plane session rows in <code>openclaw sessions --json</code>, <code>openclaw status</code>, and Gateway session RPC responses instead of the incorrect <code>"auto"</code> / <code>"pi"</code> implicit fallback. Fixes catalog #18. (#79550)</li>
|
||||
<li>Telegram: delete tool-progress-only draft bubbles before rotating to the real answer, preventing orphaned progress messages in streamed replies.</li>
|
||||
<li>Codex app-server: keep per-agent <code>CODEX_HOME</code> isolation without rewriting <code>HOME</code> by default, so Codex-run subprocesses can still find normal user-home config, tokens, and CLI state unless the launch explicitly overrides <code>HOME</code>. Thanks @pashpashpash.</li>
|
||||
<li>iMessage: stop sending visible <code><media:image></code> placeholder text for media-only native image sends while preserving the internal echo key that prevents self-echo duplicate replies. (#81209) Thanks @homer-byte.</li>
|
||||
<li>Agents/sessions: create configured agent main sessions before first <code>sessions_send</code> or gateway send, so agent-to-agent messages no longer fail when the target agent has not started yet.</li>
|
||||
<li>gateway: pass Talk session scope to resolver [AI]. (#81379) Thanks @pgondhi987.</li>
|
||||
<li>Gateway protocol: require v4 clients and stream explicit chat <code>deltaText</code>/<code>replace</code> frames so SDK clients can consume assistant updates without local diffing. (#80725) Thanks @samzong.</li>
|
||||
<li>GitHub Copilot: exchange OAuth tokens for Copilot API tokens on image understanding requests and route Gemini image payloads through Chat Completions, fixing Copilot Gemini image descriptions. (#80393, #80442) Thanks @afunnyhy.</li>
|
||||
<li>Gateway: hide pending Node pairing commands, capabilities, and permissions until approval, and refresh the live approved surface when pairings change. (#80741) Thanks @samzong.</li>
|
||||
<li>Plugins/Feishu/WhatsApp/Line: enforce inbound media size caps while reading download streams, avoiding full buffering of oversized attachments. (#81044, #81050) Thanks @samzong.</li>
|
||||
<li>Plugins/install: limit install-time code safety scans to plugin-owned runtime entrypoints while keeping dependency manifest denylist checks, so trusted packages with large dependency trees no longer get blocked or warned on third-party runtime internals.</li>
|
||||
<li>Config: serialize and retry semantic config mutations centrally, so concurrent commands can rebase safe changes instead of clobbering or hand-rolling command-local retry loops. (#76601)</li>
|
||||
<li>Installer: honor <code>--no-git-update</code> for existing git checkouts before resolving release refs, preventing pinned source installs from moving during reinstall.</li>
|
||||
<li>Plugins/install: refresh OpenClaw-managed peer dependency pins when installed plugin peer ranges change, while preserving user-owned dependency pins.</li>
|
||||
<li>Require approval for setup-code device pairing [AI]. (#81292) Thanks @pgondhi987.</li>
|
||||
<li>Plugins/install: preserve third-party peer dependencies in the managed npm root when later plugin installs or updates recalculate the shared dependency tree. Thanks @shakkernerd.</li>
|
||||
<li>Plugins/memory: prefer the npm-installed memory-lancedb plugin over the bundled fallback during duplicate resolution, keeping Active Memory's <code>memory_recall</code> tool visible after managed installs. Fixes #81193. Thanks @julio-arcila.</li>
|
||||
<li>Plugins/uninstall: prune managed third-party peer dependencies after their owning npm plugin is removed, without blocking plugin cleanup on peer-prune failures.</li>
|
||||
<li>Docker: pin setup-time container paths so stale host <code>.env</code> OpenClaw paths cannot leak into Linux containers. Fixes #80381. (#81105) Thanks @brokemac79.</li>
|
||||
<li>Channels/WeCom: refresh the official onboarding install to <code>@wecom/wecom-openclaw-plugin@2026.5.7</code> and update existing managed npm installs instead of failing on the package directory. Fixes #79884. (#80390) Thanks @brokemac79.</li>
|
||||
<li>Anthropic: reseed Claude CLI fresh-session retries from bounded OpenClaw transcript history after session rotation, preventing conversation amnesia. Fixes #80905. (#80934) Thanks @bitloi.</li>
|
||||
<li>Require explicit browser device pairing [AI]. (#81289) Thanks @pgondhi987.</li>
|
||||
<li>Require Control UI pairing before proxy-scoped access [AI]. (#81288) Thanks @pgondhi987.</li>
|
||||
<li>Installer: honor <code>--version</code> for git installs and install from the checked-in lockfile, preventing recent dependency pins from tripping pnpm's minimum-release-age gate during tag installs.</li>
|
||||
<li>Agents: deliver same-process subagent completion handoffs through the in-process agent dispatcher instead of opening a Gateway RPC loopback.</li>
|
||||
<li>Harden trusted-proxy source validation [AI]. (#81290) Thanks @pgondhi987.</li>
|
||||
<li>Agents: add permissive item schemas to array tool parameters before provider submission, preventing OpenAI-compatible schema validation from rejecting plugin tools that omit <code>items</code>. Fixes #81175. (#81217) Thanks @JARVIS-Glasses.</li>
|
||||
<li>Agents: escalate LLM idle watchdog timeouts through profile rotation and configured model fallback instead of leaving agent turns stuck after a silent model stream. Fixes #76877. (#80449) Thanks @jimdawdy-hub.</li>
|
||||
<li>Discord voice: treat OpenAI Realtime startup auth failures as fatal, suppress duplicate realtime error logs, and stop autoJoin from retrying the same broken voice channel until credentials are fixed.</li>
|
||||
<li>ACPX: stop forwarding unsupported timeout config options to Claude ACP while preserving OpenClaw's own turn timeout. (#80812) Thanks @sxxtony.</li>
|
||||
<li>Session transcripts: redact sensitive message content in the centralized JSONL append path so CLI turns, gateway transcript injection, transcript mirrors, and guarded tool results use the same configured redaction behavior. Fixes #73565. Refs #73563. (#79645) Thanks @Ziy1-Tan.</li>
|
||||
<li>Channels/iMessage: ignore Apple link-preview plugin payload attachments when users paste URLs, keeping the URL text while avoiding phantom media context. (#79374) Thanks @homer-byte.</li>
|
||||
<li>Telegram: detect polling stalls from <code>getUpdates</code> liveness only, so outbound API calls no longer mask dead inbound polling; log polling-cycle starts after transport rebuilds. Fixes #78473.</li>
|
||||
<li>fix: scan plugin runtime entries during install [AI]. (#80998) Thanks @pgondhi987.</li>
|
||||
<li>fix(plugins): scan installed dependency runtime code [AI]. (#81066) Thanks @pgondhi987.</li>
|
||||
<li>Inherit tool restrictions for delegated sessions [AI]. (#80979) Thanks @pgondhi987.</li>
|
||||
<li>Telegram: discard legacy long-poll update offsets that cannot be tied to the current bot token, so token rotation no longer leaves bots silently skipping new messages. (#80671) Thanks @sxxtony.</li>
|
||||
<li>browser: enforce navigation checks for act interactions [AI]. (#81070) Thanks @pgondhi987.</li>
|
||||
<li>Validate node exec event provenance [AI]. (#81071) Thanks @pgondhi987.</li>
|
||||
<li>Gateway: keep active reply runs visible to stuck-session diagnostics and clear no-active-work recovery state, preventing stale queued lanes after compaction or tool failures. Fixes #80677. (#81302)</li>
|
||||
<li>Codex app-server: rotate incompatible context-engine-managed native threads so Lossless-managed sessions do not resume stale hidden Codex history. (#81223) Thanks @jalehman.</li>
|
||||
<li>Codex cron: execute scheduled command-style automation payloads before workspace bootstrap or memory review, preserving existing isolated cron jobs after Codex harness migration. (#81510) Thanks @jalehman.</li>
|
||||
<li>Plugin LLM completions: honor Codex agent-runtime policy for canonical OpenAI model refs, so context-engine summarizers can use Codex OAuth instead of requiring direct <code>OPENAI_API_KEY</code> auth. (#81511) Thanks @jalehman.</li>
|
||||
<li>Gateway/OpenAI HTTP: return OpenAI-compatible 400 errors for invalid sampling params and provider validation failures instead of collapsing them to 500s. (#81275) Thanks @Lellansin.</li>
|
||||
<li>Telegram: publish plugin and skill command description localizations to native command menus while filtering unsupported locale codes and preserving Telegram command limits. (#81351) Thanks @jzakirov.</li>
|
||||
<li>Limit hook CLI tool authority [AI]. (#81065) Thanks @pgondhi987.</li>
|
||||
<li>Require admin scope for node device token management [AI]. (#81067) Thanks @pgondhi987.</li>
|
||||
<li>Restrict chat sender allowlist matching [AI]. (#80898) Thanks @pgondhi987.</li>
|
||||
<li>Update: suppress the false newer-config warning during restart health probing after an update handoff, while keeping future-version mutation guards intact. (#78652)</li>
|
||||
<li>Sessions: redact persisted tool result detail metadata before writing transcripts so diagnostic secrets do not survive tool output redaction. (#80444) Thanks @nimbleenigma.</li>
|
||||
<li>Codex runtime: allow the official installed <code>@openclaw/codex</code> package to use its private task-runtime and MCP projection SDK helpers, fixing <code>MODULE_NOT_FOUND</code> during migrated OpenAI/Codex beta runs.</li>
|
||||
<li>Codex migration: make Enter activate the highlighted checkbox row before continuing, so <code>Skip for now</code> and bulk-selection rows work even when planned items start preselected.</li>
|
||||
<li>Codex harness: keep auth-profile-backed media tools such as <code>image_generate</code> available when OpenAI auth lives in the agent's auth-profile store instead of environment variables.</li>
|
||||
<li>WhatsApp/install: allow Baileys' pinned libsignal git subdependency under pnpm 11 so source installs and local checks can complete.</li>
|
||||
<li>Require auth for sandbox browser CDP relay [AI]. (#81002) Thanks @pgondhi987.</li>
|
||||
<li>fix: detect carried exec command forms [AI]. (#81000) Thanks @pgondhi987.</li>
|
||||
<li>Reject truncated exec approval commands [AI]. (#81001) Thanks @pgondhi987.</li>
|
||||
<li>Enforce inline shell wrapper payload matching [AI]. (#80978) Thanks @pgondhi987.</li>
|
||||
<li>fix(node-pairing): replace changed pending requests [AI]. (#80894) Thanks @pgondhi987.</li>
|
||||
<li>Rate limit Google Chat webhook requests [AI]. (#80974) Thanks @pgondhi987.</li>
|
||||
<li>Docker: mount the auth-profile secret key directory so OAuth-backed auth profiles survive container rebuilds. (#80991)</li>
|
||||
<li>Onboarding: accept Codex auth profiles for canonical OpenAI model checks, avoiding false missing-auth warnings. (#80913) Thanks @rubencu.</li>
|
||||
<li>fix(feishu): normalize webhook rate-limit client keys [AI]. (#80975) Thanks @pgondhi987.</li>
|
||||
<li>fix(auth): prevent bootstrap pairing scope changes [AI]. (#80976) Thanks @pgondhi987.</li>
|
||||
<li>Validate Control UI loopback retry endpoints [AI]. (#80900) Thanks @pgondhi987.</li>
|
||||
<li>Harden exported markdown link rendering [AI]. (#80902) Thanks @pgondhi987.</li>
|
||||
<li>fix(gateway): honor minimal discovery mode for wide-area DNS-SD [AI]. (#80903) Thanks @pgondhi987.</li>
|
||||
<li>slack: enforce reaction notification policy [AI]. (#80907) Thanks @pgondhi987.</li>
|
||||
<li>Enforce gateway command scopes by caller context [AI]. (#80891) Thanks @pgondhi987.</li>
|
||||
<li>Telegram/groups: in single-account setups, treat an explicit empty <code>accounts.<id>.groups: {}</code> map the same as undefined so the root <code>channels.telegram.groups</code> allowlist still applies, instead of silently dropping every group update under the default <code>groupPolicy: "allowlist"</code>. Multi-account semantics are unchanged so per-account explicit-empty groups still scope-disable a single account without affecting siblings; the explicit way to block all groups for any account remains <code>groupPolicy: "disabled"</code>. Fixes #79427. (#81030) Thanks @kinjitakabe.</li>
|
||||
<li>Codex (app-server): project user-configured <code>mcp.servers</code> into new Codex thread configs, matching the codex-cli runtime's existing <code>-c mcp_servers=...</code> behavior so app-server-runtime agents see the same user MCP servers the CLI runtime already exposes. Plugin-curated apps remain attached via the separate <code>apps</code> config patch. Fixes #80814. Thanks @kinjitakabe.</li>
|
||||
<li>Enforce Slack plugin approval button authorization [AI]. (#80899) Thanks @pgondhi987.</li>
|
||||
<li>Recognize PowerShell -ec inline commands [AI]. (#80893) Thanks @pgondhi987.</li>
|
||||
<li>fix(qqbot): authorize approval button callbacks [AI]. (#80892) Thanks @pgondhi987.</li>
|
||||
<li>Telegram: render supported HTML tags in streamed and durable replies instead of showing literal markup. (#80977)</li>
|
||||
<li>Scrub streamable MCP redirect headers [AI]. (#80906) Thanks @pgondhi987.</li>
|
||||
<li>fix(memory-wiki): require admin scope for ingest [AI]. (#80897) Thanks @pgondhi987.</li>
|
||||
<li>memory-wiki: require write scope for Obsidian search [AI]. (#80904) Thanks @pgondhi987.</li>
|
||||
<li>WhatsApp/install: allow Baileys' pinned libsignal git subdependency under pnpm 11 so source installs and local checks can complete.</li>
|
||||
<li>WhatsApp: externalize the channel as a ClawHub/npm plugin outside the core npm runtime bundle, and bump Baileys to <code>7.0.0-rc11</code> so libsignal resolves from the registry instead of a GitHub tarball.</li>
|
||||
<li>WhatsApp: keep optional audio decoding dependencies local to the external plugin so the core npm install no longer pulls WhatsApp-only media helpers.</li>
|
||||
<li>Build: skip copied metadata for bundled plugins that are excluded from build entries, preventing update/status rebuilds from advertising missing QQ Bot runtime files. (#80925)</li>
|
||||
<li>Control UI/sessions: nest subagent sessions under their parent session in the session picker dropdown using a visual <code>└─ </code> prefix, making the parent-child relationship clear. Fixes #77628. (#78623) Thanks @chinar-amrutkar.</li>
|
||||
<li>Auto-reply: surface a visible error when the configured model backend fails and fallback produces no visible reply, while preserving intentional silent turns and side-effect-only deliveries. (#80917) Thanks @dutifulbob.</li>
|
||||
<li>Agents/exec: skip redundant heartbeat wake-ups for subagent session exec completions, preventing spurious LLM invocations on parent sessions. Fixes #66748. (#66749) Thanks @ggzeng.</li>
|
||||
<li>Provider streams: keep OpenAI-compatible SSE and JSON fallback streams draining across split chunks and fail Azure Responses streams with a bounded first-event diagnostic instead of stalling. Refs #80926. (#80927) Thanks @galiniliev and @CaptainTimon.</li>
|
||||
<li>Agents: rewrite generic provider internal errors with support request IDs into user-friendly transient error copy. (#49401) Thanks @y471823206.</li>
|
||||
<li>WhatsApp: finish handling pending debounced inbound messages before closing the socket. (#81246) Thanks @mcaxtr.</li>
|
||||
<li>CLI/commitments: write <code>--json</code> output to stdout instead of diagnostic logs so automation can parse commitment list and dismiss results. (#81215) Thanks @giodl73-repo.</li>
|
||||
<li>Update: allow pnpm GitHub-source OpenClaw updates to approve the OpenClaw package build, so source installs complete their prepare/prepack lifecycle. (#81294) Thanks @fuller-stack-dev.</li>
|
||||
<li>Telegram: preserve supported HTML tags in visible replies and durable mirrors so formatted messages render correctly instead of degrading to escaped text. (#80977) Thanks @obviyus.</li>
|
||||
<li>Plugins/runtime: attribute deprecated runtime config load/write warnings to the plugin id and source that triggered them so logs and plugin doctor runs are actionable. Refs #81394. (#81425) Thanks @BKF-Gitty.</li>
|
||||
<li>Agents/cron: honor a cron payload's explicit <code>timeoutSeconds</code> for the LLM idle watchdog even when it numerically equals <code>agents.defaults.timeoutSeconds</code>, preserving explicit per-run timeout intent and preventing stalled streaming replies from being cut to the implicit 120s cap. (#79426) Thanks @legolaz8451.</li>
|
||||
<li>Codex app-server: keep the short post-tool completion watchdog armed across dynamic tool completion bookkeeping so embedded Codex runs fail fast and release their session lane when Codex goes quiet after a tool result. (#81697) Thanks @mbelinky.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Gateway/OpenAI HTTP: honor <code>max_completion_tokens</code> and <code>max_tokens</code> on inbound <code>/v1/chat/completions</code> requests so client-provided token caps reach the upstream provider via <code>streamParams.maxTokens</code>, with <code>max_completion_tokens</code> taking precedence when both are sent. Thanks @Lellansin.</li>
|
||||
<li>Models/OpenAI CLI auth: make <code>openclaw models auth login --provider openai</code> start the ChatGPT/Codex account login by default, while <code>--method api-key</code> remains the explicit OpenAI API-key setup path.</li>
|
||||
<li>Google/Gemini: normalize retired Gemini 3 Pro Preview ids inside explicit SDK OAuth auth-result config patches, so provider helpers emit <code>google/gemini-3.1-pro-preview</code> for Gemini 3.1 testing.</li>
|
||||
<li>Google/Gemini: normalize retired Gemini 3 Pro Preview ids inside SDK OAuth auth-result default config patches, so helper-built provider auth flows emit <code>google/gemini-3.1-pro-preview</code> for Gemini 3.1 testing.</li>
|
||||
<li>Google/Gemini: normalize retired Gemini 3 Pro Preview ids returned by direct <code>openclaw models auth login --set-default</code> provider auth flows before writing config, so Gemini testing targets <code>google/gemini-3.1-pro-preview</code>.</li>
|
||||
<li>Google/Gemini: normalize retired Gemini 3 Pro Preview ids in per-agent config defaults and auth patches, so agent-specific emitted config keeps targeting <code>google/gemini-3.1-pro-preview</code>.</li>
|
||||
<li>Google/Gemini: normalize retired Gemini 3 Pro Preview ids in provider catalog rows when API-key onboarding only reapplies the agent default, so emitted config keeps testing <code>google/gemini-3.1-pro-preview</code>.</li>
|
||||
<li>Google/Gemini: normalize retired Gemini 3 Pro Preview ids in <code>config set</code> mutation output for agent overrides and provider catalog rows, so current config emits <code>google/gemini-3.1-pro-preview</code>.</li>
|
||||
<li>Google/Gemini: canonicalize provider-qualified retired Gemini 3 Pro Preview refs during Google forward-compatible model resolution, so emitted config uses <code>google/gemini-3.1-pro-preview</code> for Gemini 3.1 testing.</li>
|
||||
<li>Google/Gemini: normalize proxy-prefixed retired Gemini 3 Pro Preview catalog rows, so emitted configs use <code>google/gemini-3.1-pro-preview</code> for Gemini 3.1 testing.</li>
|
||||
<li>Google/Gemini: normalize retired Gemini 3 Pro Preview ids inside per-agent model overrides before writing config, so agent-specific config emits <code>google/gemini-3.1-pro-preview</code> for Gemini 3.1 testing.</li>
|
||||
<li>Google/Gemini: normalize retired Gemini 3 Pro Preview ids in subagent, heartbeat, compaction, and subagent-tool model config during writes, so current config keeps emitting <code>google/gemini-3.1-pro-preview</code>.</li>
|
||||
<li>Docs/subagents: document <code>agents.defaults.subagents.announceTimeoutMs</code> in the sub-agent and configuration references. (#75509) Thanks @akrimm702.</li>
|
||||
<li>Cron: add direct <code>cron.get</code>, <code>openclaw cron get <id></code>, and agent-tool <code>get</code> support for inspecting one stored cron job by id. (#75117) Thanks @samzong.</li>
|
||||
<li>Agents/tools: add per-sender tool policies with canonical channel-scoped sender keys, so operators can restrict dangerous tools by requester identity across global, agent, group, core, bundled, and plugin tool surfaces. (#66933) Thanks @JerranC.</li>
|
||||
<li>ACP: expose Gateway session lineage metadata through ACP session listings and session info snapshots so clients can render subagent graphs without private Gateway side channels. (#73458) Thanks @samzong.</li>
|
||||
<li>Channels/iMessage: add <code>openclaw channels status --channel <name></code> filtering and document the BlueBubbles-to-imsg cutover path so operators can probe iMessage without starting both channel monitors. (#80706) Thanks @omarshahine.</li>
|
||||
<li>CI: add a non-blocking <code>plugin-inspector-advisory</code> artifact to Plugin Prerelease so release runs capture bundled plugin compatibility triage without changing the blocking gate.</li>
|
||||
<li>Runtime/Fly: detect Fly Machines as container environments from their runtime env vars, so gateway bind and Bonjour defaults match remote container launches. (#80209) Thanks @liorb-mountapps.</li>
|
||||
<li>Providers/fal: route GPT Image 2 and Nano Banana 2 reference-image edit requests to <code>/edit</code> with <code>image_urls</code> array, enforce NB2 edit geometry using <code>aspect_ratio</code> and <code>resolution</code> params, lift Fal edit mode input-image caps to 10 for GPT Image 2 and 14 for Nano Banana 2, and allow aspect-ratio hints in edit mode. (#77295) Thanks @leoge007.</li>
|
||||
<li>Control UI: show a plain HTML recovery panel when the app module never registers, giving blank dashboard pages a retry path and browser-extension troubleshooting link. Fixes #44107. Thanks @BunsDev.</li>
|
||||
<li>Docs: rename the broad tools nav to Capabilities, keep automation and agent coordination as sections, and keep the tools overview focused on tools, skills, and plugins. https://docs.openclaw.ai/tools</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>Build: enable additional low-churn oxlint rules for promise, TypeScript, and runtime footgun checks.</li>
|
||||
<li>Build: enable stricter Vitest lint rules for focused, disabled, conditional, hook, matcher, and expectation hazards.</li>
|
||||
<li>Build: pin explicit oxfmt defaults in the shared formatter config to keep formatting behavior stable across upgrades.</li>
|
||||
<li>TypeScript: enable stricter compiler checks for implicit returns, side-effect imports, overrides, and unused production code.</li>
|
||||
<li>Logging: add targeted model transport, payload, SSE, and code-mode diagnostics with redacted URL handling.</li>
|
||||
<li>Agents: allow <code>session.agentToAgent.maxPingPongTurns</code> up to 20 while keeping the default at 5 for longer agent-to-agent reply chains. Fixes #52382. (#52400) Thanks @thirumaleshp.</li>
|
||||
<li>Agents: add per-agent <code>tools.message.crossContext</code> overrides so sandboxed/public agents can restrict message sends to the current conversation without changing the global bot policy.</li>
|
||||
<li>Agents: add per-agent <code>tools.message.actions.allow</code> overrides so sandboxed/public agents can expose and enforce send-only message tools.</li>
|
||||
<li>Agents: omit the sandbox workspace marker from compact command progress previews while keeping internal sandbox diagnostics unchanged.</li>
|
||||
<li>Agents: widen progress draft command preview lines by 50% so Discord inline tool updates preserve more useful command context.</li>
|
||||
<li>Codex app-server: retire timed-out app-server clients after bounded turn interrupts so Discord agents do not reuse a CPU-spinning Codex process after an attempt timeout.</li>
|
||||
<li>Codex app-server: default migrated native plugin destructive-action policy to enabled while preserving explicit global and per-plugin false overrides.</li>
|
||||
<li>Build: upgrade workspace package management to pnpm 11 and keep Docker, install, update, and release workflows on the pnpm 11 config surface. (#79414) Thanks @altaywtf.</li>
|
||||
<li>Build: align Telegram QA workflows and git source installs with the pnpm 11 workspace build allowlist surface. (#80588) Thanks @altaywtf.</li>
|
||||
<li>Models: add provider-level <code>localService</code> startup for on-demand local model servers before OpenAI-compatible requests, including one-shot model probes.</li>
|
||||
<li>Agents: trim default system prompt guidance and send-only message tool schemas to reduce prompt tokens while preserving GPT-5 personality guidance.</li>
|
||||
<li>Context: add <code>/context map</code> to send a treemap image of the current session context contributors. (#79867)</li>
|
||||
<li>Slack: add <code>unfurlLinks</code> and <code>unfurlMedia</code> config for bot <code>chat.postMessage</code> replies, including per-account overrides, so Slack link and media previews can be suppressed without workspace-wide settings. Fixes #48435. (#80145) Thanks @esegev1 and @HemantSudarshan.</li>
|
||||
<li>Slack: add explicit <code>replyBroadcast</code> support for text and Block Kit thread replies so agents can opt into Slack's parent-channel <code>reply_broadcast</code> behavior. (#64365) Thanks @tony88331.</li>
|
||||
<li>Slack: preserve mention target/source metadata in inbound prompt context so agents can distinguish direct bot mentions from implicit thread wakes that mention someone else. Fixes #79025. (#75356) Thanks @tmimmanuel.</li>
|
||||
<li>Slack: canonicalize outbound delivery-mirror routes for native DM channel IDs to the peer user session so <code>message.send</code> calls to <code>D...</code> targets do not split the same Slack DM thread into a channel session. Fixes #80091. (#80111) Thanks @bek91.</li>
|
||||
<li>Plugin SDK: deprecate public subpaths that existed for at least one month and have no bundled extension production imports, keep legacy barrel/test/zod subpath package exports for backwards compatibility, and track both sets in the SDK surface report.</li>
|
||||
<li>Plugin SDK: deprecate public subpaths currently used by only one or two bundled plugin owners, keeping them importable while steering new plugin code to focused shared SDK seams or plugin-owned APIs.</li>
|
||||
<li>Plugin SDK: remove the owner-specific <code>provider-auth-login</code> public subpath after moving Chutes, GitHub Copilot, and OpenAI Codex auth flows back to provider-owned modules.</li>
|
||||
<li>Plugin SDK: remove provider-specific model, stream, and xAI compatibility helpers from public exports after moving bundled callers to provider-owned modules.</li>
|
||||
<li>Plugin SDK: expose runtime-supplied active model metadata to native plugin tool factories for diagnostics and plugin-owned policy decisions. Fixes #77857. Thanks @jamiezigelbaum.</li>
|
||||
<li>QA/Mantis: add Telegram live PR evidence automation with Convex-leased credentials, Crabbox transcript capture, motion GIF previews, and inline PR comments.</li>
|
||||
<li>QA/Mantis: add a Telegram desktop scenario builder that leases Crabbox, installs native Telegram Desktop, configures an OpenClaw Telegram gateway with leased bot credentials, and records VNC screenshot/video artifacts.</li>
|
||||
<li>Discord/voice: add realtime voice diagnostics for speaker turns, playback resets, barge-in detection, and audio cutoff analysis.</li>
|
||||
<li>Talk: add <code>talk.realtime.instructions</code> so operators can append realtime voice style instructions while preserving OpenClaw's built-in agent-consult guidance. (#79081) Thanks @VACInc.</li>
|
||||
<li>Discord/voice: default test and source installs to the pure-JS <code>opusscript</code> decoder by ignoring optional native <code>@discordjs/opus</code> builds, avoiding slow native addon compiles outside dedicated voice-performance lanes.</li>
|
||||
<li>Discord/voice: add an opt-in native <code>@discordjs/opus</code> install script and decoder preference for live voice-performance lanes without charging unrelated Docker/tests for native addon builds.</li>
|
||||
<li>Discord/voice: add <code>voice.allowedChannels</code> to restrict voice joins and bot voice-state moves to configured channels while preserving open voice behavior when unset.</li>
|
||||
<li>Gateway/skills: add an opt-in private skill archive upload install path gated by <code>skills.install.allowUploadedArchives</code>, so trusted Gateway clients can stage and install zip-backed skills only when operators explicitly enable the code-install surface. (#74430) Thanks @samzong.</li>
|
||||
<li>Codex app-server: enable Codex native code-mode-only for harness threads so deferred OpenClaw dynamic tools run through Codex's own searchable code execution surface instead of a PI-style wrapper.</li>
|
||||
<li>Dependencies: refresh workspace pins and patch targets, including ACPX <code>@agentclientprotocol/claude-agent-acp</code> <code>0.33.1</code>, Codex ACP <code>0.14.0</code>, Baileys <code>7.0.0-rc10</code>, Google GenAI <code>2.0.1</code>, OpenAI <code>6.37.0</code>, AWS SDK <code>3.1045.0</code>, Kysely <code>0.29.0</code>, Tlon skill <code>0.3.6</code>, Aimock <code>1.19.5</code>, and tsdown <code>0.22.0</code>.</li>
|
||||
<li>Dependencies: refresh workspace pins for Anthropic SDK, Smithy shared ini loading, Playwright, YAML, Aimock, TypeScript native preview, Vitest, Oxlint/Oxfmt, Vite, and pnpm 11.1.0.</li>
|
||||
<li>Dependencies: hard-pin non-peer direct dependency specs across bundled packages and add a changed-check guard so runtime installs resolve the exact versions tested by maintainers.</li>
|
||||
<li>Dependencies: move embedded Pi packages to the <code>@earendil-works</code> namespace, refresh Twitch Twurple packages, and move <code>@openclaw/fs-safe</code> from the GitHub release pin to the published npm package.</li>
|
||||
<li>Build: route Testbox changed-check delegation through Crabbox and remove the OpenClaw-specific Blacksmith Testbox helper scripts.</li>
|
||||
<li>Agents/compaction: preserve scoped background exec/process session references across embedded compaction and after-turn runtime contexts without exposing sessions from unrelated scopes. Fixes #79284. (#79307) Thanks @TurboTheTurtle.</li>
|
||||
<li>Agents/process: tell agents to inspect background sessions with <code>process log</code> before sending interactive input and to use <code>waitingForInput</code>/<code>stdinWritable</code> hints from <code>log</code>/<code>poll</code>.</li>
|
||||
<li>CLI/onboarding: improve setup, onboarding, configure, and channel command wayfinding so terminal flows explain the next useful command instead of relying on terse setup labels.</li>
|
||||
<li>Agents/Codex: remove the configurable Codex dynamic-tools profile so Codex app-server always owns workspace, edit, patch, exec, process, and plan tools while OpenClaw integration tools remain available.</li>
|
||||
<li>macOS app: update the Peekaboo bridge dependency to Peekaboo 3.0.0.</li>
|
||||
<li>Dependencies: refresh workspace pins and move the WhatsApp plugin from <code>@whiskeysockets/baileys</code> to <code>baileys</code> while keeping the <code>7.0.0-rc10</code> runtime.</li>
|
||||
<li>Plugin SDK: add bundled-plugin session actions, <code>sendSessionAttachment</code>, and Cron-backed <code>scheduleSessionTurn</code>/tag cleanup under the grouped session namespace. Replaces #75578/#75581/#75588 and part of #73384/#74483. Thanks @100yenadmin.</li>
|
||||
<li>Plugin SDK/media-understanding: add <code>extractStructuredWithModel(...)</code> plus the optional provider-side <code>extractStructured(...)</code> seam so trusted plugins can run bounded image-first structured extraction with optional supplemental text context through provider-owned runtimes such as Codex.</li>
|
||||
<li>Exec approvals: add <code>tools.exec.commandHighlighting</code> so parser-derived command highlighting in approval prompts can be enabled globally or per agent. (#79348) Thanks @jesse-merhi.</li>
|
||||
<li>Codex app-server: mirror native Codex subagent spawn lifecycle events into Task Registry so app-server child agents appear in task/status surfaces without relying on transcript text. (#79512) Thanks @mbelinky.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>CLI/media: render terminal QR codes with full-block characters by default so the bundled <code>qrcode</code> terminal renderer does not emit a pathologically dense ANSI final row in compact half-block mode that breaks scanning in some terminals. Fixes #77820. Thanks @KrasimirKralev.</li>
|
||||
<li>Agents/compaction: read post-compaction AGENTS.md refresh context from the queued run workspace instead of the runner process cwd, so CLI-backed follow-up turns re-inject the correct workspace startup rules after compaction. Fixes #70541. (#75532) Thanks @vyctorbrzezowski.</li>
|
||||
<li>Agents/read tool: treat positive offsets beyond EOF as empty ranges instead of surfacing the upstream read error, so stale pagination cursors no longer crash tool calls while unrelated read failures still fail loud. Fixes #62466. (#75536) Thanks @vyctorbrzezowski.</li>
|
||||
<li>Google/Gemini: normalize retired Gemini 3 Pro Preview refs left in Google API-key onboarding model allowlists and fallbacks, so setup-emitted config keeps testing <code>google/gemini-3.1-pro-preview</code> instead of <code>google/gemini-3-pro-preview</code>.</li>
|
||||
<li>Telegram/context: bound selected topic context to the active session so messages from before <code>/new</code> or <code>/reset</code> are not replayed into later turns. (#80848) Thanks @VACInc.</li>
|
||||
<li>Google/Gemini: normalize retired nested Gemini 3 Pro Preview ids when resolving exact configured proxy-provider refs, so <code>kilocode/google/gemini-3-pro-preview</code> resolves to <code>kilocode/google/gemini-3.1-pro-preview</code> for Gemini 3.1 testing.</li>
|
||||
<li>CLI: strip generic OSC terminal escape payloads from sanitized output fields, preventing clipboard/title escape bodies from leaking into commitment tables and other terminal-safe text. Thanks @shakkernerd.</li>
|
||||
<li>Codex app-server: match connector-backed plugin approval elicitations by stable connector id so enabled destructive actions no longer fall through to display-name-only rejection.</li>
|
||||
<li>Build: replace selected build utility <code>tsx</code> preloads with Node native type stripping so Node 26 build paths no longer emit <code>DEP0205</code> module loader deprecation warnings. (#78584) Thanks @keshavbotagent.</li>
|
||||
<li>Media generation: honor configured music and video generation timeouts when tool calls omit <code>timeoutMs</code>, matching image generation behavior. (#80687)</li>
|
||||
<li>CLI/update/status: label beta-channel plugin fallback and model-pricing refresh failures as warnings, keeping mixed beta/latest plugin cohorts visible without making core update or Gateway reachability look failed. Fixes #80689. Thanks @BKF-Gitty.</li>
|
||||
<li>Doctor/plugins: relink managed npm plugin <code>openclaw</code> peer dependencies during <code>doctor --fix</code>, while refusing to follow package-local <code>node_modules</code> symlinks outside the plugin package. (#77412) Thanks @TheCrazyLex.</li>
|
||||
<li>iMessage: route inbound tapbacks as reaction system events instead of normal messages, defaulting to bot-authored-message notifications while allowing <code>reactionNotifications: "off" | "own" | "all"</code> overrides. Fixes #60274; refs #39031 and #39322. Thanks @hyperclaw.</li>
|
||||
<li>Control UI/performance: scope Nodes polling to the active Nodes tab, debounce stale session-list reconciliation, and bound chat-side session refreshes so long-running dashboards avoid background reload churn. Thanks @BunsDev.</li>
|
||||
<li>Plugins/channels: explain bundled channel entry files that reach the legacy plugin loader as setup-runtime loader mismatches instead of generic missing-register failures. Thanks @chinar-amrutkar.</li>
|
||||
<li>Plugins/session-end: fire a typed <code>session_end</code> plugin hook with reason <code>shutdown</code> (or <code>restart</code> when a restart is expected) for every session that was still active when the gateway process stops. Previously SIGTERM/SIGINT/restart paths closed the gateway without enumerating active sessions, leaving downstream <code>session_end</code> plugins (e.g. claude-mem) with ghost rows accumulating across restarts. The new shutdown finalizer drains an in-memory tracker that is populated by <code>session_start</code> and forgotten by replace / reset / delete / compaction emitters, so previously-finalized sessions are never double-fired. The drain is bounded to a 2 s total budget so a slow plugin cannot block process exit. Adds <code>"shutdown"</code> and <code>"restart"</code> to <code>PluginHookSessionEndReason</code>. Fixes #57790. Thanks @pandadev66.</li>
|
||||
<li>Codex app-server: clamp Codex code-mode sandboxing to workspace-write when an OpenClaw sandbox is active, preventing Docker gateway socket access from becoming a danger-full-access Codex turn.</li>
|
||||
<li>TUI: exit immediately on Ctrl+C/SIGINT after gateway disconnect and bound shutdown drain so terminal teardown cannot strand sessions. Fixes #75379. (#75381) Thanks @udaymanish6.</li>
|
||||
<li>Matrix: default outbound markdown tables to bullet lists instead of fenced code blocks. Fixes #78990. (#80890) Thanks @kinjitakabe.</li>
|
||||
<li>Bonjour/Gateway: treat active ciao probing and fresh name-conflict renames as in-progress so the mDNS watchdog waits for probe settlement before retrying, preventing rapid re-advertise loops on Windows, WSL, and other multicast-hostile hosts. (#74778) Refs #74242. Thanks @fuller-stack-dev.</li>
|
||||
<li>Providers/MiniMax: send a minimal Anthropic-compatible user fallback when message conversion filters a turn to an empty payload, so MiniMax M2.7 no longer returns <code>chat content is empty</code> after tool-heavy sessions. Fixes #74589. Thanks @neeravmakwana and @DerekEXS.</li>
|
||||
<li>Tools/media: preserve implicit allow-all semantics from <code>tools.alsoAllow</code>-only policies when preconstructing built-in media generation and PDF tools, so configured media tools become live without forcing <code>tools.allow: ["*", ...]</code>. Fixes #77841. Thanks @trialanderrorstudios.</li>
|
||||
<li>Codex/Telegram: separate code-mode tool progress from final replies, render bridged tool calls with native tool labels, and repair persisted missing tool results for safer follow-up turns. (#80663) Thanks @jalehman.</li>
|
||||
<li>Memory/search: load the platform-specific <code>sqlite-vec-<platform>-<arch></code> variant directly when the meta <code>sqlite-vec</code> package is missing from a global install, so vector recall keeps working on <code>npm install -g openclaw@latest</code> upgrades where optionalDependencies left only the platform variant on disk. Fixes #77838. Thanks @corevibe555 and @Simon2256928.</li>
|
||||
<li>Cron: keep long manual cron runs active in the task registry until completion, preventing transient <code>lost</code> markers before durable recovery reconciles. Fixes #78233. (#78243) Thanks @Feelw00.</li>
|
||||
<li>Doctor/GitHub CLI: surface a <code>GH_CONFIG_DIR</code> hint when the GitHub skill is usable but <code>gh</code> auth lives under a different operator HOME than the agent process, without warning for disabled or filtered skills. Fixes #78063. (#78095) Thanks @tmimmanuel.</li>
|
||||
<li>Gateway: dedupe concurrent <code>send</code>, <code>poll</code>, and <code>message.action</code> requests while delivery is still in flight, preventing duplicate outbound work for the same idempotency key. (#68341) Thanks @thesomewhatyou.</li>
|
||||
<li>Cron: keep main-session <code>systemEvent</code> heartbeat wakes on their bound session route for both direct and queued wake paths by dropping inherited explicit heartbeat destinations when forcing <code>target: "last"</code>. Fixes #73900. Thanks @richardmqq.</li>
|
||||
<li>Telegram: honor forced document delivery for video media so <code>--force-document</code> sends MP4s as documents instead of typed videos. Fixes #80389. (#80405) Thanks @jbetala7.</li>
|
||||
<li>Gateway: clear speculative node wake state when APNs registration is missing, preventing unregistered or mistyped node IDs from retaining wake throttle entries. Fixes #68847. (#68848) Thanks @Feelw00.</li>
|
||||
<li>Auto-reply: keep late follow-up queue drain finalizers from deleting a replacement queue registered after <code>/stop</code>, preventing immediate follow-up messages from being orphaned. Fixes #68838. (#68839) Thanks @Feelw00.</li>
|
||||
<li>Feishu: make manual App ID/App Secret setup the default channel-binding path while keeping QR scan-to-create as an optional best-effort flow, and document the manual fallback for domestic Feishu mobile clients that do not react to the QR code. Fixes #80591. Thanks @wei-wei-zhao.</li>
|
||||
<li>Memory: cap dreaming promotion writes to <code>MEMORY.md</code> by compacting oldest auto-promoted sections while preserving user-authored notes, keeping active memory below the bootstrap budget. Fixes #73691. (#74088) Thanks @YB0y.</li>
|
||||
<li>Telegram: show resolved thinking defaults in native <code>/status</code> and <code>/think</code> menus while preserving explicit session overrides. (#80341) Thanks @VACInc.</li>
|
||||
<li>Channels: cache selected channel registry lookups against the active fallback snapshot so pinned-empty registries refresh native command and alias routing after active registry swaps. (#80333) Thanks @samzong.</li>
|
||||
<li>Codex app-server: reuse native Codex CLI OAuth for isolated app-server harness login, refresh, and app inventory cache keys so ChatGPT-authenticated Codex runs no longer fall back to unauthenticated OpenAI API calls. (#79877) Thanks @jeffjhunter.</li>
|
||||
<li>Gateway: scope <code>sessions.resolve</code> sessionId and label store loads to the requested agent so large unrelated agent stores are not parsed for scoped lookups. Fixes #51264. (#79474) Thanks @samzong.</li>
|
||||
<li>Gateway: share serialized streaming event envelopes across eligible WebSocket and node subscribers while preserving per-client sequence numbers. (#80299) Thanks @samzong.</li>
|
||||
<li>Gateway: consolidate duplicate <code>openclaw doctor</code> service config panels while preserving the declined-repair <code>--force</code> hint. Fixes #80287. (#78688) Thanks @YB0y.</li>
|
||||
<li>Browser: report Chrome MCP existing-session page readiness in browser status without letting status probes exceed the client timeout. Fixes #80268. (#80280) Thanks @ai-hpc.</li>
|
||||
<li>WhatsApp: route opening-phase Baileys 428 connectionClosed through the WhatsApp reconnect policy and keep post-open 428 closes retryable, so transient setup socket closes retry with WhatsApp diagnostics instead of escaping as a bare <code>channel exited</code> error. Fixes #75736; mitigates #77443. Thanks @dataCenter430.</li>
|
||||
<li>Agents: disable Pi's default filesystem resource discovery for embedded runs while keeping OpenClaw inline extension factories active, avoiding Windows event-loop stalls during first WhatsApp-triggered agent startup. Fixes #77443. Thanks @dataCenter430.</li>
|
||||
<li>Providers/self-hosted: read model-scoped llama.cpp runtime context from <code>/props.default_generation_settings.n_ctx</code> while keeping top-level <code>n_ctx</code> as a fallback, so session budgeting reflects the loaded context window. Fixes #73664. (#74057) Thanks @brokemac79.</li>
|
||||
<li>Memory: reject symlinked directory components in configured extra memory paths before reading Markdown files. (#80331) Thanks @samzong.</li>
|
||||
<li>Sessions/transcripts: replace whole-file <code>readFile</code> scans with shared streaming helpers (<code>streamSessionTranscriptLines</code> and <code>streamSessionTranscriptLinesReverse</code>) for idempotency lookup, latest/tail assistant text reads, delivery-mirror dedupe, and compaction fork loading, so long-running sessions no longer materialize the full transcript in memory. Forward scans use <code>readline</code> over a bounded <code>createReadStream</code>; reverse scans read bounded chunks from the file end and decode complete JSONL lines newest-first without a fixed tail cap. Synthetic 200 MiB transcript: peak RSS delta drops from +252 MiB to +27 MiB while preserving malformed-line tolerance and idempotency-key return semantics. Fixes #54296. Thanks @jack-stormentswe.</li>
|
||||
<li>Browser/CDP: filter browser-internal targets from raw CDP and persistent Playwright tab selection so navigation opens real page tabs. Fixes #55734. Thanks @Demine4.</li>
|
||||
<li>WhatsApp: apply hot-reloaded <code>dmPolicy</code> and <code>allowFrom</code> settings to the active Web listener before processing new inbound DMs. Fixes #80538. Thanks @Ampaskopi129.</li>
|
||||
<li>Plugins: let <code>openclaw doctor --fix</code> repair managed plugin installs whose package entrypoints fail package-directory boundary validation after local state moves. Fixes #80592. Thanks @wei-wei-zhao.</li>
|
||||
<li>Voice-call: resume voice-originated exec approval follow-ups as internal non-delivery turns instead of rejecting them as <code>unknown channel: voice</code>. Fixes #80540. Thanks @patrickmch.</li>
|
||||
<li>Control UI: preserve the composer draft when Stop is tapped during an active chat run, preventing accidental prompt loss on mobile. Fixes #80586. Thanks @KCALLC.</li>
|
||||
<li>Infra/retry: keep jittered retry delays at or above server-supplied Retry-After lower bounds when the hint can be honored. Fixes #68541. (#68543) Thanks @Feelw00.</li>
|
||||
<li>Docs: clarify that <code>/model provider/model</code> is an exact session route, while duplicate bare model ids only use configured fallback order on non-session override paths. Refs #80562. Thanks @gaodaabao.</li>
|
||||
<li>Redact persisted secret-shaped payloads [AI]. (#79006) Thanks @pgondhi987.</li>
|
||||
<li>Agents: label <code>.openclaw/sandboxes</code> exec workdirs as sandbox runs in compact tool summaries instead of showing the full path.</li>
|
||||
<li>OpenAI Codex: surface browser OAuth and device-code login failures instead of treating failed logins as empty successful auth results. Refs #80363.</li>
|
||||
<li>CLI agents: carry runtime-only current-turn sender/reply context into CLI model prompts while keeping prompt-build hook input and transcript text clean.</li>
|
||||
<li>Control UI: keep workspace file presence checks from treating <code>fs-safe</code> stat helper failures as missing files, restoring Agents file status for existing Windows workspace files. Fixes #79953. Thanks @lovelefeng-glitch.</li>
|
||||
<li>Microsoft Foundry: report an explicit error when the Azure subscription prompt returns an id that is not present in the enabled subscription list, instead of continuing from an unsafe subscription assertion. (#62742) Thanks @oliviareid-svg.</li>
|
||||
<li>fix(matrix): gate name-based allowlist resolution [AI]. (#79007) Thanks @pgondhi987.</li>
|
||||
<li>Slack: include the bot's own root/parent message in new thread sessions so in-thread replies reach the agent with the parent text the user is responding to, instead of only <code>reply_to_id</code> metadata. Fixes #79338. Thanks @sxxtony.</li>
|
||||
<li>Docker: keep image builds on the source pnpm workspace policy so pnpm 11 can prune production dependencies without a Docker-only workspace rewrite.</li>
|
||||
<li>Agents/compaction: restore info-level gateway logs for embedded compaction start, completion, and incomplete outcomes. (#71961) Thanks @rubencu.</li>
|
||||
<li>Telegram: build reply-aware inbound turns through the shared channel context path so agents see the current reply target inline with the current message.</li>
|
||||
<li>Telegram: recover legacy message cache files that mixed JSON-array and line-delimited entries so restarted gateways preserve reply-window context. (#80567)</li>
|
||||
<li>Telegram: update the reply-context cache when messages are edited, so streamed bot replies appear in later agent context with their final text instead of the first draft.</li>
|
||||
<li>Skills/Windows: normalize compacted skill prompt locations to forward slashes after home-prefix compaction so Windows skill paths remain readable by model file tools. (#52200) Thanks @chienchandler.</li>
|
||||
<li>Control UI/Windows: update <code>@openclaw/fs-safe</code> so agent workspace file presence checks fall back correctly on Windows, preventing existing AGENTS.md, SOUL.md, TOOLS.md, IDENTITY.md, USER.md, HEARTBEAT.md, and MEMORY.md files from showing as missing. Fixes #79953. Thanks @lovelefeng-glitch.</li>
|
||||
<li>Memory: skip managed dreaming cron reconciliation warnings for ordinary cron and heartbeat hook contexts that cannot manage Gateway cron. (#77027) Thanks @rubencu.</li>
|
||||
<li>Cron: treat Codex app-server turn acceptance, CLI process spawn, and tool starts as execution milestones, preventing isolated runs from tripping the early startup watchdog after work has begun.</li>
|
||||
<li>Codex app-server: treat current-turn <code><turn_aborted></code> raw markers as terminal so interrupted native-tool turns release Discord agent sessions instead of waiting for the outer timeout.</li>
|
||||
<li>Yuanbao: bump <code>openclaw-plugin-yuanbao</code> to 2.13.1 to support <code>sourceReplyDeliveryMode: "automatic"</code> for group chat. (#79814) Thanks @loongfay.</li>
|
||||
<li>Memory: keep <code>memory_search</code> result <code>corpus</code> labels aligned with the hit source, so session transcript hits surface as <code>sessions</code> and memory-file hits stay <code>memory</code>. Fixes #72885. (#71898, #72886) Thanks @rubencu.</li>
|
||||
<li>Codex app-server: default native plugin app tool approvals to automatic so non-destructive read tools run when destructive actions are disabled.</li>
|
||||
<li>Plugins: allow untracked local source plugins in the global extensions directory to load TypeScript package entries while keeping managed installs strict about compiled runtime output. Fixes #80503. Thanks @Kaspre.</li>
|
||||
<li>Google/Gemini: normalize retired nested Gemini 3 Pro Preview ids while converting manifest catalog rows into emitted provider config, so <code>google/gemini-3.1-pro-preview</code> is used for testing instead of <code>google/gemini-3-pro-preview</code>.</li>
|
||||
<li>Google/Gemini: normalize retired nested Gemini 3 Pro Preview ids inside saved model allowlists and fallback chains, so proxy routes like <code>openrouter/google/gemini-3-pro-preview</code> are persisted as Gemini 3.1 Pro Preview.</li>
|
||||
<li>Google/Gemini: normalize retired nested Gemini 3 Pro Preview ids in configured proxy/provider-auth model catalogs, so regenerated config keeps testing <code>google/gemini-3.1-pro-preview</code> instead of <code>google/gemini-3-pro-preview</code>.</li>
|
||||
<li>Google/Gemini: normalize retired nested Gemini 3 Pro Preview ids while onboarding provider catalog presets, so setup-emitted proxy configs test <code>google/gemini-3.1-pro-preview</code> instead of <code>google/gemini-3-pro-preview</code>.</li>
|
||||
<li>Google/Gemini: normalize retired Gemini 3 Pro Preview ids in provider catalog rows during generic config writes, so unrelated config changes keep testing <code>google/gemini-3.1-pro-preview</code>.</li>
|
||||
<li>Models: keep configured fallback chains ahead of configured primary models for override selections with duplicate model ids, preventing fallback jumps to the wrong provider. Fixes #80562.</li>
|
||||
<li>Native apps: advertise the Gateway protocol compatibility range so chat and node sessions can connect to v3 gateways after additive v4 client updates.</li>
|
||||
<li>Gateway/agents: keep stale <code>sessions_send</code> ACP manager and <code>web_fetch</code> runtime chunks importable after package updates, preventing live gateways from breaking before restart. Fixes #78804. Thanks @Gomesy72.</li>
|
||||
<li>Gateway/install: preserve service environment value-source metadata in <code>openclaw gateway install</code>, so systemd reinstall paths keep env-file-backed secrets out of inline unit metadata. Refs #77406, #77427. Thanks @stainlu and @brokemac79.</li>
|
||||
<li>Auto-reply/reset: include inbound sender context in bare <code>/new</code> and <code>/reset</code> model prompts while keeping startup instructions out of transcript prompts, so agents see sender identity on the first reset turn. Fixes #77360. Thanks @srb11e.</li>
|
||||
<li>Gateway: avoid synchronous restart-sentinel state probes during post-attach startup, preventing slow Windows or redirected state directories from blocking channel turns. Fixes #79264. Thanks @liyi58.</li>
|
||||
<li>Agents/auth: update successful model auth profile status with one locked store write, reducing post-model reply latency from duplicate <code>auth-profiles.json</code> saves. Thanks @mcaxtr.</li>
|
||||
<li>Agents/image: honor explicit <code>image</code> tool model overrides even when <code>agents.defaults.imageModel</code> is unset, restoring one-off vision calls for configured multimodal providers. Fixes #79341. Thanks @haumanto.</li>
|
||||
<li>Doctor/update: leave live systemd gateway units unchanged during noninteractive update-mode service repair, so update-time doctor does not silently overwrite operator-owned unit directives. Refs #80462.</li>
|
||||
<li>Update: accept optional leading <code>v</code> prefixes when verifying exact npm package install targets, so <code>openclaw update --tag v2026...</code> does not roll back after installing the matching bare package version. Refs #74069; #80480. Thanks @Kaspre.</li>
|
||||
<li>Doctor: treat missing plugin ids in <code>plugins.deny</code> as stale config warnings instead of fatal validation errors, and remove them during stale plugin cleanup so update repair does not restore last-known-good config for deny-only stale plugin refs. Refs #77802. Thanks @Kaspre.</li>
|
||||
<li>Codex app-server: preserve prompt-local current-turn context through context-engine prompt projection, so replied-to Telegram messages stay visible to the Codex model input.</li>
|
||||
<li>Telegram: pass agent-scoped media roots through gateway message actions so workspace-local media from the active agent is not rejected as cross-agent access. Thanks @frankekn.</li>
|
||||
<li>CLI/gateway: keep <code>gateway status --deep</code> plugin-aware so configured plugin manifest warnings, including missing channel config metadata, stay visible during install and update smoke checks.</li>
|
||||
<li>Doctor/status: clarify gateway token source conflict warnings and suppress them inside the managed Gateway service credential context.</li>
|
||||
<li>Feishu: accept Schema 2 card callbacks whose operator identity is nested under <code>operator.user_id</code>, so card buttons dispatch instead of being dropped as malformed. Fixes #71670. (#71787) Thanks @rubencu.</li>
|
||||
<li>Feishu: fall back to a top-level group send when normal group quoted replies target a withdrawn or missing message, preventing replies from disappearing silently while preserving native topic safety. Fixes #79349. Thanks @arlen8411.</li>
|
||||
<li>Doctor: stop flagging the live compatibility agent directory as orphaned when the configured default agent is not <code>main</code>. Fixes #74313. (#74438) Thanks @carlos4s.</li>
|
||||
<li>Auth/Claude CLI: persist fresher managed external CLI OAuth credentials back to <code>auth-profiles.json</code>, preventing stale <code>anthropic:claude-cli</code> profiles from repeatedly bootstrapping and flooding debug logs. Fixes #80129. Thanks @Caulderein.</li>
|
||||
<li>Context: render <code>/context map</code> only from actual run context and persist Codex app-server run reports without counting deferred tool-search schemas as prompt-loaded tool schemas.</li>
|
||||
<li>Codex app-server: report Codex-native tool execution to diagnostics so long-running native <code>bash</code>, web, file, and MCP tools no longer look like stale embedded runs to the watchdog. (#80217)</li>
|
||||
<li>Codex app-server: refresh Codex account rate limits after subscription usage-limit failures so Discord and other channel replies can show the next reset time instead of saying Codex returned none. Thanks @pashpashpash.</li>
|
||||
<li>Agents/auth: let Codex-backed OpenAI agent turns use <code>auth.order.openai</code> entries for Codex-compatible OAuth and API-key profiles while keeping existing <code>openai-codex</code> profile ordering valid.</li>
|
||||
<li>Codex app-server: emit async <code>after_tool_call</code> observations for native tool completions not covered by the native hook relay so observability plugins can record Codex-native tools. (#80372) Thanks @VACInc.</li>
|
||||
<li>Tasks: route group and channel task completions through the requester session so the parent agent can send the visible summary instead of stopping at a generic task-status line. Fixes #77251. (#77365) Thanks @funmerlin.</li>
|
||||
<li>Telegram: preserve blank lines between manually indented bullet blocks and following numbered sections in rendered replies. Fixes #76998. Thanks @evgyur.</li>
|
||||
<li>Agents/sandbox: allow read-only sandbox sessions to read the <code>/agent</code> workspace mount while keeping write/edit/apply_patch workspace-only guarded, restoring <code>read /agent/...</code> for <code>workspaceAccess: "ro"</code>. Fixes #39497. Thanks @stainlu and @teosborne.</li>
|
||||
<li>Slack: pass configured agent identity through draft preview sends so partial streaming replies keep custom username/avatar on the initial Slack message. Fixes #38235. (#38237) Thanks @lacymorrow.</li>
|
||||
<li>Slack: support <code>allowBots: "mentions"</code> for bot-authored messages that mention the receiving bot, matching the documented Discord-style mode without accepting every bot message. Fixes #43587. (#43588) Thanks @raw34.</li>
|
||||
<li>Slack: refresh private file URLs with <code>files.info</code> when inbound DM file events omit or stale attachment URLs, preventing file attachments from being dropped before media hydration. Fixes #50129. (#50200) Thanks @smartchainark.</li>
|
||||
<li>Slack: add scoped message-tool formatting hints so agents use Markdown for plain sends and direct mrkdwn for Block Kit fields. Fixes #34609. (#50979) Thanks @carrotRakko.</li>
|
||||
<li>Slack: describe <code>download-file</code> file ids separately from message timestamps and return a targeted recovery error when agents pass <code>messageId</code> instead of <code>fileId</code>. (#74155) Thanks @jarvis-ai-gregmoser.</li>
|
||||
<li>Slack: retain processed room messages for <code>requireMention=false</code> channels so always-on Slack rooms keep recent conversation context between turns. (#38658) Thanks @syedamaann.</li>
|
||||
<li>Slack: compile interactive reply directives for direct outbound sends without bypassing the <code>interactiveReplies</code> capability gate, preserving Block Kit for Slack CLI and cron deliveries. (#78220) Thanks @kazamak.</li>
|
||||
<li>Slack: keep DM last-route updates scoped to the active non-main DM session, including threaded DM turns, so isolated Slack DM sessions do not overwrite the shared main route. (#73085) Thanks @clawSean.</li>
|
||||
<li>Slack/ACP: route Slack channel and DM messages through configured ACP bindings when no runtime binding exists, keeping bound thread replies pinned to the persistent ACP session and dropping unavailable configured targets instead of falling back to <code>main</code>. (#73101) Thanks @Raasl.</li>
|
||||
<li>Slack: mark unresolved thread replies as ambiguous and skip them instead of treating them as root channel messages, keeping thread continuation on the SDK-backed participation store. (#75630) Thanks @soichiyo.</li>
|
||||
<li>Slack: let same-channel message tool sends opt out of inherited thread context with <code>topLevel: true</code> or <code>threadId: null</code>, allowing agents to post a new parent-channel message from inside a Slack thread. Fixes #79807. Thanks @vexclawx31.</li>
|
||||
<li>Slack: prefer full rich-text block content over truncated socket-mode message previews so long inbound Slack messages reach agents intact. Fixes #79027. Thanks @BobAccentWebDev.</li>
|
||||
<li>Slack: include structured Slack API error details in setup, probe, streaming, and reply logs while preserving token redaction. (#53966) Thanks @deucemask.</li>
|
||||
<li>Gateway/agents: keep structured reasons when active-run queueing fails and deprecate the legacy boolean queue helper, so steering and subagent wake diagnostics distinguish completed, non-streaming, and compacting runs. Fixes #80156. Thanks @markus-lassfolk.</li>
|
||||
<li>System events: dedupe keyed events across the queue while preserving unkeyed, delivery-route, and trust-boundary event identity. (#73040) Thanks @statxc.</li>
|
||||
<li>Agents/UI: compact exec and tool progress rows by hiding redundant shell tool names, replacing known workspace paths with short context markers, and preserving Discord trace scrubbing for compact command lines.</li>
|
||||
<li>ACPX: run and await the embedded ACP backend startup probe by default so the gateway <code>ready</code> signal no longer fires before the acpx runtime has either become usable or reported a probe failure; set <code>OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE=0</code> to restore lazy startup. Fixes #79596. Thanks @bzelones.</li>
|
||||
<li>Gateway/status: surface model-pricing bootstrap and refresh failures as degraded health/status warnings while keeping Gateway liveness healthy. Fixes #79599. Thanks @bzelones.</li>
|
||||
<li>OpenAI-compatible models: strip prior assistant reasoning fields from replayed Chat Completions history by default, preventing oMLX/vLLM Qwen follow-up turns from rejecting or stalling on stale <code>reasoning</code> payloads. Fixes #46637. Thanks @zipzagster and @lexhoefsloot.</li>
|
||||
<li>CLI/onboarding: give non-Azure custom providers a safe generated context window and heal legacy 4k wizard entries without overwriting explicit valid small model limits, preventing first-turn compaction loops. Fixes #79428. (#79911) Thanks @Jefsky.</li>
|
||||
<li>OpenAI-compatible models: add <code>compat.strictMessageKeys</code> to strip Chat Completions replay messages to <code>role</code> and <code>content</code> for strict providers that reject OpenAI-style tool and metadata keys. Fixes #50374. Thanks @choutos.</li>
|
||||
<li>Bedrock Mantle: add <code>plugins.entries.amazon-bedrock-mantle.config.discovery.enabled=false</code> to suppress automatic Mantle discovery and IAM bearer-token generation while keeping the plugin enabled. Fixes #67288. Thanks @kanekoh.</li>
|
||||
<li>Ollama: stop native <code>/api/chat</code> requests from copying catalog <code>contextWindow</code> or <code>maxTokens</code> into <code>options.num_ctx</code> unless <code>params.num_ctx</code> is explicitly configured, avoiding pathological prompt-ingestion latency on local large-context models. Fixes #62267. Thanks @BenSHPD.</li>
|
||||
<li>Ollama: keep the model idle watchdog enabled for <code>*:cloud</code> models routed through a local Ollama host, so cloud-backed tool-loop stalls fail over visibly instead of inheriting local-model no-idle behavior. Fixes #79350. Thanks @geek111.</li>
|
||||
<li>Voice/Ollama: honor routed voice agent <code>tools.allow</code> for classic embedded voice responses, including empty allowlists, so no-tool Ollama agents do not receive tool schemas. Fixes #79506. Thanks @donkeykong91.</li>
|
||||
<li>Agents/doctor: warn when channel-routed agents cannot call the <code>message</code> tool, so operators can fix tool policy mismatches before explicit channel actions such as attachments or thread replies fail. Refs #80128. Thanks @jeffjhunterai.</li>
|
||||
<li>Gateway: reread config from disk after the first in-process restart loop startup, preventing SIGUSR1 restarts from reusing a stale startup snapshot and dropping config written after boot. Fixes #79947. Thanks @TheLevti.</li>
|
||||
<li>Codex app-server: deliver native image-generation outputs from Codex <code>savedPath</code> events as reply media, so blank-text image generation turns still attach the generated file. Thanks @keshavbotagent.</li>
|
||||
<li>Network/SSRF: keep pinned automatic DNS lookups on IPv4 when dual-stack hosts also publish AAAA records, and treat <code>EADDRNOTAVAIL</code> as a transient gateway network failure instead of a fatal crash. Fixes #80078. Thanks @takamasa-aiso.</li>
|
||||
<li>Control UI: show compact one-line live/idle/terminal run status badges in the Sessions table and rename the active-minute filter to its updated-within meaning. Fixes #78307. Thanks @BunsDev.</li>
|
||||
<li>Control UI: scope chat session-list refreshes by agent and skip disk-only agent store discovery for configured-only lists, preventing post-first-message session switching stalls on large Windows stores. Fixes #79675. Thanks @lovelefeng-glitch, @BunsDev.</li>
|
||||
<li>Control UI: allow Appearance tweakcn theme imports through the served CSP so browser-local custom theme links no longer fail with a <code>connect-src</code> violation. Fixes #78504. Thanks @BunsDev.</li>
|
||||
<li>Control UI/config: remove plugin allowlist entries that the form auto-added when a plugin enable toggle is reverted before saving, so reverting the visible toggle clears dirty state without persisting unintended allowlist changes. (#78329) Thanks @samzong.</li>
|
||||
<li>Gateway/mobile: reuse bootstrap-issued device-token scopes on handoff reconnects and surface device-token scope mismatches separately from token mismatches while preserving full shared-token dashboard/native sessions. Fixes #79292. Thanks @BunsDev.</li>
|
||||
<li>Media/host-read: allow buffer-verified gzip, tar, and 7z archives in the shared host-local media validator alongside ZIP and document attachments.</li>
|
||||
<li>Plugins/install: retry managed npm plugin installs without npm alias overrides after npm's <code>Invalid comparator: npm:</code> failure, so older npm versions can install official plugins instead of aborting. (#80539) Thanks @rubencu.</li>
|
||||
<li>Plugins/doctor: invalidate persisted plugin registry snapshots when plugin diagnostics point at deleted source paths, so <code>openclaw doctor</code> stops repeating stale warnings after a local extension is replaced by a managed npm plugin. Fixes #80087. (#80134) Thanks @hclsys.</li>
|
||||
<li>Doctor/OpenAI Codex: preserve Codex auth intent when auto-repairing legacy <code>openai-codex/*</code> model refs to canonical <code>openai/*</code> by adding provider/model-scoped Codex runtime policy, preventing repaired configs from falling through to direct OpenAI API-key auth. Fixes #78533 and #78570. Thanks @superck110 and @Azmodump.</li>
|
||||
<li>CLI/agents: surface durable message delivery status from <code>sendDurableMessageBatch</code> in <code>deliverAgentCommandResult</code> and <code>openclaw agent --json --deliver</code>, preserving suppressed hook outcomes as terminal no-retry results while exposing partial and failed sends for automation. Supersedes #53961 and #57755. Thanks @Kaspre.</li>
|
||||
<li>Agents: apply the LLM idle watchdog while provider stream setup is still pending, preventing silent pre-stream model hangs from waiting for the full agent timeout.</li>
|
||||
<li>Cron: let isolated self-cleanup runs inspect their own job run history while keeping other cron jobs and mutation actions blocked. Fixes #80019. Thanks @hclsys.</li>
|
||||
<li>Cron: report isolated agent-turn setup and pre-model stalls with phase-specific timeout errors instead of waiting for the full job budget when no model call starts. Fixes #74803. Thanks @jeffsteinbok-openclaw and @dgkim311.</li>
|
||||
<li>CLI/plugins: treat arbitrary unknown subcommands outside plugin CLI metadata as normal unknown commands instead of suggesting <code>plugins.allow</code>, while preserving allowlist guidance for real plugin command roots. Fixes #80109. (#80123) Thanks @kagura-agent.</li>
|
||||
<li>CLI/config: persist explicit <code>config set</code> and <code>config patch</code> values that equal runtime defaults instead of reporting success while dropping them. Fixes #79856. (#80106) Thanks @abodanty and @hclsys.</li>
|
||||
<li>OpenAI/realtime voice: accept Codex-compatible legacy audio and transcript event aliases so provider protocol drift does not drop assistant audio or captions.</li>
|
||||
<li>Discord/voice: keep default agent-proxy realtime sessions from auto-speaking filler before the forced OpenClaw consult answer, finish Discord playback on realtime response completion, and queue later exact-speech answers until playback idles to avoid mid-sentence replacement.</li>
|
||||
<li>Gateway: return deterministic <code>400 invalid_request_error</code> responses for malformed encoded session-kill HTTP paths instead of letting route-shaped requests fall through to later Gateway handlers. (#72439) Thanks @rubencu.</li>
|
||||
<li>Control UI: serve root PWA and favicon assets from <code>/__openclaw__/</code> SPA routes so tab icons, install metadata, and the service worker do not 404 after internal navigation. Fixes #80072. Thanks @CodeNovice2017.</li>
|
||||
<li>Exec/safe bins: compare trusted safe-bin dirs with path-specific case folding on case-insensitive filesystems so Windows and default macOS paths match without weakening case-sensitive mounts. (#42131) Thanks @hkochar.</li>
|
||||
<li>OpenAI/realtime voice: honor disabled input-audio interruption locally so server VAD speech-start events do not clear Discord playback after operators set <code>interruptResponseOnInputAudio: false</code>.</li>
|
||||
<li>Telegram: keep no-response DM turns quiet instead of rewriting them into visible silent-reply chatter. Fixes #78188. (#78228) Thanks @Beandon13.</li>
|
||||
<li>Telegram: handle managed select button callbacks before the raw callback fallback while preserving delimiter-containing option values such as <code>env|prod</code>. (#79816) Thanks @moeedahmed.</li>
|
||||
<li>OpenAI-compatible models: handle JSON chat-completion bodies returned to streaming requests, preserving reasoning fields and visible text instead of completing an empty agent turn. Fixes #77870.</li>
|
||||
<li>Discord/models: defer model picker component interactions before loading route, model, and preference data, preventing "This interaction failed" timeouts under gateway load. Fixes #77283. Thanks @colin-chang.</li>
|
||||
<li>xAI: expose <code>/think low|medium|high</code> for reasoning-capable Grok models and keep <code>reasoning.effort</code> on native Responses payloads while preserving off-only behavior for non-reasoning routes. Fixes #79210. Thanks @colinmcintosh.</li>
|
||||
<li>CLI/media: let explicit image description model refs use bundled static provider catalogs and generic model-backed image hooks, so <code>openclaw infer image describe --model zai/glm-4.6v</code> works like direct model runs and Anthropic auth probes avoid stale Claude 3 Haiku catalog entries.</li>
|
||||
<li>Models/Anthropic: add <code>anthropic/claude-haiku-4-5</code> to Anthropic API-key agent allowlist defaults when an Anthropic default model is configured, so cron model overrides can select the current Haiku alias. Fixes #78000.</li>
|
||||
<li>Agents/compaction: initialize built-in context engines before CLI transcript compaction resolves the default engine, preventing clean-process <code>legacy</code> engine registration failures during CLI session persistence. Fixes #79446. Thanks @TurboTheTurtle.</li>
|
||||
<li>Agents/Anthropic-compatible: strip replayed thinking blocks for custom Anthropic-compatible models that explicitly declare <code>supportsReasoningEffort: false</code>, preventing Kimi-compatible providers from resending unsupported <code>thinking</code> content. Fixes #47452.</li>
|
||||
<li>Kimi: keep Anthropic-compatible thinking streams valid by supplying required thinking budgets and enough output room for hidden reasoning plus final text. (#80481) Thanks @InTheCloudDan.</li>
|
||||
<li>Browser: wait longer for existing-session Chrome MCP status and non-deep doctor probes so slow first attaches do not falsely report offline while keeping raw CDP status probes short. (#77473) Thanks @rubencu.</li>
|
||||
<li>Gateway/logging: install console capture before foreground Gateway fast-path parsing and suppress known libsignal session dumps even in verbose mode, preventing raw terminal logs from printing WhatsApp session key material. (#76306) Thanks @rubencu.</li>
|
||||
<li>Exec approvals: keep <code>exec.approval.list</code> on the lightweight policy-summary path so listing pending approvals no longer loads the rich tree-sitter command explainer. (#76943) Thanks @rubencu.</li>
|
||||
<li>Agents: surface concise default-visible warnings when <code>exec</code>/<code>bash</code> tool calls fail after the assistant claims success, while keeping raw stderr hidden unless verbose details are enabled. Fixes #60497. (#80003) Thanks @jbetala7.</li>
|
||||
<li>Channels/iMessage: keep redacted failed probe details in non-sensitive health snapshots so Full Disk Access failures no longer appear as configured/OK in status output. Fixes #79795.</li>
|
||||
<li>Agents: stop blank model-emitted tool calls before dispatch while preserving id-based tool-name recovery, preventing Kimi/NVIDIA blank-name retry loops without creating a callable <code>_blank</code> sentinel. Fixes #34129. (#56391) Thanks @smartchainark.</li>
|
||||
<li>Agents/Telegram: deliver the canonical final assistant answer instead of replaying accumulated pre-tool text blocks, preventing duplicate Telegram replies and raw-looking tool-output fragments from leaking into chat delivery. Fixes #79621 and #79986. Thanks @nonzeroclaw and @dudaefj.</li>
|
||||
<li>Auto-reply/TUI: keep fallback timeout recovery deliverable after a primary model lifecycle error by emitting fallback progress and deferring terminal TUI errors until recovery has a chance to finish. Fixes #80000. (#80009) Thanks @TurboTheTurtle.</li>
|
||||
<li>Heartbeat: clear stale auto fallback model overrides when the configured default model changes, so heartbeat runs follow updated <code>agents.defaults.model.primary</code> without requiring a manual reset. Fixes #74284. Thanks @brtkwr and @bitloi.</li>
|
||||
<li>CLI/agent: let <code>openclaw agent --model</code> use the backend/admin Gateway scope without cached device-token scopes silently downscoping the request. (#78837) Thanks @VACInc.</li>
|
||||
<li>CLI/help: keep help and version invocations configless while improving shared port, channel, plugin, task, session, message, pairing, and auth recovery text.</li>
|
||||
<li>CLI/config: explain strict JSON parse failures with a valid example and the plain-string escape hatch.</li>
|
||||
<li>CLI/secrets: turn offline Gateway reload failures into actionable recovery text.</li>
|
||||
<li>CLI/channels: explain missing or ambiguous channel selections with next commands.</li>
|
||||
<li>CLI/channels: defer guided channel status collection until a channel is selected, keeping <code>openclaw channels add</code> first screen quieter.</li>
|
||||
<li>CLI/channels: exit guided channel setup cleanly on cancellation instead of printing the internal wizard error.</li>
|
||||
<li>Plugins/CLI: route disabled Matrix and LanceDB memory command roots to plugin-enable guidance instead of generic unknown-command errors.</li>
|
||||
<li>Browser/Docker: detect Playwright-managed Chromium from <code>PLAYWRIGHT_BROWSERS_PATH</code> and the default Playwright cache on Linux, so Docker installs that persist <code>/home/node/.cache/ms-playwright</code> no longer need <code>browser.executablePath</code>.</li>
|
||||
<li>Ollama: keep DeepSeek V4 cloud models thinking-capable even when Ollama Cloud <code>/api/show</code> omits the <code>thinking</code> capability, so <code>/think high</code> no longer rejects <code>ollama/deepseek-v4-*:cloud</code>.</li>
|
||||
<li>ACPX/Claude ACP: keep foreground prompts waiting for their own result when autonomous task-notification results arrive during the same session, and retarget the patch for Claude Agent ACP <code>0.33.1</code>.</li>
|
||||
<li>WhatsApp: keep Baileys media uploads from passing non-Dispatcher agents to undici in <code>7.0.0-rc10</code>, and patch the bundled Baileys declaration so the latest tsdown build stays warning-clean.</li>
|
||||
<li>Build: keep tsdown <code>0.22.0</code> warning-clean by externalizing known third-party declaration edges and replacing relative channel config module augmentations with explicit built-in channel fields.</li>
|
||||
<li>ACP sessions: map canonical runtime options to backend-advertised ACP config keys like Claude's <code>effort</code> while keeping persisted OpenClaw state canonical. (#79926) Thanks @InTheCloudDan.</li>
|
||||
<li>Models/Discord: support <code>provider/*</code> entries in <code>agents.defaults.models</code> so <code>/model</code>, <code>/models</code>, and model pickers can show dynamically discovered models for selected providers without exact model allowlists. Fixes #79485. Thanks @rendrag-git.</li>
|
||||
<li>Gateway/watch: rebuild or restage missing bundled-plugin dist and runtime-postbuild outputs before launching the Gateway from a source checkout, preventing incomplete watch-mode runtime trees. (#70805) Thanks @rubencu.</li>
|
||||
<li>CLI/update: allow restart health probes from the previous gateway protocol during self-update, and make plugin dry-runs report exact npm target versions instead of <code>unknown</code> while preserving unchanged status.</li>
|
||||
<li>OpenAI/Codex: forward persisted <code>openai-codex</code> OAuth profile metadata into Codex plugin harness attempts after canonical <code>openai/*</code> migration, so OAuth-only installs keep using native Codex auth instead of falling through to direct OpenAI API-key auth. Fixes #79978.</li>
|
||||
<li>OpenAI/Codex: point gateway missing-key recovery and wizard docs at the canonical <code>openai/gpt-5.5</code> plus Codex OAuth route, and fix trajectory export errors so they suggest the valid <code>openclaw sessions</code> command.</li>
|
||||
<li>Google/Gemini: normalize retired <code>google/gemini-3-pro-preview</code> primary, fallback, and model-map refs during config load and unrelated config writes so saved config keeps targeting Gemini 3.1 Pro Preview.</li>
|
||||
<li>Google/Gemini: normalize retired Gemini 3 Pro Preview ids inside emitted Google provider model config, so regenerated models.json rows test <code>google/gemini-3.1-pro-preview</code>.</li>
|
||||
<li>Google/Gemini: normalize retired Gemini 3 Pro Preview ids for explicit OpenAI-compatible Google and Gemini CLI provider configs, so emitted config targets <code>google/gemini-3.1-pro-preview</code>.</li>
|
||||
<li>Google/Gemini: normalize retired Gemini 3 Pro Preview ids preserved from existing merged models.json providers so config emission keeps targeting <code>google/gemini-3.1-pro-preview</code>.</li>
|
||||
<li>Google/Gemini: normalize retired Gemini 3 Pro Preview ids inside provider auth config patches so setup-emitted provider catalogs test <code>google/gemini-3.1-pro-preview</code>.</li>
|
||||
<li>GitHub Copilot: mint short-lived Copilot API tokens with the same <code>vscode-chat</code> integration identity used by runtime requests, and refresh legacy cached tokens missing that identity so image-capable Copilot models no longer inherit the <code>copilot-language-server</code> scope. Fixes #79946, #80074. Thanks @TurboTheTurtle.</li>
|
||||
<li>Plugins/doctor: drop stale managed npm install records when <code>openclaw doctor --fix</code> removes npm packages that shadow bundled plugins, so the rebuilt registry no longer resurrects the removed package metadata.</li>
|
||||
<li>Doctor: warn when a per-agent model config omits the <code>fallbacks</code> key and <code>agents.defaults.model.fallbacks</code> is non-empty. Covers both string-form (<code>"model": "..."</code>) and partial-object form (<code>"model": { "primary": "..." }</code>) — both silently clobber the defaults chain at runtime. Use <code>"fallbacks": []</code> to explicitly opt out of fallbacks, or add <code>"fallbacks": [...]</code> to inherit or override. Fixes #79369. Thanks @Kaspre.</li>
|
||||
<li>Discord/voice: reuse or suppress late realtime consult tool calls without stealing newer speaker context or speaking forced fallback answers twice.</li>
|
||||
<li>Discord/voice: skip likely incomplete realtime forced-consult transcript fragments and non-actionable closings so stale partial speech does not queue delayed answers over the next turn.</li>
|
||||
<li>Discord/voice: keep realtime forced consults from clearing active exact-speech playback, so back-to-back voice answers queue instead of cutting each other off.</li>
|
||||
<li>Discord/voice: synthesize realtime playback timestamps from emitted Discord PCM so OpenAI realtime barge-in truncation no longer sees <code>audioEndMs=0</code> and skips legitimate interruptions.</li>
|
||||
<li>Plugin SDK: keep activated linked plugin runtime facades loadable when bundled plugin fallback is disabled. Thanks @shakkernerd.</li>
|
||||
<li>Feishu: auto-thread <code>message(action="send")</code> replies inside the topic when the active session is group_topic or group_topic_sender, and propagate <code>replyInThread</code> through text, card, and media outbound adapters so topic-scoped sessions no longer post at the group root. Fixes #74903. (#77151) Thanks @ai-hpc.</li>
|
||||
<li>WhatsApp: pass routing context into voice-note transcript echo preflight so echoed transcripts can deliver to the originating chat. Fixes #79778. (#79788) Thanks @hclsys.</li>
|
||||
<li>Cron/failover: classify structured OpenAI-compatible <code>server_error</code> payloads as <code>server_error</code>, expose that reason in cron state, and let one-shot cron retry policy honor <code>retryOn: ["server_error"]</code> without requiring raw <code>5xx</code> text. (#45594) Thanks @clovericbot.</li>
|
||||
<li>Slack: wake the resolved thread session after interactive reply button/select clicks and carry Slack delivery context through the queued interaction event, so clicks continue the visible conversation. Fixes #79676 and #61502. (#79836) Thanks @velvet-shark, @tianxiaochannel-oss88, and @Saicheg.</li>
|
||||
<li>WhatsApp/streaming: send only the new suffix when text-end block replies repeat prior preambles across tool-call cycles, preventing cumulative WhatsApp preamble messages. Fixes #78946. (#79120) Thanks @brokemac79 and @papawattu.</li>
|
||||
<li>Tests/security audit: sandbox <code>audit-exec-surface.test.ts</code> under a per-case OpenClaw home tempdir, redirecting <code>OPENCLAW_HOME</code> (which wins over <code>HOME</code>/<code>USERPROFILE</code> in <code>resolveRawHomeDir</code>) alongside <code>HOME</code> and <code>USERPROFILE</code>, so its <code>saveExecApprovals(...)</code> calls never touch the live <code>~/.openclaw/exec-approvals.json</code> on the host running the suite. Sibling exec-approvals tests already used the tempdir pattern; this file did not, so running <code>pnpm test</code> against a contributor's local checkout was silently truncating their real approvals to <code>{ "version": 1, "agents": {} }</code>. (#79885) Thanks @omarshahine.</li>
|
||||
<li>ACP/gateway: preserve <code>AcpRuntimeError</code> cause chain (code/method/JSON-RPC detail) through the lifecycle boundary so gateway logs, telegram replies, and tool-result text show the actual upstream failure instead of opaque <code>Internal error</code>/<code>[object Object]</code>, with redaction applied before the chain reaches log or reply surfaces.</li>
|
||||
<li>Channels/iMessage: wire <code>action: "reply"</code> attachments through <code>imsg send-rich --file</code> when the installed imsg build advertises that capability (probed once via <code>imsg send-rich --help</code> and cached on the private-API status). Reply now hydrates <code>media</code>/<code>mediaUrl</code>/<code>fileUrl</code>/<code>mediaUrls[0]</code>/<code>filePath</code>/<code>path</code>/base64 <code>buffer</code>+<code>filename</code> through the shared outbound resolver, stages buffers via the existing <code>withTempFile</code> helper, rejects <code>http(s)://</code> URL attachments with a targeted error pointing callers at <code>send</code>'s full attachment-resolver pipeline, and falls back to the explicit <code>imsg#114 not landed yet</code> error on older imsg builds. Depends on the upstream <code>openclaw/imsg#114</code> capability landing in an installable release; until then the new path stays gated and users see the same explicit fallback <code>#79822</code> introduced. (#79864) Thanks @omarshahine.</li>
|
||||
<li>Telegram: preserve the first-preview debounce while appending true partial-stream deltas, so edited draft previews no longer duplicate earlier text when providers emit incremental output. (#80045) Thanks @TurboTheTurtle.</li>
|
||||
<li>Agents/Anthropic: report 1M session context for Claude Opus/Sonnet 4 models even when local model config still advertises 200k, matching model discovery and preventing premature status/UI overflow. Fixes #66766.</li>
|
||||
<li>Models/OpenRouter: hide missing-auth direct provider rows in <code>/model status</code> when they are only duplicated by a nested OpenRouter model id such as <code>openrouter/google/...</code>, while preserving explicitly configured direct providers. Fixes #62317.</li>
|
||||
<li>Models: preserve an explicitly selected provider/model such as <code>opencode-go/deepseek-v4-pro</code> when another provider owns the same bare model alias. Fixes #79325.</li>
|
||||
<li>Models/config: explain missing <code>models.providers.<provider>.models[]</code> registration when a model exists only in <code>agents.defaults.models</code>, instead of returning a bare unknown-model error. Fixes #80089.</li>
|
||||
<li>MCP/tools: prefix bundle MCP server/tool fragments that would start with digits, keeping generated tool names valid for Moonshot/Kimi and other strict providers. Fixes #79179.</li>
|
||||
<li>Models/OpenRouter: treat <code>403 API key budget limit exceeded</code> as billing so model fallback advances instead of retrying the exhausted primary. Fixes #60191. Thanks @omgitsgela.</li>
|
||||
<li>Models/OpenRouter: repair stale session overrides that lost the outer <code>openrouter/</code> provider wrapper, so sessions return to the configured OpenRouter model instead of failing as an unknown direct-provider model. Fixes #78161. Thanks @hjamal7-bit.</li>
|
||||
<li>Google/Gemini: default API-key onboarding back to <code>google/gemini-3.1-pro-preview</code> so fresh Gemini test configs exercise Gemini 3.1 Pro Preview.</li>
|
||||
<li>Telegram: show full provider/model labels for nested OpenRouter model ids in the model picker, so <code>openrouter/openai/gpt-5.4-mini</code> no longer displays as <code>openai/gpt-5.4-mini</code>. Fixes #67792. (#72752) Thanks @iot2edge.</li>
|
||||
<li>Models/OpenRouter: preserve live <code>supported_parameters</code> tool support metadata so non-tool Perplexity Sonar models no longer receive agent tool payloads and fall back unnecessarily. Fixes #64175. Thanks @Catfish-75.</li>
|
||||
<li>Models/OpenRouter: add MoonshotAI Kimi K2.5 to the bundled OpenRouter catalog so onboarding/model pickers can offer it without waiting for live discovery. Fixes #14601.</li>
|
||||
<li>Models/OpenRouter: keep keyRef/tokenRef-backed auth profiles visible to read-only PI model discovery, so OpenRouter models stay available in model pickers without storing plaintext keys. Fixes #58106. Thanks @ThalynLabs.</li>
|
||||
<li>Models/list: include explicit configured provider rows and read-only auth-backed catalog rows in the default configured view without loading PI's full registry, keeping Control UI pickers aligned with usable model auth. Refs #79381. Thanks @ismael-81.</li>
|
||||
<li>Security/audit: honor <code>tools.byProvider["provider/model"].deny</code> when reporting small-model web/browser exposure, so per-model OpenRouter mitigations clear the <code>models.small_params</code> exposure signal. Fixes #80118.</li>
|
||||
<li>Models/Moonshot: accept direct <code>moonshotai/...</code> and <code>moonshot-ai/...</code> refs as aliases for canonical <code>moonshot/...</code>, so copied OpenRouter Kimi ids no longer fail as unknown direct models. Fixes #73876. (#74946) Thanks @jeffrey701.</li>
|
||||
<li>Kimi Code: use Kimi's stable <code>kimi-for-coding</code> API model id in bundled catalog, onboarding, and docs while normalizing legacy <code>kimi-code</code> and <code>k2p5</code> refs. Fixes #79965.</li>
|
||||
<li>Telegram: render cached reply targets and nearby group chatter as one selected conversation context window, so stale replies no longer split JSON reply chains from local chat context.</li>
|
||||
<li>Volcengine/Kimi: strip provider-unsupported tool schema length and item constraint keywords for direct and coding-plan models so hosted Kimi runs do not reject message tools with <code>minLength</code>. Fixes #38817.</li>
|
||||
<li>DeepSeek: backfill V4 <code>reasoning_content</code> replay fields for unowned OpenAI-compatible proxy providers, preventing follow-up request failures outside the bundled DeepSeek and OpenRouter routes. Fixes #79608.</li>
|
||||
<li>iMessage: emit a WARN log when an action is blocked because the imsg private API bridge is not attached, so operators see the silent-drop in <code>~/.openclaw/logs/openclaw.log</code> instead of having to read per-session trajectory JSONL <code>tool.result</code> payloads. Common after a gateway restart un-injects the dylib from Messages.app. (#80035) Thanks @omarshahine.</li>
|
||||
<li>Codex: cross-fill missing <code>thread.id</code> and <code>thread.sessionId</code> before schema validation so live Codex app-server responses that omit <code>sessionId</code> no longer fail <code>thread/start</code> or <code>thread/resume</code>. Fixes #80124. (#80137) Thanks @kagura-agent.</li>
|
||||
<li>Agents/Pi: wait for embedded abort cleanup to settle before releasing the session write lock, preventing follow-up turns from racing previous prompt teardown. (#80239) Thanks @samzong.</li>
|
||||
<li>WhatsApp: downgrade OpenClaw watchdog-triggered Web reconnects from runtime errors to recovery warnings and clear the recovered reconnect status after the next healthy connection. (#77026) Thanks @rubencu.</li>
|
||||
<li>ACPX/Windows: hide the MCP proxy target child process window on Windows so ACP-backed agents do not flash or fail because of terminal window handling. Fixes #60672. (#60678) Thanks @KChow-ctrl.</li>
|
||||
<li>Agents: abort generic repeated no-progress tool loops at the critical threshold when identical calls keep returning identical outcomes. (#80668) Thanks @frankekn.</li>
|
||||
<li>Exec approvals: omit generated command highlights for non-POSIX Windows and shell-wrapper approval commands until those command languages have native highlighting support. (#80566) Thanks @jesse-merhi.</li>
|
||||
<li>Telegram: keep verbose tool progress and result drafts separate from the final assistant answer so tool output no longer blends into the final Telegram message. (#80294) Thanks @jalehman.</li>
|
||||
<li>Plugin SDK/Windows: enable the native require fast path for root <code>openclaw/plugin-sdk</code> dist aliases instead of forcing Jiti transforms. (#80878) Thanks @medns.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.12/OpenClaw-2026.5.12.zip" length="52670462" type="application/octet-stream" sparkle:edSignature="RhckloxLoZhtGQZ+0jrH0qWN3py61an+kiAdgEJY9IZGekTKmJ+DWHXY3ixQJYvKf2WLgxOzaY6Jy27pF+kECw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.5.7</title>
|
||||
<pubDate>Thu, 07 May 2026 22:36:27 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026050790</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.5.7</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.5.7</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Release/plugin publishing: retry transient ClawHub CLI dependency install failures, keep preview-passing plugins publishable when one preview cell flakes, and verify every expected ClawHub package version after publish so maintenance releases are faster to recover and less likely to hide partial plugin publishes.</li>
|
||||
<li>OpenAI: support <code>openai/chat-latest</code> as an explicit direct API-key model override for trying the moving ChatGPT Instant API alias without changing the stable default model.</li>
|
||||
<li>Cron CLI: include computed <code>status</code> in <code>cron list --json</code> and <code>cron show --json</code> output so external tooling can read disabled/running/ok/error/skipped/idle state without reimplementing cron status derivation. (#78701) Thanks @aweiker.</li>
|
||||
<li>Channels CLI: make <code>openclaw channels list</code> channel-only, add <code>--all</code> for bundled and catalog channels, render installed/configured/enabled state, and move model auth/usage details to <code>openclaw models auth list</code>, <code>openclaw status</code>, and <code>openclaw models list</code>. (#78456) Thanks @sliverp.</li>
|
||||
<li>Native commands: honor owner enforcement for native command handlers. (#78864) Thanks @pgondhi987.</li>
|
||||
<li>Active Memory: require admin scope for global memory toggles. (#78863) Thanks @pgondhi987.</li>
|
||||
<li>Gateway/sessions: clear cached skills snapshots during <code>/new</code> and <code>sessions.reset</code> so long-lived channel sessions rebuild the visible skill list after skills change. (#78873) Thanks @Evizero.</li>
|
||||
<li>Auto-reply: gate inline skill tool dispatch through before-tool-call authorization hooks. (#78517) Thanks @pgondhi987.</li>
|
||||
<li>Tavily: resolve dedicated <code>tavily_search</code> and <code>tavily_extract</code> tool credentials from the active runtime config snapshot, so <code>exec</code> SecretRef-backed API keys do not reach the tools unresolved. (#78610) Thanks @VACInc.</li>
|
||||
<li>Plugins/install: use the same absolute POSIX npm lifecycle shell for managed plugin install, rollback, repair, and uninstall npm operations as staged package updates, preventing restricted PATH shells from breaking cleanup. Thanks @vincentkoc.</li>
|
||||
<li>Agents/context engine: invalidate cached assembled context views when source history shrinks or assembly fails, preventing stale pre-reset history from being reused. Fixes #77968. (#78163) Thanks @brokemac79 and @ChrisBot2026.</li>
|
||||
<li>Discord/message: parse provider-prefixed targets like <code>discord:channel:<id></code> as channel sends instead of legacy Discord DM targets, so cross-channel agent <code>message(action="send")</code> calls no longer misroute channel IDs into misleading <code>Unknown Channel</code> failures. Fixes #78572.</li>
|
||||
<li>Agents/compaction: clamp compaction summary reserve tokens to each model's output limit so high-context compaction no longer requests invalid <code>max_tokens</code> values. (#54392) Thanks @adzendo.</li>
|
||||
<li>Commands/BTW: show the <code>/btw</code> missing-question usage placeholder with brackets so outbound channel sanitization keeps it visible. Fixes #62877. Thanks @RajvardhanPatil07.</li>
|
||||
<li>Cron/doctor: repair persisted cron jobs whose <code>payload.model</code> was stored as <code>"default"</code>, <code>"null"</code>, blank, or JSON <code>null</code> by removing the bad override during <code>openclaw doctor --fix</code> while keeping cron runtime model validation strict. Fixes #78549. Thanks @bizzle12368239.</li>
|
||||
<li>Telegram: honor <code>accessGroup:*</code> sender allowlists for DMs, groups, native commands, and callback authorization before applying Telegram's numeric sender-ID checks. Fixes #78660. Thanks @manugc.</li>
|
||||
<li>Agent delivery: report <code>deliverySucceeded=false</code> when outbound delivery returns no adapter result, so claimed/empty delivery paths no longer masquerade as successful sends. Fixes #78532. Thanks @joeyfrasier.</li>
|
||||
<li>Cron/isolated runs: fail implicit announce delivery before model execution when <code>delivery.channel=last</code> has no previous route, so recurring jobs do not spend tokens before hitting a permanent delivery-target error. Fixes #78608. Thanks @sallyom.</li>
|
||||
<li>Gateway/sessions: persist a new generated transcript file when daily gateway-agent session rollover changes the session id, while preserving custom transcript paths. Fixes #78607. Thanks @nailujac, @zerone0x, and @sallyom.</li>
|
||||
<li>Doctor/Codex OAuth: preserve working <code>openai-codex/*</code> PI routes during <code>doctor --fix</code> and recover 2026.5.5-rewritten <code>openai/*</code> GPT-5 routes when only Codex OAuth auth is available, so update repair does not break subscription-auth setups. Fixes #78407. Thanks @shakkernerd.</li>
|
||||
<li>Telegram: keep the polling watchdog tied to <code>getUpdates</code> liveness so unrelated outbound Bot API calls cannot mask a wedged inbound poller. Fixes #78422. Thanks @ai-hpc.</li>
|
||||
<li>Agents/subagents: have completed session-mode subagent registry rows honor <code>agents.defaults.subagents.archiveAfterMinutes</code> instead of a hardcoded 5-minute TTL, so registry-backed surfaces keep one retention knob across spawn modes. (#78263) Thanks @arniesaha.</li>
|
||||
<li>Plugins/channel setup: forward <code>setChannelRuntime</code> from non-bundled external plugin setup entries so deferred external channel runtime initializers are installed before startup polling. Fixes #77779. (#77799) Thanks @openperf.</li>
|
||||
<li>Telegram: treat successful same-chat <code>message</code> tool outbound sends during an inbound Telegram turn as delivered when deciding whether to emit the rewritten silent reply fallback. (#78685) Thanks @neeravmakwana.</li>
|
||||
<li>Gateway/tasks: reconcile stale CLI run-context tasks whose live run context disappeared and bound channel hot-reload deferrals so stale task records cannot block Discord/Slack/Telegram reloads forever.</li>
|
||||
<li>Discord/voice: audit Discord voice-channel permissions in <code>channels capabilities</code> and <code>channels status --probe</code>, including auto-join targets, so missing Connect/Speak/Read Message History permissions show up before <code>/vc join</code>.</li>
|
||||
<li>Discord/voice: make voice capture less choppy by extending the default post-speech silence grace to 2.5s, add <code>voice.captureSilenceGraceMs</code> for noisy Discord sessions, and tighten the spoken-output prompt around live STT fragments. Thanks @vincentkoc.</li>
|
||||
<li>WhatsApp: route proactive phone-number sends through Baileys LID forward mappings when available, so LID-addressed contacts receive agent messages instead of creating sender-only ghost chats. Fixes #67378. (#74925) Thanks @edenfunf.</li>
|
||||
<li>WhatsApp: send captioned <code>MEDIA:</code> directive auto-replies once instead of emitting an empty media message before the captioned media reply. (#78770) Thanks @ai-hpc.</li>
|
||||
<li>Codex/approvals: in Codex approval modes, stop installing the pre-guardian native <code>PermissionRequest</code> hook by default so Codex's reviewer can approve safe commands before OpenClaw surfaces an approval, remember <code>allow-always</code> decisions for identical Codex native <code>PermissionRequest</code> payloads within the active session window, and make plugin approval requests validate/render their actual allowed decisions so Telegram and other native approval UIs cannot offer stale actions. Thanks @shakkernerd.</li>
|
||||
<li>Model providers: normalize APNG sniffed PNG uploads, preserve Gemini 3 tool-call thought-signature replay with fallback signatures, accept legacy <code>__env__:VAR</code> custom-provider keys, and repair snake_case tool-call transcript sanitization. Fixes #51881, #48915, #77566, and #42858.</li>
|
||||
<li>Telegram/models: parse provider ids containing dots in <code>/models</code> callback buttons so <code>hf.co</code> model lists render as inline keyboard buttons. Fixes #38745.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.7/OpenClaw-2026.5.7.zip" length="51130645" type="application/octet-stream" sparkle:edSignature="Zu+EzBGMRE1k7N4//L8HUxtUCPdO0ImrfDbgr2GrPMBrj7VGI1tOOl74gxNJoi/wfWvXz3fYVcBz2W/84ojuCw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -209,15 +209,16 @@ Why these matter:
|
||||
|
||||
- Google Play treats SMS and Call Log access as highly restricted. In most cases, Play only allows them for the default SMS app, default Phone app, default Assistant, or a narrow policy exception.
|
||||
- Review usually involves a `Permissions Declaration Form`, policy justification, and demo video evidence in Play Console.
|
||||
- If we want a Play-safe build, these should be the first permissions removed behind a dedicated product flavor / variant.
|
||||
- The Play build removes these behind the `play` flavor.
|
||||
- Photo library access is also removed from the Play build. Use third-party builds for `photos.latest`.
|
||||
|
||||
Current OpenClaw Android implication:
|
||||
|
||||
- APK / sideload build can keep SMS and Call Log features.
|
||||
- Google Play build should exclude SMS send/search and Call Log search unless the product is intentionally positioned and approved as a default-handler exception case.
|
||||
- APK / sideload build can keep SMS, Call Log, and recent-photo features.
|
||||
- Google Play build excludes SMS send/search, Call Log search, and recent-photo access unless the product is intentionally positioned and approved under the relevant policy exception.
|
||||
- The repo now ships this split as Android product flavors:
|
||||
- `play`: removes `READ_SMS`, `SEND_SMS`, and `READ_CALL_LOG`, and hides SMS / Call Log surfaces in onboarding, settings, and advertised node capabilities.
|
||||
- `thirdParty`: keeps the full permission set and the existing SMS / Call Log functionality.
|
||||
- `play`: removes `READ_SMS`, `SEND_SMS`, `READ_CALL_LOG`, `READ_MEDIA_IMAGES`, `READ_MEDIA_VISUAL_USER_SELECTED`, and `READ_EXTERNAL_STORAGE`; hides SMS, Call Log, and Photos surfaces in onboarding, settings, and advertised node capabilities.
|
||||
- `thirdParty`: keeps the full permission set and the existing SMS / Call Log / Photos functionality.
|
||||
|
||||
Policy links:
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026051900
|
||||
versionName = "2026.5.19"
|
||||
versionCode = 2026052100
|
||||
versionName = "2026.5.21"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@@ -5,6 +5,7 @@ import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
import ai.openclaw.app.chat.ChatSessionEntry
|
||||
import ai.openclaw.app.chat.OutgoingAttachment
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary
|
||||
import ai.openclaw.app.node.CameraCaptureManager
|
||||
import ai.openclaw.app.node.CanvasController
|
||||
import ai.openclaw.app.node.SmsManager
|
||||
@@ -81,6 +82,40 @@ class MainViewModel(
|
||||
val statusText: StateFlow<String> = runtimeState(initial = "Offline") { it.statusText }
|
||||
val serverName: StateFlow<String?> = runtimeState(initial = null) { it.serverName }
|
||||
val remoteAddress: StateFlow<String?> = runtimeState(initial = null) { it.remoteAddress }
|
||||
val gatewayVersion: StateFlow<String?> = runtimeState(initial = null) { it.gatewayVersion }
|
||||
val gatewayUpdateAvailable: StateFlow<GatewayUpdateAvailableSummary?> = runtimeState(initial = null) { it.gatewayUpdateAvailable }
|
||||
val modelCatalog: StateFlow<List<GatewayModelSummary>> = runtimeState(initial = emptyList()) { it.modelCatalog }
|
||||
val modelAuthProviders: StateFlow<List<GatewayModelProviderSummary>> = runtimeState(initial = emptyList()) { it.modelAuthProviders }
|
||||
val modelCatalogRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.modelCatalogRefreshing }
|
||||
val modelCatalogErrorText: StateFlow<String?> = runtimeState(initial = null) { it.modelCatalogErrorText }
|
||||
val gatewayDefaultAgentId: StateFlow<String?> = runtimeState(initial = null) { it.gatewayDefaultAgentId }
|
||||
val gatewayAgents: StateFlow<List<GatewayAgentSummary>> = runtimeState(initial = emptyList()) { it.gatewayAgents }
|
||||
val cronStatus: StateFlow<GatewayCronStatus> = runtimeState(initial = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null)) { it.cronStatus }
|
||||
val cronJobs: StateFlow<List<GatewayCronJobSummary>> = runtimeState(initial = emptyList()) { it.cronJobs }
|
||||
val cronRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.cronRefreshing }
|
||||
val cronErrorText: StateFlow<String?> = runtimeState(initial = null) { it.cronErrorText }
|
||||
val usageSummary: StateFlow<GatewayUsageSummary> = runtimeState(initial = GatewayUsageSummary(updatedAtMs = null, providers = emptyList())) { it.usageSummary }
|
||||
val usageRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.usageRefreshing }
|
||||
val usageErrorText: StateFlow<String?> = runtimeState(initial = null) { it.usageErrorText }
|
||||
val skillsSummary: StateFlow<GatewaySkillsSummary> = runtimeState(initial = GatewaySkillsSummary(skills = emptyList())) { it.skillsSummary }
|
||||
val skillsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.skillsRefreshing }
|
||||
val skillsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.skillsErrorText }
|
||||
val nodesDevicesSummary: StateFlow<GatewayNodesDevicesSummary> =
|
||||
runtimeState(initial = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())) { it.nodesDevicesSummary }
|
||||
val nodesDevicesRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.nodesDevicesRefreshing }
|
||||
val nodesDevicesErrorText: StateFlow<String?> = runtimeState(initial = null) { it.nodesDevicesErrorText }
|
||||
val channelsSummary: StateFlow<GatewayChannelsSummary> =
|
||||
runtimeState(initial = GatewayChannelsSummary(channels = emptyList())) { it.channelsSummary }
|
||||
val channelsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.channelsRefreshing }
|
||||
val channelsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.channelsErrorText }
|
||||
val dreamingSummary: StateFlow<GatewayDreamingSummary> =
|
||||
runtimeState(initial = GatewayDreamingSummary()) { it.dreamingSummary }
|
||||
val dreamingRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.dreamingRefreshing }
|
||||
val dreamingErrorText: StateFlow<String?> = runtimeState(initial = null) { it.dreamingErrorText }
|
||||
val healthLogsSummary: StateFlow<GatewayHealthLogsSummary> =
|
||||
runtimeState(initial = GatewayHealthLogsSummary()) { it.healthLogsSummary }
|
||||
val healthLogsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.healthLogsRefreshing }
|
||||
val healthLogsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.healthLogsErrorText }
|
||||
val pendingGatewayTrust: StateFlow<NodeRuntime.GatewayTrustPrompt?> = runtimeState(initial = null) { it.pendingGatewayTrust }
|
||||
val seamColorArgb: StateFlow<Long> = runtimeState(initial = 0xFF0EA5E9) { it.seamColorArgb }
|
||||
val mainSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.mainSessionKey }
|
||||
@@ -293,6 +328,10 @@ class MainViewModel(
|
||||
ensureRuntime().setMicEnabled(enabled)
|
||||
}
|
||||
|
||||
fun cancelMicCapture() {
|
||||
ensureRuntime().cancelMicCapture()
|
||||
}
|
||||
|
||||
fun setTalkModeEnabled(enabled: Boolean) {
|
||||
ensureRuntime().setTalkModeEnabled(enabled)
|
||||
}
|
||||
@@ -355,6 +394,42 @@ class MainViewModel(
|
||||
ensureRuntime().refreshHomeCanvasOverviewIfConnected()
|
||||
}
|
||||
|
||||
fun refreshModelCatalog() {
|
||||
ensureRuntime().refreshModelCatalog()
|
||||
}
|
||||
|
||||
fun refreshAgents() {
|
||||
ensureRuntime().refreshAgents()
|
||||
}
|
||||
|
||||
fun refreshCronJobs() {
|
||||
ensureRuntime().refreshCronJobs()
|
||||
}
|
||||
|
||||
fun refreshUsage() {
|
||||
ensureRuntime().refreshUsage()
|
||||
}
|
||||
|
||||
fun refreshSkills() {
|
||||
ensureRuntime().refreshSkills()
|
||||
}
|
||||
|
||||
fun refreshNodesDevices() {
|
||||
ensureRuntime().refreshNodesDevices()
|
||||
}
|
||||
|
||||
fun refreshChannels() {
|
||||
ensureRuntime().refreshChannels()
|
||||
}
|
||||
|
||||
fun refreshDreaming() {
|
||||
ensureRuntime().refreshDreaming()
|
||||
}
|
||||
|
||||
fun refreshHealthLogs() {
|
||||
ensureRuntime().refreshHealthLogs()
|
||||
}
|
||||
|
||||
fun loadChat(sessionKey: String) {
|
||||
ensureRuntime().loadChat(sessionKey)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,7 @@ class ChatController(
|
||||
|
||||
private val pendingRuns = mutableSetOf<String>()
|
||||
private val pendingRunTimeoutJobs = ConcurrentHashMap<String, Job>()
|
||||
private val optimisticMessagesByRunId = LinkedHashMap<String, ChatMessage>()
|
||||
private val pendingRunTimeoutMs = 120_000L
|
||||
|
||||
private var lastHealthPollAtMs: Long? = null
|
||||
@@ -76,6 +77,7 @@ class ChatController(
|
||||
fun load(sessionKey: String) {
|
||||
val key = normalizeRequestedSessionKey(sessionKey)
|
||||
_sessionKey.value = key
|
||||
optimisticMessagesByRunId.clear()
|
||||
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
|
||||
}
|
||||
|
||||
@@ -113,6 +115,7 @@ class ChatController(
|
||||
if (key.isEmpty()) return
|
||||
if (key == _sessionKey.value) return
|
||||
_sessionKey.value = key
|
||||
optimisticMessagesByRunId.clear()
|
||||
// Keep the thread switch path lean: history + health are needed immediately,
|
||||
// but the session list is usually unchanged and can refresh on explicit pull-to-refresh.
|
||||
scope.launch { bootstrap(forceHealth = true, refreshSessions = false) }
|
||||
@@ -171,14 +174,15 @@ class ChatController(
|
||||
)
|
||||
}
|
||||
}
|
||||
_messages.value =
|
||||
_messages.value +
|
||||
val optimisticMessage =
|
||||
ChatMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
role = "user",
|
||||
content = userContent,
|
||||
timestampMs = System.currentTimeMillis(),
|
||||
)
|
||||
optimisticMessagesByRunId[runId] = optimisticMessage
|
||||
_messages.value = _messages.value + optimisticMessage
|
||||
|
||||
armPendingRunTimeout(runId)
|
||||
synchronized(pendingRuns) {
|
||||
@@ -218,6 +222,7 @@ class ChatController(
|
||||
val res = session.request("chat.send", params.toString())
|
||||
val actualRunId = parseRunId(res) ?: runId
|
||||
if (actualRunId != runId) {
|
||||
optimisticMessagesByRunId[actualRunId] = optimisticMessagesByRunId.remove(runId) ?: optimisticMessage
|
||||
clearPendingRun(runId)
|
||||
armPendingRunTimeout(actualRunId)
|
||||
synchronized(pendingRuns) {
|
||||
@@ -228,6 +233,7 @@ class ChatController(
|
||||
true
|
||||
} catch (err: Throwable) {
|
||||
clearPendingRun(runId)
|
||||
removeOptimisticMessage(runId)
|
||||
_errorText.value = err.message
|
||||
false
|
||||
}
|
||||
@@ -302,7 +308,7 @@ class ChatController(
|
||||
|
||||
val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")
|
||||
val history = parseHistory(historyJson, sessionKey = key, previousMessages = _messages.value)
|
||||
_messages.value = history.messages
|
||||
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
|
||||
_sessionId.value = history.sessionId
|
||||
history.thinkingLevel
|
||||
?.trim()
|
||||
@@ -369,7 +375,13 @@ class ChatController(
|
||||
if (state == "error") {
|
||||
_errorText.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed"
|
||||
}
|
||||
if (runId != null) clearPendingRun(runId) else clearPendingRuns()
|
||||
if (runId != null) {
|
||||
clearPendingRun(runId)
|
||||
optimisticMessagesByRunId.remove(runId)
|
||||
} else {
|
||||
clearPendingRuns()
|
||||
optimisticMessagesByRunId.clear()
|
||||
}
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
@@ -378,7 +390,7 @@ class ChatController(
|
||||
val historyJson =
|
||||
session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""")
|
||||
val history = parseHistory(historyJson, sessionKey = _sessionKey.value, previousMessages = _messages.value)
|
||||
_messages.value = history.messages
|
||||
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
|
||||
_sessionId.value = history.sessionId
|
||||
history.thinkingLevel
|
||||
?.trim()
|
||||
@@ -471,6 +483,7 @@ class ChatController(
|
||||
}
|
||||
if (!stillPending) return@launch
|
||||
clearPendingRun(runId)
|
||||
removeOptimisticMessage(runId)
|
||||
_errorText.value = "Timed out waiting for a reply; try again or refresh."
|
||||
}
|
||||
}
|
||||
@@ -488,12 +501,18 @@ class ChatController(
|
||||
job.cancel()
|
||||
}
|
||||
pendingRunTimeoutJobs.clear()
|
||||
optimisticMessagesByRunId.clear()
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.clear()
|
||||
_pendingRunCount.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeOptimisticMessage(runId: String) {
|
||||
val message = optimisticMessagesByRunId.remove(runId) ?: return
|
||||
_messages.value = _messages.value.filterNot { it.id == message.id }
|
||||
}
|
||||
|
||||
private fun parseHistory(
|
||||
historyJson: String,
|
||||
sessionKey: String,
|
||||
@@ -620,11 +639,54 @@ internal fun reconcileMessageIds(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun mergeOptimisticMessages(
|
||||
incoming: List<ChatMessage>,
|
||||
optimistic: Collection<ChatMessage>,
|
||||
): List<ChatMessage> {
|
||||
if (optimistic.isEmpty()) return incoming
|
||||
|
||||
val unmatchedIncoming = incoming.toMutableList()
|
||||
val missingOptimistic =
|
||||
optimistic.filter { message ->
|
||||
val matchIndex =
|
||||
unmatchedIncoming.indexOfFirst { incomingMessage ->
|
||||
incomingMessageConsumesOptimistic(incomingMessage, message)
|
||||
}
|
||||
if (matchIndex >= 0) {
|
||||
unmatchedIncoming.removeAt(matchIndex)
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
if (missingOptimistic.isEmpty()) return incoming
|
||||
|
||||
return (incoming + missingOptimistic).sortedWith(compareBy<ChatMessage> { it.timestampMs ?: Long.MAX_VALUE }.thenBy { it.id })
|
||||
}
|
||||
|
||||
internal fun messageIdentityKey(message: ChatMessage): String? {
|
||||
val contentKey = messageContentIdentityKey(message) ?: return null
|
||||
val timestamp = message.timestampMs?.toString().orEmpty()
|
||||
if (timestamp.isEmpty() && contentKey.isEmpty()) return null
|
||||
return listOf(contentKey, timestamp).joinToString(separator = "|")
|
||||
}
|
||||
|
||||
private fun optimisticMessageIdentityKey(message: ChatMessage): String? = messageContentIdentityKey(message)
|
||||
|
||||
private fun incomingMessageConsumesOptimistic(
|
||||
incoming: ChatMessage,
|
||||
optimistic: ChatMessage,
|
||||
): Boolean {
|
||||
if (optimisticMessageIdentityKey(incoming) != optimisticMessageIdentityKey(optimistic)) return false
|
||||
val incomingTimestamp = incoming.timestampMs ?: return false
|
||||
val optimisticTimestamp = optimistic.timestampMs ?: return true
|
||||
return incomingTimestamp >= optimisticTimestamp
|
||||
}
|
||||
|
||||
private fun messageContentIdentityKey(message: ChatMessage): String? {
|
||||
val role = message.role.trim().lowercase()
|
||||
if (role.isEmpty()) return null
|
||||
|
||||
val timestamp = message.timestampMs?.toString().orEmpty()
|
||||
val contentFingerprint =
|
||||
message.content.joinToString(separator = "\u001E") { part ->
|
||||
listOf(
|
||||
@@ -642,8 +704,7 @@ internal fun messageIdentityKey(message: ChatMessage): String? {
|
||||
).joinToString(separator = "\u001F")
|
||||
}
|
||||
|
||||
if (timestamp.isEmpty() && contentFingerprint.isEmpty()) return null
|
||||
return listOf(role, timestamp, contentFingerprint).joinToString(separator = "|")
|
||||
return listOf(role, contentFingerprint).joinToString(separator = "|")
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
@@ -68,6 +68,20 @@ data class GatewayConnectErrorDetails(
|
||||
val reason: String? = null,
|
||||
)
|
||||
|
||||
data class GatewayHelloSummary(
|
||||
val serverName: String?,
|
||||
val remoteAddress: String?,
|
||||
val serverVersion: String?,
|
||||
val mainSessionKey: String?,
|
||||
val updateAvailable: GatewayUpdateAvailableSummary?,
|
||||
)
|
||||
|
||||
data class GatewayUpdateAvailableSummary(
|
||||
val currentVersion: String?,
|
||||
val latestVersion: String?,
|
||||
val channel: String?,
|
||||
)
|
||||
|
||||
private data class SelectedConnectAuth(
|
||||
val authToken: String?,
|
||||
val authBootstrapToken: String?,
|
||||
@@ -86,7 +100,7 @@ class GatewaySession(
|
||||
private val scope: CoroutineScope,
|
||||
private val identityStore: DeviceIdentityStore,
|
||||
private val deviceAuthStore: DeviceAuthTokenStore,
|
||||
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
|
||||
private val onConnected: (GatewayHelloSummary) -> Unit,
|
||||
private val onDisconnected: (message: String) -> Unit,
|
||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||
private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null,
|
||||
@@ -604,8 +618,8 @@ class GatewaySession(
|
||||
val allowedOperatorScopes =
|
||||
setOf(
|
||||
"operator.approvals",
|
||||
"operator.pairing",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
)
|
||||
scopes.filter { allowedOperatorScopes.contains(it) }.distinct().sorted()
|
||||
@@ -648,7 +662,9 @@ class GatewaySession(
|
||||
pendingDeviceTokenRetry = false
|
||||
deviceTokenRetryBudgetUsed = false
|
||||
reconnectPausedForAuthFailure = false
|
||||
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
|
||||
val server = obj["server"].asObjectOrNull()
|
||||
val serverName = server?.get("host").asStringOrNull()
|
||||
val serverVersion = server?.get("version").asStringOrNull()
|
||||
val authObj = obj["auth"].asObjectOrNull()
|
||||
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
|
||||
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
|
||||
@@ -686,13 +702,33 @@ class GatewaySession(
|
||||
?.let { normalized -> surface to normalized }
|
||||
} ?: emptyList()
|
||||
pluginSurfaceUrls = normalizedPluginSurfaceUrls.toMap()
|
||||
val snapshot = obj["snapshot"].asObjectOrNull()
|
||||
val sessionDefaults =
|
||||
obj["snapshot"]
|
||||
.asObjectOrNull()
|
||||
snapshot
|
||||
?.get("sessionDefaults")
|
||||
.asObjectOrNull()
|
||||
mainSessionKey = sessionDefaults?.get("mainSessionKey").asStringOrNull()
|
||||
onConnected(serverName, remoteAddress, mainSessionKey)
|
||||
onConnected(
|
||||
GatewayHelloSummary(
|
||||
serverName = serverName,
|
||||
remoteAddress = remoteAddress,
|
||||
serverVersion = serverVersion,
|
||||
mainSessionKey = mainSessionKey,
|
||||
updateAvailable = parseUpdateAvailable(snapshot?.get("updateAvailable").asObjectOrNull()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseUpdateAvailable(value: JsonObject?): GatewayUpdateAvailableSummary? {
|
||||
if (value == null) return null
|
||||
val latestVersion = value["latestVersion"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val currentVersion = value["currentVersion"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val channel = value["channel"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
return GatewayUpdateAvailableSummary(
|
||||
currentVersion = currentVersion,
|
||||
latestVersion = latestVersion,
|
||||
channel = channel,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildConnectParams(
|
||||
|
||||
@@ -22,6 +22,7 @@ class ConnectionManager(
|
||||
private val readSmsAvailable: () -> Boolean,
|
||||
private val smsSearchPossible: () -> Boolean,
|
||||
private val callLogAvailable: () -> Boolean,
|
||||
private val photosAvailable: () -> Boolean,
|
||||
private val hasRecordAudioPermission: () -> Boolean,
|
||||
private val manualTls: () -> Boolean,
|
||||
) {
|
||||
@@ -96,6 +97,7 @@ class ConnectionManager(
|
||||
readSmsAvailable = readSmsAvailable(),
|
||||
smsSearchPossible = smsSearchPossible(),
|
||||
callLogAvailable = callLogAvailable(),
|
||||
photosAvailable = photosAvailable(),
|
||||
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
|
||||
motionActivityAvailable = motionActivityAvailable(),
|
||||
motionPedometerAvailable = motionPedometerAvailable(),
|
||||
@@ -160,7 +162,15 @@ class ConnectionManager(
|
||||
fun buildOperatorConnectOptions(): GatewayConnectOptions =
|
||||
GatewayConnectOptions(
|
||||
role = "operator",
|
||||
scopes = listOf("operator.read", "operator.write", "operator.talk.secrets"),
|
||||
// QR bootstrap hands Android a bounded operator token that includes approvals; keep the
|
||||
// default operator reconnect request aligned so the post-bootstrap loop can approve work.
|
||||
scopes =
|
||||
listOf(
|
||||
"operator.approvals",
|
||||
"operator.pairing",
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
|
||||
@@ -28,6 +28,7 @@ class DeviceHandler(
|
||||
private val appContext: Context,
|
||||
private val smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
|
||||
private val callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled,
|
||||
private val photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled,
|
||||
) {
|
||||
companion object {
|
||||
internal fun hasAnySmsCapability(
|
||||
@@ -150,7 +151,9 @@ class DeviceHandler(
|
||||
val smsReadGranted = hasPermission(Manifest.permission.READ_SMS)
|
||||
val notificationAccess = DeviceNotificationListenerService.isAccessEnabled(appContext)
|
||||
val photosGranted =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
if (!photosEnabled) {
|
||||
false
|
||||
} else if (Build.VERSION.SDK_INT >= 33) {
|
||||
hasPermission(Manifest.permission.READ_MEDIA_IMAGES)
|
||||
} else {
|
||||
hasPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
@@ -248,7 +251,7 @@ class DeviceHandler(
|
||||
"photos",
|
||||
permissionStateJson(
|
||||
granted = photosGranted,
|
||||
promptableWhenDenied = true,
|
||||
promptableWhenDenied = photosEnabled,
|
||||
),
|
||||
)
|
||||
put(
|
||||
|
||||
@@ -23,6 +23,7 @@ data class NodeRuntimeFlags(
|
||||
val readSmsAvailable: Boolean,
|
||||
val smsSearchPossible: Boolean,
|
||||
val callLogAvailable: Boolean,
|
||||
val photosAvailable: Boolean,
|
||||
val voiceWakeEnabled: Boolean,
|
||||
val motionActivityAvailable: Boolean,
|
||||
val motionPedometerAvailable: Boolean,
|
||||
@@ -37,6 +38,7 @@ enum class InvokeCommandAvailability {
|
||||
ReadSmsAvailable,
|
||||
RequestableSmsSearchAvailable,
|
||||
CallLogAvailable,
|
||||
PhotosAvailable,
|
||||
MotionActivityAvailable,
|
||||
MotionPedometerAvailable,
|
||||
DebugBuild,
|
||||
@@ -48,6 +50,7 @@ enum class NodeCapabilityAvailability {
|
||||
LocationEnabled,
|
||||
SmsAvailable,
|
||||
CallLogAvailable,
|
||||
PhotosAvailable,
|
||||
VoiceWakeEnabled,
|
||||
MotionAvailable,
|
||||
}
|
||||
@@ -87,7 +90,10 @@ object InvokeCommandRegistry {
|
||||
name = OpenClawCapability.Location.rawValue,
|
||||
availability = NodeCapabilityAvailability.LocationEnabled,
|
||||
),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.Photos.rawValue),
|
||||
NodeCapabilitySpec(
|
||||
name = OpenClawCapability.Photos.rawValue,
|
||||
availability = NodeCapabilityAvailability.PhotosAvailable,
|
||||
),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.Contacts.rawValue),
|
||||
NodeCapabilitySpec(name = OpenClawCapability.Calendar.rawValue),
|
||||
NodeCapabilitySpec(
|
||||
@@ -188,6 +194,7 @@ object InvokeCommandRegistry {
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawPhotosCommand.Latest.rawValue,
|
||||
availability = InvokeCommandAvailability.PhotosAvailable,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawContactsCommand.Search.rawValue,
|
||||
@@ -244,6 +251,7 @@ object InvokeCommandRegistry {
|
||||
NodeCapabilityAvailability.LocationEnabled -> flags.locationEnabled
|
||||
NodeCapabilityAvailability.SmsAvailable -> flags.sendSmsAvailable || flags.readSmsAvailable
|
||||
NodeCapabilityAvailability.CallLogAvailable -> flags.callLogAvailable
|
||||
NodeCapabilityAvailability.PhotosAvailable -> flags.photosAvailable
|
||||
NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled
|
||||
NodeCapabilityAvailability.MotionAvailable -> flags.motionActivityAvailable || flags.motionPedometerAvailable
|
||||
}
|
||||
@@ -260,6 +268,7 @@ object InvokeCommandRegistry {
|
||||
InvokeCommandAvailability.ReadSmsAvailable -> flags.readSmsAvailable
|
||||
InvokeCommandAvailability.RequestableSmsSearchAvailable -> flags.smsSearchPossible
|
||||
InvokeCommandAvailability.CallLogAvailable -> flags.callLogAvailable
|
||||
InvokeCommandAvailability.PhotosAvailable -> flags.photosAvailable
|
||||
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
|
||||
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
|
||||
InvokeCommandAvailability.DebugBuild -> flags.debugBuild
|
||||
|
||||
@@ -77,6 +77,7 @@ class InvokeDispatcher(
|
||||
private val smsFeatureEnabled: () -> Boolean,
|
||||
private val smsTelephonyAvailable: () -> Boolean,
|
||||
private val callLogAvailable: () -> Boolean,
|
||||
private val photosAvailable: () -> Boolean,
|
||||
private val debugBuild: () -> Boolean,
|
||||
private val onCanvasA2uiPush: () -> Unit,
|
||||
private val onCanvasA2uiReset: () -> Unit,
|
||||
@@ -325,6 +326,15 @@ class InvokeDispatcher(
|
||||
message = "CALL_LOG_UNAVAILABLE: call log not available on this build",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.PhotosAvailable ->
|
||||
if (photosAvailable()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "PHOTOS_UNAVAILABLE",
|
||||
message = "PHOTOS_UNAVAILABLE: photos not available on this build",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.DebugBuild ->
|
||||
if (debugBuild()) {
|
||||
null
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ScreenShare
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun CanvasSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val currentUrl by viewModel.canvasCurrentUrl.collectAsState()
|
||||
val hydrated by viewModel.canvasA2uiHydrated.collectAsState()
|
||||
val rehydratePending by viewModel.canvasRehydratePending.collectAsState()
|
||||
val rehydrateErrorText by viewModel.canvasRehydrateErrorText.collectAsState()
|
||||
val hasLivePage = currentUrl?.isNotBlank() == true
|
||||
val showCanvasSurface = isConnected
|
||||
val canvasLabel = if (hasLivePage) "Live page" else "Home canvas"
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshHomeCanvasOverviewIfConnected()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = "Canvas",
|
||||
subtitle = "Current screen output and interactive app surface.",
|
||||
icon = Icons.AutoMirrored.Filled.ScreenShare,
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Connection", if (isConnected) "Online" else "Offline"),
|
||||
SettingsMetric("Surface", canvasLabel),
|
||||
SettingsMetric("Bridge", if (hasLivePage && hydrated) "Ready" else "Standby"),
|
||||
),
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawPrimaryButton(
|
||||
text = if (rehydratePending) "Refreshing" else "Refresh Screen",
|
||||
onClick = { viewModel.requestCanvasRehydrate(source = "settings_canvas") },
|
||||
enabled = isConnected && !rehydratePending,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
ClawSecondaryButton(
|
||||
text = "Reconnect",
|
||||
onClick = viewModel::refreshGatewayConnection,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
rehydrateErrorText?.let {
|
||||
ClawPanel {
|
||||
Text(text = it, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(text = canvasLabel, style = ClawTheme.type.section, color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().height(520.dp).clip(RoundedCornerShape(ClawTheme.radii.panel)),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.canvas,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box {
|
||||
if (showCanvasSurface) {
|
||||
CanvasScreen(viewModel = viewModel, visible = true, modifier = Modifier.fillMaxWidth().height(520.dp))
|
||||
} else {
|
||||
CanvasStandbyPanel(isConnected = isConnected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CanvasStandbyPanel(isConnected: Boolean) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().height(520.dp).padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(54.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.surfacePressed,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
contentColor = ClawTheme.colors.text,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.ScreenShare, contentDescription = null, modifier = Modifier.size(26.dp))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = if (isConnected) "Screen surface ready" else "Connect the gateway",
|
||||
style = ClawTheme.type.title,
|
||||
color = ClawTheme.colors.text,
|
||||
modifier = Modifier.padding(top = 18.dp),
|
||||
)
|
||||
Text(
|
||||
text = if (isConnected) "Canvas output appears here when OpenClaw opens an app surface." else "Canvas output needs an active gateway connection.",
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayChannelSummary
|
||||
import ai.openclaw.app.GatewayChannelsSummary
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawDetailRow
|
||||
import ai.openclaw.app.ui.design.ClawListPanel
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawTextBadge
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun ChannelsSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val summary by viewModel.channelsSummary.collectAsState()
|
||||
val refreshing by viewModel.channelsRefreshing.collectAsState()
|
||||
val errorText by viewModel.channelsErrorText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val channels = summary.channels
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshChannels()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = "Channels",
|
||||
subtitle = "Messaging surfaces connected to this gateway.",
|
||||
icon = Icons.Default.Notifications,
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Channels", channels.size.toString()),
|
||||
SettingsMetric("Connected", channels.count { it.connected }.toString()),
|
||||
SettingsMetric("Configured", channels.count { it.configured }.toString()),
|
||||
SettingsMetric("Issues", channels.count { it.error != null }.toString()),
|
||||
),
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawSecondaryButton(
|
||||
text = if (refreshing) "Refreshing" else "Refresh",
|
||||
onClick = viewModel::refreshChannels,
|
||||
enabled = isConnected && !refreshing,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
errorText?.let { error ->
|
||||
ClawPanel {
|
||||
Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
if (summary.partial || summary.warnings.isNotEmpty()) {
|
||||
ClawPanel {
|
||||
Text(text = channelsWarningText(summary), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
when {
|
||||
!isConnected ->
|
||||
ClawPanel {
|
||||
Text(text = "Connect the gateway to load channels.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
channels.isEmpty() ->
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "No channels found.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = "Telegram, WhatsApp, email, and other channels appear here after setup.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
else -> ChannelsPanel(channels = channels)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelsPanel(channels: List<GatewayChannelSummary>) {
|
||||
ClawListPanel(items = channels) { channel ->
|
||||
ChannelRow(channel = channel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelRow(channel: GatewayChannelSummary) {
|
||||
ClawDetailRow(
|
||||
title = channel.label,
|
||||
subtitle = channelSubtitle(channel),
|
||||
leading = { ClawTextBadge(text = channelBadge(channel.label)) },
|
||||
trailing = { ClawStatusPill(text = channelStatusText(channel), status = channelStatus(channel)) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun channelSubtitle(channel: GatewayChannelSummary): String {
|
||||
val accounts =
|
||||
when (channel.accountCount) {
|
||||
0 -> null
|
||||
1 -> "1 account"
|
||||
else -> "${channel.accountCount} accounts"
|
||||
}
|
||||
val lifecycle =
|
||||
when {
|
||||
channel.connected -> "Connected"
|
||||
channel.running -> "Running"
|
||||
channel.linked -> "Linked"
|
||||
channel.configured -> "Configured"
|
||||
channel.enabled -> "Enabled"
|
||||
else -> "Off"
|
||||
}
|
||||
return listOfNotNull(accounts, lifecycle, channel.error).joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun channelStatusText(channel: GatewayChannelSummary): String =
|
||||
when {
|
||||
channel.error != null -> "Issue"
|
||||
channel.connected -> "Connected"
|
||||
channel.running -> "Running"
|
||||
channel.linked || channel.configured -> "Ready"
|
||||
channel.enabled -> "Setup"
|
||||
else -> "Off"
|
||||
}
|
||||
|
||||
private fun channelStatus(channel: GatewayChannelSummary): ClawStatus =
|
||||
when {
|
||||
channel.error != null -> ClawStatus.Danger
|
||||
channel.connected || channel.running -> ClawStatus.Success
|
||||
channel.linked || channel.configured -> ClawStatus.Neutral
|
||||
channel.enabled -> ClawStatus.Warning
|
||||
else -> ClawStatus.Neutral
|
||||
}
|
||||
|
||||
private fun channelBadge(label: String): String =
|
||||
label
|
||||
.split(' ', '-', '_')
|
||||
.filter { it.isNotBlank() }
|
||||
.take(2)
|
||||
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
|
||||
.joinToString("")
|
||||
.ifBlank { "C" }
|
||||
|
||||
private fun channelsWarningText(summary: GatewayChannelsSummary): String = summary.warnings.firstOrNull()?.takeIf { it.isNotBlank() } ?: "Some channel status checks did not complete."
|
||||
@@ -0,0 +1,320 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayModelProviderSummary
|
||||
import ai.openclaw.app.GatewayModelSummary
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawEmptyState
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawScaffold
|
||||
import ai.openclaw.app.ui.design.ClawSeparatedColumn
|
||||
import ai.openclaw.app.ui.design.ClawTextField
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.outlined.AccessTime
|
||||
import androidx.compose.material.icons.outlined.ChatBubbleOutline
|
||||
import androidx.compose.material.icons.outlined.Inventory2
|
||||
import androidx.compose.material.icons.outlined.MicNone
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun CommandPalette(
|
||||
viewModel: MainViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onOpenChat: () -> Unit,
|
||||
onOpenVoice: () -> Unit,
|
||||
onOpenSessions: () -> Unit,
|
||||
onOpenProviders: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSession: (String) -> Unit,
|
||||
) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
val models by viewModel.modelCatalog.collectAsState()
|
||||
val providers by viewModel.modelAuthProviders.collectAsState()
|
||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
val normalizedQuery = query.trim().lowercase()
|
||||
val quickActions =
|
||||
listOf(
|
||||
CommandItem("Open Chat", "Start or continue a conversation", Icons.Outlined.ChatBubbleOutline, onOpenChat),
|
||||
CommandItem("Start Voice", "Talk or dictate with OpenClaw", Icons.Outlined.MicNone, onOpenVoice),
|
||||
CommandItem("Browse Sessions", "Find previous conversations", Icons.Outlined.AccessTime, onOpenSessions),
|
||||
CommandItem("Providers & Models", providerCommandSubtitle(isConnected, providers, models), Icons.Outlined.Inventory2, onOpenProviders),
|
||||
CommandItem("Settings", "Gateway, voice, notifications, privacy", Icons.Outlined.Settings, onOpenSettings),
|
||||
)
|
||||
val actionRows = quickActions.filter { it.matches(normalizedQuery) }
|
||||
val sessionRows =
|
||||
sessions
|
||||
.filter { session ->
|
||||
val title = commandSessionTitle(session.displayName)
|
||||
normalizedQuery.isEmpty() || title.lowercase().contains(normalizedQuery)
|
||||
}.take(5)
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 20.dp)) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
CommandIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search", onClick = onDismiss)
|
||||
Text(text = "Search", style = ClawTheme.type.title, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), textAlign = TextAlign.Center)
|
||||
CommandAvatar(text = "OC")
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
ClawTextField(value = query, onValueChange = { query = it }, placeholder = "Search OpenClaw")
|
||||
}
|
||||
|
||||
item {
|
||||
CommandSectionLabel(title = "Quick actions")
|
||||
}
|
||||
|
||||
if (actionRows.isEmpty()) {
|
||||
item {
|
||||
ClawEmptyState(title = "No actions found", body = "Try Chat, Voice, Sessions, Providers, or Settings.")
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
CommandActionList(rows = actionRows)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
CommandSectionLabel(title = "Sessions")
|
||||
}
|
||||
|
||||
if (sessionRows.isEmpty()) {
|
||||
item {
|
||||
ClawPanel {
|
||||
Text(
|
||||
text = if (isConnected) "No matching sessions yet." else "Connect the Gateway to search sessions.",
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
CommandSessionList(
|
||||
rows =
|
||||
sessionRows.map { session ->
|
||||
CommandSessionRow(
|
||||
key = session.key,
|
||||
title = commandSessionTitle(session.displayName),
|
||||
subtitle = if (pendingRunCount > 0) "Assistant working" else "OpenClaw session",
|
||||
metadata = session.updatedAtMs?.let(::commandRelativeTime) ?: "now",
|
||||
)
|
||||
},
|
||||
onOpen = onOpenSession,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class CommandItem(
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val icon: ImageVector,
|
||||
val onClick: () -> Unit,
|
||||
) {
|
||||
fun matches(query: String): Boolean = query.isEmpty() || title.lowercase().contains(query) || subtitle.lowercase().contains(query)
|
||||
}
|
||||
|
||||
private data class CommandSessionRow(
|
||||
val key: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val metadata: String,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun CommandActionList(rows: List<CommandItem>) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)) {
|
||||
ClawSeparatedColumn(items = rows) { row ->
|
||||
CommandActionRow(row = row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommandActionRow(row: CommandItem) {
|
||||
Surface(color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 52.dp)
|
||||
.clip(RoundedCornerShape(ClawTheme.radii.row))
|
||||
.clickable(onClick = row.onClick)
|
||||
.padding(horizontal = 2.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Icon(imageVector = row.icon, contentDescription = null, modifier = Modifier.size(19.dp), tint = ClawTheme.colors.text)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = row.title, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(text = row.subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = "Open ${row.title}",
|
||||
modifier = Modifier.size(17.dp),
|
||||
tint = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommandSessionList(
|
||||
rows: List<CommandSessionRow>,
|
||||
onOpen: (String) -> Unit,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)) {
|
||||
ClawSeparatedColumn(items = rows) { row ->
|
||||
CommandSessionListRow(row = row, onClick = { onOpen(row.key) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommandSessionListRow(
|
||||
row: CommandSessionRow,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 58.dp)
|
||||
.clip(RoundedCornerShape(ClawTheme.radii.row))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 2.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(30.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.canvas,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.Outlined.ChatBubbleOutline, contentDescription = null, modifier = Modifier.size(15.dp), tint = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = row.title, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = row.subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 1)
|
||||
}
|
||||
Text(text = row.metadata, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = "Open session",
|
||||
modifier = Modifier.size(17.dp),
|
||||
tint = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommandIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommandAvatar(text: String) {
|
||||
Surface(
|
||||
modifier = Modifier.size(34.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(text = text.take(2).uppercase(), style = ClawTheme.type.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommandSectionLabel(title: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text = title.uppercase(), style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
private fun providerCommandSubtitle(
|
||||
isConnected: Boolean,
|
||||
providers: List<GatewayModelProviderSummary>,
|
||||
models: List<GatewayModelSummary>,
|
||||
): String {
|
||||
if (!isConnected) return "Connect Gateway to load models"
|
||||
val readyProviderCount = providers.count { modelProviderReady(it.status) }
|
||||
if (readyProviderCount > 0) return "$readyProviderCount providers ready"
|
||||
if (models.isNotEmpty()) return "${models.size} models available"
|
||||
return "Configure model access"
|
||||
}
|
||||
|
||||
private fun commandSessionTitle(displayName: String?): String = displayName?.takeIf { it.isNotBlank() } ?: "Main session"
|
||||
|
||||
private fun commandRelativeTime(updatedAtMs: Long): String {
|
||||
val deltaMs = (System.currentTimeMillis() - updatedAtMs).coerceAtLeast(0L)
|
||||
val minutes = deltaMs / 60_000L
|
||||
if (minutes < 1) return "now"
|
||||
if (minutes < 60) return "${minutes}m"
|
||||
val hours = minutes / 60
|
||||
if (hours < 24) return "${hours}h"
|
||||
return "${hours / 24}d"
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayDreamDiaryEntry
|
||||
import ai.openclaw.app.GatewayDreamingSummary
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun DreamingSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val summary by viewModel.dreamingSummary.collectAsState()
|
||||
val refreshing by viewModel.dreamingRefreshing.collectAsState()
|
||||
val errorText by viewModel.dreamingErrorText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshDreaming()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = "Dreaming",
|
||||
subtitle = "Memory consolidation and dream diary.",
|
||||
icon = Icons.Default.Storage,
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Status", if (summary.enabled) "On" else "Off"),
|
||||
SettingsMetric("Waiting", summary.shortTermCount.toString()),
|
||||
SettingsMetric("Signals", summary.totalSignalCount.toString()),
|
||||
SettingsMetric("Next Cycle", formatDreamingNextRun(summary.nextRunAtMs)),
|
||||
),
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawSecondaryButton(
|
||||
text = if (refreshing) "Refreshing" else "Refresh",
|
||||
onClick = viewModel::refreshDreaming,
|
||||
enabled = isConnected && !refreshing,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
errorText?.let { error ->
|
||||
ClawPanel {
|
||||
Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
when {
|
||||
!isConnected ->
|
||||
ClawPanel {
|
||||
Text(text = "Connect the gateway to load dreaming.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
else -> DreamingPanel(summary = summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DreamingPanel(summary: GatewayDreamingSummary) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
DreamingHealthRow(
|
||||
title = "Memory Store",
|
||||
value = if (summary.storeHealthy) "Healthy" else "Needs attention",
|
||||
healthy = summary.storeHealthy,
|
||||
)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
DreamingHealthRow(
|
||||
title = "Signal Index",
|
||||
value = if (summary.phaseSignalHealthy) "Healthy" else "Needs attention",
|
||||
healthy = summary.phaseSignalHealthy,
|
||||
)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
DreamingHealthRow(
|
||||
title = "Promoted",
|
||||
value = "${summary.promotedToday} today · ${summary.promotedTotal} total",
|
||||
healthy = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
DreamDiaryPanel(summary = summary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DreamingHealthRow(
|
||||
title: String,
|
||||
value: String,
|
||||
healthy: Boolean,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(7.dp))
|
||||
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
|
||||
ClawStatusPill(text = value, status = if (healthy) ClawStatus.Success else ClawStatus.Warning)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DreamDiaryPanel(summary: GatewayDreamingSummary) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text = "DIARY", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
if (!summary.diaryFound) {
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "No dream diary yet.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = "Entries appear after a dreaming cycle writes a narrative summary.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (summary.diaryEntries.isEmpty()) {
|
||||
ClawPanel {
|
||||
Text(text = "The diary is waiting for its first entry.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
return
|
||||
}
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
summary.diaryEntries.forEachIndexed { index, entry ->
|
||||
DreamDiaryRow(entry = entry)
|
||||
if (index != summary.diaryEntries.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DreamDiaryRow(entry: GatewayDreamDiaryEntry) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(30.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.surfacePressed,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(text = "D", style = ClawTheme.type.label, color = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = entry.date, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(text = entry.text, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDreamingNextRun(nextRunAtMs: Long?): String {
|
||||
val next = nextRunAtMs ?: return "Not scheduled"
|
||||
val deltaMinutes = ((next - System.currentTimeMillis()) / 60_000L).coerceAtLeast(0L)
|
||||
val hours = deltaMinutes / 60L
|
||||
return when {
|
||||
hours >= 24L -> "In ${hours / 24L}d"
|
||||
hours >= 1L -> "In ${hours}h"
|
||||
deltaMinutes >= 1L -> "In ${deltaMinutes}m"
|
||||
else -> "Soon"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayHealthLogsSummary
|
||||
import ai.openclaw.app.GatewayLogEntry
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun HealthLogsSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
|
||||
val chatHealthOk by viewModel.chatHealthOk.collectAsState()
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val modelCount by viewModel.modelCatalog.collectAsState()
|
||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||
val talkStatus by viewModel.talkModeStatusText.collectAsState()
|
||||
val logsSummary by viewModel.healthLogsSummary.collectAsState()
|
||||
val logsRefreshing by viewModel.healthLogsRefreshing.collectAsState()
|
||||
val logsErrorText by viewModel.healthLogsErrorText.collectAsState()
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshHealthLogs()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = "Health",
|
||||
subtitle = "Gateway status, phone node readiness, and recent log stream.",
|
||||
icon = Icons.Default.Settings,
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Gateway", if (isConnected) "Online" else "Offline"),
|
||||
SettingsMetric("Node", if (isNodeConnected) "Online" else "Waiting"),
|
||||
SettingsMetric("Models", modelCount.size.toString()),
|
||||
SettingsMetric("Logs", logsSummary.entries.size.toString()),
|
||||
),
|
||||
)
|
||||
HealthStatusPanel(
|
||||
gateway = statusText,
|
||||
node = if (isNodeConnected) "Online" else "Waiting",
|
||||
chat = if (chatHealthOk) "Ready" else "Needs connection",
|
||||
models = "${modelCount.size} available",
|
||||
voice = talkStatus,
|
||||
runs = if (pendingRunCount > 0) "$pendingRunCount active" else "Idle",
|
||||
isConnected = isConnected,
|
||||
isNodeConnected = isNodeConnected,
|
||||
chatHealthOk = chatHealthOk,
|
||||
modelsReady = modelCount.isNotEmpty(),
|
||||
voiceReady = talkStatus.lowercase() != "off",
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawSecondaryButton(
|
||||
text = if (logsRefreshing) "Refreshing" else "Refresh Logs",
|
||||
onClick = viewModel::refreshHealthLogs,
|
||||
enabled = isConnected && !logsRefreshing,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
logsErrorText?.let { error ->
|
||||
ClawPanel {
|
||||
Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
GatewayLogsPanel(isConnected = isConnected, summary = logsSummary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HealthStatusPanel(
|
||||
gateway: String,
|
||||
node: String,
|
||||
chat: String,
|
||||
models: String,
|
||||
voice: String,
|
||||
runs: String,
|
||||
isConnected: Boolean,
|
||||
isNodeConnected: Boolean,
|
||||
chatHealthOk: Boolean,
|
||||
modelsReady: Boolean,
|
||||
voiceReady: Boolean,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
HealthStatusRow(title = "Gateway", value = gateway, healthy = isConnected)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
HealthStatusRow(title = "Phone Node", value = node, healthy = isNodeConnected)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
HealthStatusRow(title = "Chat", value = chat, healthy = chatHealthOk)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
HealthStatusRow(title = "Models", value = models, healthy = modelsReady)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
HealthStatusRow(title = "Voice", value = voice, healthy = voiceReady)
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
HealthStatusRow(title = "Runs", value = runs, healthy = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HealthStatusRow(
|
||||
title: String,
|
||||
value: String,
|
||||
healthy: Boolean,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
|
||||
ClawStatusPill(text = value, status = if (healthy) ClawStatus.Success else ClawStatus.Warning)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GatewayLogsPanel(
|
||||
isConnected: Boolean,
|
||||
summary: GatewayHealthLogsSummary,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = "RECENT LOGS", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
summary.fileName?.let { fileName ->
|
||||
Text(text = fileName, style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
when {
|
||||
!isConnected ->
|
||||
ClawPanel {
|
||||
Text(text = "Connect the gateway to load recent logs.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
summary.entries.isEmpty() ->
|
||||
ClawPanel {
|
||||
Text(text = "No recent log entries.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
else ->
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
val entries = summary.entries.takeLast(12)
|
||||
Column {
|
||||
entries.forEachIndexed { index, entry ->
|
||||
GatewayLogRow(entry = entry)
|
||||
if (index != entries.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (summary.truncated) {
|
||||
Text(text = "Showing the latest log chunk.", style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GatewayLogRow(entry: GatewayLogEntry) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Text(text = compactLogTime(entry.time), style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, modifier = Modifier.weight(0.72f), maxLines = 1)
|
||||
Column(modifier = Modifier.weight(2.7f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = entry.message, style = ClawTheme.type.caption, color = ClawTheme.colors.text, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
entry.subsystem?.let { subsystem ->
|
||||
Text(text = subsystem, style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
ClawStatusPill(text = entry.level?.uppercase() ?: "LOG", status = logLevelStatus(entry.level))
|
||||
}
|
||||
}
|
||||
|
||||
private fun compactLogTime(value: String?): String {
|
||||
val raw = value?.trim().orEmpty()
|
||||
if (raw.isEmpty()) return "--:--"
|
||||
val time =
|
||||
raw
|
||||
.substringAfter('T', raw)
|
||||
.substringBefore('.')
|
||||
.substringBefore('+')
|
||||
.substringBefore('Z')
|
||||
return time.takeIf { it.length >= 5 }?.take(5) ?: raw.take(5)
|
||||
}
|
||||
|
||||
private fun logLevelStatus(level: String?): ClawStatus =
|
||||
when (level?.lowercase()) {
|
||||
"error", "fatal" -> ClawStatus.Danger
|
||||
"warn" -> ClawStatus.Warning
|
||||
"info" -> ClawStatus.Success
|
||||
else -> ClawStatus.Neutral
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayDeviceTokenSummary
|
||||
import ai.openclaw.app.GatewayNodeSummary
|
||||
import ai.openclaw.app.GatewayNodesDevicesSummary
|
||||
import ai.openclaw.app.GatewayPairedDeviceSummary
|
||||
import ai.openclaw.app.GatewayPendingDeviceSummary
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawDetailRow
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawTextBadge
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun NodesDevicesSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val summary by viewModel.nodesDevicesSummary.collectAsState()
|
||||
val refreshing by viewModel.nodesDevicesRefreshing.collectAsState()
|
||||
val errorText by viewModel.nodesDevicesErrorText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshNodesDevices()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = "Nodes & Devices",
|
||||
subtitle = "Live nodes, paired phones, and pending device requests.",
|
||||
icon = Icons.Default.Cloud,
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Nodes", summary.nodes.size.toString()),
|
||||
SettingsMetric("Online", summary.nodes.count { it.connected }.toString()),
|
||||
SettingsMetric("Devices", if (summary.devicePairingAvailable) summary.pairedDevices.size.toString() else "Locked"),
|
||||
SettingsMetric("Pending", summary.pendingDevices.size.toString()),
|
||||
),
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawSecondaryButton(
|
||||
text = if (refreshing) "Refreshing" else "Refresh",
|
||||
onClick = viewModel::refreshNodesDevices,
|
||||
enabled = isConnected && !refreshing,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
errorText?.let {
|
||||
ClawPanel {
|
||||
Text(text = it, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
when {
|
||||
!isConnected ->
|
||||
ClawPanel {
|
||||
Text(text = "Connect the gateway to load nodes and paired devices.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
summary.isEmpty() ->
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "No nodes or paired devices.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = "Linked phones and node hosts will appear here after pairing.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
else -> NodesDevicesPanel(summary = summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NodesDevicesPanel(summary: GatewayNodesDevicesSummary) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (!summary.devicePairingAvailable) {
|
||||
ClawPanel {
|
||||
Text(text = "Pairing controls are not available from this connection.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
if (summary.pendingDevices.isNotEmpty()) {
|
||||
NodesSection(title = "Pending Requests") {
|
||||
summary.pendingDevices.forEachIndexed { index, device ->
|
||||
PendingDeviceRow(device = device)
|
||||
if (index != summary.pendingDevices.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (summary.nodes.isNotEmpty()) {
|
||||
NodesSection(title = "Nodes") {
|
||||
summary.nodes.forEachIndexed { index, node ->
|
||||
NodeRow(node = node)
|
||||
if (index != summary.nodes.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (summary.pairedDevices.isNotEmpty()) {
|
||||
NodesSection(title = "Paired Devices") {
|
||||
summary.pairedDevices.forEachIndexed { index, device ->
|
||||
PairedDeviceRow(device = device)
|
||||
if (index != summary.pairedDevices.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NodesSection(
|
||||
title: String,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text = title.uppercase(), style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NodeRow(node: GatewayNodeSummary) {
|
||||
DeviceListRow(
|
||||
badge = nodeBadge(node.displayName ?: node.id),
|
||||
title = node.displayName ?: node.id,
|
||||
subtitle = nodeSubtitle(node),
|
||||
statusText = if (node.connected) "Online" else "Offline",
|
||||
status = if (node.connected) ClawStatus.Success else ClawStatus.Warning,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PendingDeviceRow(device: GatewayPendingDeviceSummary) {
|
||||
DeviceListRow(
|
||||
badge = nodeBadge(device.displayName ?: device.deviceId),
|
||||
title = device.displayName ?: "New device",
|
||||
subtitle = pendingDeviceSubtitle(device),
|
||||
statusText = if (device.repair) "Repair" else "Review",
|
||||
status = ClawStatus.Warning,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PairedDeviceRow(device: GatewayPairedDeviceSummary) {
|
||||
DeviceListRow(
|
||||
badge = nodeBadge(device.displayName ?: device.deviceId),
|
||||
title = device.displayName ?: "Paired device",
|
||||
subtitle = pairedDeviceSubtitle(device),
|
||||
statusText = pairedDeviceStatusText(device.tokens),
|
||||
status = pairedDeviceStatus(device.tokens),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceListRow(
|
||||
badge: String,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
statusText: String,
|
||||
status: ClawStatus,
|
||||
) {
|
||||
ClawDetailRow(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
leading = { ClawTextBadge(text = badge) },
|
||||
trailing = { ClawStatusPill(text = statusText, status = status) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun GatewayNodesDevicesSummary.isEmpty(): Boolean = nodes.isEmpty() && pendingDevices.isEmpty() && pairedDevices.isEmpty()
|
||||
|
||||
private fun nodeSubtitle(node: GatewayNodeSummary): String {
|
||||
val kind = node.deviceFamily ?: "Node host"
|
||||
val version = node.version?.let { "OpenClaw $it" }
|
||||
val status = if (node.paired) "Paired" else "Unpaired"
|
||||
val commands =
|
||||
node.commands
|
||||
.take(2)
|
||||
.joinToString(", ")
|
||||
.takeIf { it.isNotBlank() }
|
||||
return listOfNotNull(kind, version, status, commands).joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun pendingDeviceSubtitle(device: GatewayPendingDeviceSummary): String {
|
||||
val roles = formatDeviceList(device.roles, "role")
|
||||
val scopes = formatDeviceList(device.scopes, "scope")
|
||||
val requested = device.requestedAtMs?.let { "requested ${relativeDeviceTime(it)}" }
|
||||
return listOfNotNull(roles, scopes, requested, device.remoteIp).joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun pairedDeviceSubtitle(device: GatewayPairedDeviceSummary): String {
|
||||
val roles = formatDeviceList(device.roles, "role")
|
||||
val scopes = formatDeviceList(device.scopes, "scope")
|
||||
val tokens = "${device.tokens.count { !it.revoked }}/${device.tokens.size} active tokens"
|
||||
return listOfNotNull(roles, scopes, tokens, device.remoteIp).joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun pairedDeviceStatusText(tokens: List<GatewayDeviceTokenSummary>): String =
|
||||
when {
|
||||
tokens.isEmpty() -> "Paired"
|
||||
tokens.any { !it.revoked } -> "Active"
|
||||
else -> "Needs Token"
|
||||
}
|
||||
|
||||
private fun pairedDeviceStatus(tokens: List<GatewayDeviceTokenSummary>): ClawStatus =
|
||||
when {
|
||||
tokens.isEmpty() -> ClawStatus.Neutral
|
||||
tokens.any { !it.revoked } -> ClawStatus.Success
|
||||
else -> ClawStatus.Warning
|
||||
}
|
||||
|
||||
private fun formatDeviceList(
|
||||
values: List<String>,
|
||||
fallback: String,
|
||||
): String? =
|
||||
when (values.size) {
|
||||
0 -> null
|
||||
1 -> values.first()
|
||||
else -> "${values.size} ${fallback}s"
|
||||
}
|
||||
|
||||
private fun nodeBadge(value: String): String =
|
||||
value
|
||||
.split(' ', '-', '_')
|
||||
.filter { it.isNotBlank() }
|
||||
.take(2)
|
||||
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
|
||||
.joinToString("")
|
||||
.ifBlank { "N" }
|
||||
|
||||
private fun relativeDeviceTime(timeMs: Long): String {
|
||||
val minutes = ((System.currentTimeMillis() - timeMs).coerceAtLeast(0L)) / 60_000L
|
||||
if (minutes < 1) return "now"
|
||||
if (minutes < 60) return "${minutes}m ago"
|
||||
val hours = minutes / 60L
|
||||
if (hours < 24) return "${hours}h ago"
|
||||
return "${hours / 24L}d ago"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,558 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayModelProviderSummary
|
||||
import ai.openclaw.app.GatewayModelSummary
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.providerDisplayName
|
||||
import ai.openclaw.app.ui.design.ClawEmptyState
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
import ai.openclaw.app.ui.design.ClawScaffold
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
internal fun ProvidersModelsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
onAddProvider: () -> Unit,
|
||||
) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val models by viewModel.modelCatalog.collectAsState()
|
||||
val providers by viewModel.modelAuthProviders.collectAsState()
|
||||
val refreshing by viewModel.modelCatalogRefreshing.collectAsState()
|
||||
val errorText by viewModel.modelCatalogErrorText.collectAsState()
|
||||
val providerRows = providerRows(providers = providers, models = models)
|
||||
val modelGroups = sortedModelGroups(models)
|
||||
val setupRows = providerSetupRows(providerRows)
|
||||
var expandedModelProviders by rememberSaveable { mutableStateOf(emptyList<String>()) }
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshModelCatalog()
|
||||
}
|
||||
}
|
||||
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 13.dp, end = 20.dp, bottom = 13.dp)) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(7.dp), contentPadding = PaddingValues(bottom = 112.dp)) {
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
ProviderHeaderIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", onClick = onBack)
|
||||
ProviderHeaderIconButton(icon = Icons.Default.Add, contentDescription = "Add provider", outlined = true, onClick = onAddProvider)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "Providers & Models", style = ClawTheme.type.display.copy(fontSize = 14.8.sp, lineHeight = 18.sp), color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(
|
||||
text = "Connect and manage AI providers\nBrowse models and their capabilities.",
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
ProviderOverviewPanel(
|
||||
isConnected = isConnected,
|
||||
providerRows = providerRows,
|
||||
modelCount = models.size,
|
||||
onRefresh = viewModel::refreshModelCatalog,
|
||||
onSetup = onAddProvider,
|
||||
refreshing = refreshing,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
ProviderSectionLabel(title = "Provider setup")
|
||||
}
|
||||
|
||||
item {
|
||||
ProviderSetupList(rows = setupRows, onSetup = onAddProvider)
|
||||
}
|
||||
|
||||
item {
|
||||
ProviderSectionLabel(title = "Connected providers")
|
||||
}
|
||||
|
||||
item {
|
||||
if (!isConnected && providerRows.isEmpty()) {
|
||||
ClawEmptyState(title = "Gateway offline", body = "Connect your Gateway to load provider readiness and model catalog.")
|
||||
} else {
|
||||
ProviderList(rows = providerRows, refreshing = refreshing)
|
||||
}
|
||||
}
|
||||
|
||||
errorText?.let { message ->
|
||||
item {
|
||||
ClawPanel {
|
||||
Text(text = message, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
ProviderSectionLabel(title = "Model catalog")
|
||||
}
|
||||
|
||||
if (modelGroups.isEmpty()) {
|
||||
item {
|
||||
ModelCatalogEmpty(
|
||||
title = if (refreshing) "Loading models" else "No models loaded",
|
||||
body = if (isConnected) "Refresh after configuring a provider on the Gateway." else "Connect the Gateway to browse models.",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(modelGroups, key = { it.first }) { entry ->
|
||||
val expanded = expandedModelProviders.contains(entry.first)
|
||||
ModelGroup(
|
||||
provider = entry.first,
|
||||
models = entry.second,
|
||||
expanded = expanded,
|
||||
onToggle = {
|
||||
expandedModelProviders =
|
||||
if (expanded) {
|
||||
expandedModelProviders - entry.first
|
||||
} else {
|
||||
expandedModelProviders + entry.first
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ProviderAddButton(onClick = onAddProvider, modifier = Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class ProviderSetupRow(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val subtitle: String,
|
||||
val ready: Boolean,
|
||||
)
|
||||
|
||||
private data class ProviderRow(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val status: String,
|
||||
val ready: Boolean,
|
||||
val modelCount: Int,
|
||||
)
|
||||
|
||||
private fun providerRows(
|
||||
providers: List<GatewayModelProviderSummary>,
|
||||
models: List<GatewayModelSummary>,
|
||||
): List<ProviderRow> {
|
||||
val modelCounts = models.groupingBy { it.provider }.eachCount()
|
||||
val authRows =
|
||||
providers.map { provider ->
|
||||
val ready = modelProviderReady(provider.status)
|
||||
ProviderRow(
|
||||
id = provider.id,
|
||||
name = provider.displayName,
|
||||
status = if (ready) "Ready" else "Needs setup",
|
||||
ready = ready,
|
||||
modelCount = modelCounts[provider.id] ?: 0,
|
||||
)
|
||||
}
|
||||
val missingAuthRows =
|
||||
modelCounts.keys
|
||||
.filter { provider -> authRows.none { it.id == provider } }
|
||||
.map { provider ->
|
||||
ProviderRow(
|
||||
id = provider,
|
||||
name = providerDisplayName(provider),
|
||||
status = "Ready",
|
||||
ready = true,
|
||||
modelCount = modelCounts[provider] ?: 0,
|
||||
)
|
||||
}
|
||||
return (authRows + missingAuthRows).sortedWith(compareBy(::providerPriority, { it.name.lowercase() }))
|
||||
}
|
||||
|
||||
private fun providerSetupRows(providerRows: List<ProviderRow>): List<ProviderSetupRow> {
|
||||
val byId = providerRows.associateBy { it.id.trim().lowercase() }
|
||||
return listOf("openai", "anthropic", "google", "openrouter", "ollama").map { id ->
|
||||
val row = byId[id] ?: byId["ollama-local"].takeIf { id == "ollama" }
|
||||
ProviderSetupRow(
|
||||
id = id,
|
||||
name = providerDisplayName(id),
|
||||
subtitle = providerSetupSubtitle(id, row),
|
||||
ready = row?.ready == true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun providerSetupSubtitle(
|
||||
id: String,
|
||||
row: ProviderRow?,
|
||||
): String =
|
||||
when {
|
||||
row?.ready == true -> if (row.modelCount > 0) "${row.modelCount} models available" else "Ready"
|
||||
row != null -> "Finish setup to use ${row.name}"
|
||||
id == "ollama" -> "Use models running on your network"
|
||||
else -> "Add provider credentials on your Gateway"
|
||||
}
|
||||
|
||||
internal fun modelProviderReady(status: String): Boolean {
|
||||
val normalized = status.trim().lowercase()
|
||||
return normalized == "ok" ||
|
||||
normalized == "ready" ||
|
||||
normalized == "healthy" ||
|
||||
normalized == "configured" ||
|
||||
normalized == "static"
|
||||
}
|
||||
|
||||
private fun sortedModelGroups(models: List<GatewayModelSummary>): List<Pair<String, List<GatewayModelSummary>>> =
|
||||
models
|
||||
.groupBy { it.provider }
|
||||
.entries
|
||||
.sortedWith(compareBy({ providerPriority(it.key) }, { providerDisplayName(it.key).lowercase() }))
|
||||
.map { it.key to it.value }
|
||||
|
||||
private fun providerPriority(row: ProviderRow): Int = providerPriority(row.id)
|
||||
|
||||
private fun providerPriority(provider: String): Int =
|
||||
when (provider.trim().lowercase()) {
|
||||
"openai" -> 0
|
||||
"anthropic" -> 1
|
||||
"google" -> 2
|
||||
"openrouter" -> 3
|
||||
"ollama", "ollama-local" -> 4
|
||||
"codex", "openai-codex" -> 5
|
||||
else -> 100
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderList(
|
||||
rows: List<ProviderRow>,
|
||||
refreshing: Boolean,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
if (rows.isEmpty()) {
|
||||
ProviderListRow(ProviderRow(id = "loading", name = "Provider catalog", status = if (refreshing) "Loading" else "No providers", ready = false, modelCount = 0))
|
||||
} else {
|
||||
val visibleRows = rows.take(5)
|
||||
visibleRows.forEachIndexed { index, row ->
|
||||
ProviderListRow(row)
|
||||
if (index != visibleRows.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderOverviewPanel(
|
||||
isConnected: Boolean,
|
||||
providerRows: List<ProviderRow>,
|
||||
modelCount: Int,
|
||||
refreshing: Boolean,
|
||||
onRefresh: () -> Unit,
|
||||
onSetup: () -> Unit,
|
||||
) {
|
||||
val readyCount = providerRows.count { it.ready }
|
||||
val needsSetupCount = providerRows.count { !it.ready }
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ProviderMetricTile(label = "Ready", value = readyCount.toString(), modifier = Modifier.weight(1f))
|
||||
ProviderMetricTile(label = "Models", value = modelCount.toString(), modifier = Modifier.weight(1f))
|
||||
ProviderMetricTile(label = "Setup", value = needsSetupCount.toString(), modifier = Modifier.weight(1f))
|
||||
}
|
||||
Text(
|
||||
text = if (isConnected) "Choose a provider below, then finish credentials on your Gateway." else "Connect your Gateway before adding model providers.",
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawSecondaryButton(text = if (refreshing) "Refreshing" else "Refresh", onClick = onRefresh, enabled = isConnected && !refreshing, modifier = Modifier.weight(1f))
|
||||
ClawPrimaryButton(text = "Setup Provider", onClick = onSetup, enabled = isConnected, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderMetricTile(
|
||||
label: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.surface,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
contentColor = ClawTheme.colors.text,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 9.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(text = value, style = ClawTheme.type.title, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = label, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderSetupList(
|
||||
rows: List<ProviderSetupRow>,
|
||||
onSetup: () -> Unit,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
rows.forEachIndexed { index, row ->
|
||||
ProviderSetupListRow(row = row, onClick = onSetup)
|
||||
if (index != rows.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderSetupListRow(
|
||||
row: ProviderSetupRow,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
ProviderBadge(text = row.name)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = row.name, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = row.subtitle, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Box(modifier = Modifier.size(5.dp).clip(CircleShape).background(if (row.ready) ClawTheme.colors.success else ClawTheme.colors.warning))
|
||||
Text(text = if (row.ready) "Ready" else "Setup", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Open ${row.name}", modifier = Modifier.size(17.dp), tint = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderListRow(row: ProviderRow) {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ProviderBadge(text = row.name)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = row.name, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = if (row.modelCount > 0) "${row.modelCount} models" else "Provider setup", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(if (row.ready) ClawTheme.colors.success else ClawTheme.colors.warning))
|
||||
Text(text = row.status, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderBadge(text: String) {
|
||||
Surface(modifier = Modifier.size(30.dp), shape = RoundedCornerShape(ClawTheme.radii.row), color = ClawTheme.colors.surfacePressed, border = BorderStroke(1.dp, ClawTheme.colors.border)) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(text = providerInitials(text), style = ClawTheme.type.label, color = ClawTheme.colors.text, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun providerInitials(value: String): String =
|
||||
value
|
||||
.split(' ', '-', '_')
|
||||
.filter { it.isNotBlank() }
|
||||
.take(2)
|
||||
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
|
||||
.joinToString("")
|
||||
.ifBlank { "AI" }
|
||||
|
||||
@Composable
|
||||
private fun ModelCatalogEmpty(
|
||||
title: String,
|
||||
body: String,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 11.dp, vertical = 10.dp)) {
|
||||
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = body, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModelGroup(
|
||||
provider: String,
|
||||
models: List<GatewayModelSummary>,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
Surface(onClick = onToggle, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ProviderBadge(text = providerDisplayName(provider))
|
||||
Text(text = providerDisplayName(provider), style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
|
||||
ProviderMiniTag(text = "${models.size} models")
|
||||
Icon(imageVector = if (expanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = if (expanded) "Collapse ${providerDisplayName(provider)} models" else "Expand ${providerDisplayName(provider)} models", modifier = Modifier.size(14.dp), tint = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
val visibleModels = if (expanded) models else models.take(3)
|
||||
visibleModels.forEachIndexed { index, model ->
|
||||
ModelRow(model)
|
||||
if (index != visibleModels.lastIndex || models.size > visibleModels.size) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
if (models.size > visibleModels.size) {
|
||||
Surface(onClick = onToggle, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = "View all models", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, modifier = Modifier.weight(1f))
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "View all models", modifier = Modifier.size(14.dp), tint = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModelRow(model: GatewayModelSummary) {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(horizontal = 10.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text = model.name, style = ClawTheme.type.mono, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
modelCapabilityLabels(model).take(3).forEach { label ->
|
||||
ProviderMiniTag(text = label)
|
||||
}
|
||||
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(ClawTheme.colors.success))
|
||||
}
|
||||
}
|
||||
|
||||
private fun modelCapabilityLabels(model: GatewayModelSummary): List<String> =
|
||||
buildList {
|
||||
if (model.supportsReasoning) add("Reasoning")
|
||||
if (model.supportsVision) add("Vision")
|
||||
if (model.supportsAudio) add("Voice")
|
||||
if (model.supportsDocuments) add("Docs")
|
||||
if ((model.contextTokens ?: 0L) >= 100_000L) add("Long context")
|
||||
if (isEmpty()) add("Fast")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderSectionLabel(title: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(text = title.uppercase(), style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderHeaderIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
outlined: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
|
||||
shape = CircleShape,
|
||||
color = Color.Transparent,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = if (outlined) BorderStroke(1.dp, ClawTheme.colors.borderStrong) else null,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(if (outlined) 17.dp else 20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderAddButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier.fillMaxWidth().height(ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.primary,
|
||||
contentColor = ClawTheme.colors.primaryText,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Add, contentDescription = null, modifier = Modifier.size(17.dp))
|
||||
Spacer(modifier = Modifier.width(7.dp))
|
||||
Text(text = "Open Gateway Setup", style = ClawTheme.type.label, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderMiniTag(text: String) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(5.dp),
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
contentColor = ClawTheme.colors.textMuted,
|
||||
) {
|
||||
Text(text = text, modifier = Modifier.padding(horizontal = 4.dp, vertical = 0.5.dp), style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), maxLines = 1)
|
||||
}
|
||||
}
|
||||
@@ -16,5 +16,5 @@ fun RootScreen(viewModel: MainViewModel) {
|
||||
return
|
||||
}
|
||||
|
||||
PostOnboardingTabs(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
||||
ShellScreen(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawEmptyState
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
import ai.openclaw.app.ui.design.ClawScaffold
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.StarBorder
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.filled.SwapVert
|
||||
import androidx.compose.material.icons.outlined.AccessTime
|
||||
import androidx.compose.material.icons.outlined.ChatBubbleOutline
|
||||
import androidx.compose.material.icons.outlined.MicNone
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
internal fun SessionsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onOpenCommand: () -> Unit,
|
||||
onOpenChat: () -> Unit,
|
||||
) {
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
val chatSessionKey by viewModel.chatSessionKey.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
var filter by rememberSaveable { mutableStateOf(SessionFilter.Recent) }
|
||||
var compactLayout by rememberSaveable { mutableStateOf(false) }
|
||||
var recentFirst by rememberSaveable { mutableStateOf(true) }
|
||||
val visibleSessions =
|
||||
sessions
|
||||
.let { rows ->
|
||||
when (filter) {
|
||||
SessionFilter.Recent -> rows
|
||||
SessionFilter.Live -> rows.filter { it.key == chatSessionKey }
|
||||
}
|
||||
}.let { rows ->
|
||||
if (recentFirst) {
|
||||
rows.sortedByDescending { it.updatedAtMs ?: 0L }
|
||||
} else {
|
||||
rows.sortedBy { it.updatedAtMs ?: 0L }
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshChatSessions(limit = 200)
|
||||
}
|
||||
}
|
||||
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 20.dp)) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(text = "Sessions", style = ClawTheme.type.display.copy(fontSize = 17.4.sp, lineHeight = 21.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
|
||||
SessionPlainIconButton(icon = Icons.Default.Search, contentDescription = "Search sessions", onClick = onOpenCommand)
|
||||
SessionPlainIconButton(icon = Icons.Default.SwapVert, contentDescription = "Reverse session sort", onClick = { recentFirst = !recentFirst })
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
FilterPill(text = "Recent", icon = Icons.Outlined.AccessTime, active = filter == SessionFilter.Recent, onClick = { filter = SessionFilter.Recent })
|
||||
FilterPill(text = "Live", icon = Icons.Outlined.MicNone, active = filter == SessionFilter.Live, live = sessions.any { it.key == chatSessionKey }, onClick = { filter = SessionFilter.Live })
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(ClawTheme.radii.row))
|
||||
.clickable { recentFirst = !recentFirst }
|
||||
.padding(horizontal = 2.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(text = "Sort: ${if (recentFirst) "Newest" else "Oldest"}", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
Icon(imageVector = Icons.Default.KeyboardArrowDown, contentDescription = null, modifier = Modifier.size(11.dp), tint = ClawTheme.colors.textMuted)
|
||||
}
|
||||
SessionOutlineIconButton(icon = Icons.Default.Storage, contentDescription = "Toggle session layout", onClick = { compactLayout = !compactLayout })
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Text(text = if (compactLayout) "Layout: Compact" else "Layout: Detailed", style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle)
|
||||
}
|
||||
|
||||
if (visibleSessions.isEmpty()) {
|
||||
item {
|
||||
ClawEmptyState(
|
||||
title = emptySessionTitle(filter),
|
||||
body = emptySessionBody(filter),
|
||||
action = { ClawPrimaryButton(text = "Start Chat", onClick = onOpenChat) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(visibleSessions, key = { it.key }) { session ->
|
||||
val active = session.key == chatSessionKey
|
||||
SessionRow(
|
||||
title = displaySessionTitle(session.displayName),
|
||||
subtitle = if (active) "Current session" else "OpenClaw session",
|
||||
metadata = session.updatedAtMs?.let(::relativeSessionTime) ?: "now",
|
||||
active = active,
|
||||
compact = compactLayout,
|
||||
onClick = {
|
||||
viewModel.switchChatSession(session.key)
|
||||
onOpenChat()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterPill(
|
||||
text: String,
|
||||
icon: ImageVector? = null,
|
||||
active: Boolean = false,
|
||||
live: Boolean = false,
|
||||
dropdown: Boolean = false,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick ?: {},
|
||||
enabled = onClick != null,
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
color = if (active) ClawTheme.colors.surfaceRaised else Color.Transparent,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (active) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
icon?.let { Icon(imageVector = it, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.text) }
|
||||
Text(text = text, style = ClawTheme.type.label, color = ClawTheme.colors.text, maxLines = 1)
|
||||
if (live) {
|
||||
Box(modifier = Modifier.size(4.dp).clip(CircleShape).background(ClawTheme.colors.success))
|
||||
}
|
||||
if (dropdown) {
|
||||
Icon(imageVector = Icons.Default.KeyboardArrowDown, contentDescription = null, modifier = Modifier.size(11.dp), tint = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
metadata: String,
|
||||
active: Boolean,
|
||||
compact: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(7.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(30.dp),
|
||||
shape = CircleShape,
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = if (active) Icons.Default.StarBorder else Icons.Outlined.ChatBubbleOutline,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = ClawTheme.colors.text,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.5.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.text,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (active) {
|
||||
Box(modifier = Modifier.size(3.5.dp).clip(CircleShape).background(ClawTheme.colors.success))
|
||||
}
|
||||
}
|
||||
if (!compact) {
|
||||
Text(text = subtitle, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
SessionMiniTag(text = "Workspace")
|
||||
SessionMiniTag(text = if (active) "Active" else "OpenClaw")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Icon(imageVector = Icons.Outlined.ChatBubbleOutline, contentDescription = null, modifier = Modifier.size(13.dp), tint = ClawTheme.colors.textMuted)
|
||||
Text(text = metadata, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionPlainIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionOutlineIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
color = Color.Transparent,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(14.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionMiniTag(text: String) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(5.dp),
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
contentColor = ClawTheme.colors.textMuted,
|
||||
) {
|
||||
Text(text = text, modifier = Modifier.padding(horizontal = 4.dp, vertical = 0.5.dp), style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
private enum class SessionFilter {
|
||||
Recent,
|
||||
Live,
|
||||
}
|
||||
|
||||
private fun emptySessionTitle(filter: SessionFilter): String =
|
||||
when (filter) {
|
||||
SessionFilter.Recent -> "No sessions yet"
|
||||
SessionFilter.Live -> "No live session"
|
||||
}
|
||||
|
||||
private fun emptySessionBody(filter: SessionFilter): String =
|
||||
when (filter) {
|
||||
SessionFilter.Recent -> "Start a new conversation and it will show up here."
|
||||
SessionFilter.Live -> "Open Chat to start or resume the current session."
|
||||
}
|
||||
|
||||
private fun relativeSessionTime(updatedAtMs: Long): String {
|
||||
val deltaMs = (System.currentTimeMillis() - updatedAtMs).coerceAtLeast(0L)
|
||||
val minutes = deltaMs / 60_000L
|
||||
if (minutes < 1) return "now"
|
||||
if (minutes < 60) return "${minutes}m"
|
||||
val hours = minutes / 60
|
||||
if (hours < 24) return "${hours}h"
|
||||
return "${hours / 24}d"
|
||||
}
|
||||
|
||||
private fun displaySessionTitle(displayName: String?): String = displayName?.takeIf { it.isNotBlank() } ?: "Main session"
|
||||
1146
apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt
Normal file
1146
apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -209,6 +209,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||
}
|
||||
val callLogPermissionAvailable = remember { SensitiveFeatureConfig.callLogEnabled }
|
||||
val photosPermissionAvailable = remember { SensitiveFeatureConfig.photosEnabled }
|
||||
val photosPermission =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
@@ -245,8 +246,11 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
var photosPermissionGranted by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, photosPermission) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
if (photosPermissionAvailable) {
|
||||
ContextCompat.checkSelfPermission(context, photosPermission) == PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
false
|
||||
},
|
||||
)
|
||||
}
|
||||
val photosPermissionLauncher =
|
||||
@@ -347,8 +351,11 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
notificationListenerEnabled = isNotificationListenerEnabled(context)
|
||||
installedNotificationApps = queryInstalledApps(context, notificationForwardingPackages)
|
||||
photosPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, photosPermission) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (photosPermissionAvailable) {
|
||||
ContextCompat.checkSelfPermission(context, photosPermission) == PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
false
|
||||
}
|
||||
contactsPermissionGranted =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
|
||||
PackageManager.PERMISSION_GRANTED &&
|
||||
@@ -980,31 +987,33 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
}
|
||||
item {
|
||||
Column(modifier = Modifier.settingsRowModifier()) {
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Photos", style = mobileHeadline) },
|
||||
supportingContent = { Text("Access recent photos.", style = mobileCallout) },
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (photosPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
photosPermissionLauncher.launch(photosPermission)
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (photosPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
if (photosPermissionAvailable) {
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Photos", style = mobileHeadline) },
|
||||
supportingContent = { Text("Access recent photos.", style = mobileCallout) },
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (photosPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
photosPermissionLauncher.launch(photosPermission)
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (photosPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
}
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
|
||||
1019
apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt
Normal file
1019
apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewaySkillSummary
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.design.ClawDetailRow
|
||||
import ai.openclaw.app.ui.design.ClawListPanel
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawTextBadge
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun SkillsSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val skillsSummary by viewModel.skillsSummary.collectAsState()
|
||||
val skillsRefreshing by viewModel.skillsRefreshing.collectAsState()
|
||||
val skillsErrorText by viewModel.skillsErrorText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val skills = skillsSummary.skills
|
||||
val readyCount = skills.count { skillReady(it) }
|
||||
val needsSetupCount = skills.count { skillNeedsSetup(it) }
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshSkills()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDetailFrame(
|
||||
title = "Skills",
|
||||
subtitle = "Installed capabilities available to OpenClaw.",
|
||||
icon = Icons.Default.Settings,
|
||||
onBack = onBack,
|
||||
) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Installed", skills.size.toString()),
|
||||
SettingsMetric("Ready", readyCount.toString()),
|
||||
SettingsMetric("Needs Setup", needsSetupCount.toString()),
|
||||
),
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawSecondaryButton(
|
||||
text = if (skillsRefreshing) "Refreshing" else "Refresh",
|
||||
onClick = viewModel::refreshSkills,
|
||||
enabled = isConnected && !skillsRefreshing,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
skillsErrorText?.let { errorText ->
|
||||
ClawPanel {
|
||||
Text(text = errorText, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
when {
|
||||
!isConnected ->
|
||||
ClawPanel {
|
||||
Text(text = "Connect the gateway to load skills.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
skills.isEmpty() ->
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "No skills installed.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = "Skills installed on the gateway will appear here.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
else -> SkillsPanel(skills = skills)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillsPanel(skills: List<GatewaySkillSummary>) {
|
||||
ClawListPanel(items = skills) { skill ->
|
||||
SkillListRow(skill = skill)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillListRow(skill: GatewaySkillSummary) {
|
||||
ClawDetailRow(
|
||||
title = skill.name,
|
||||
subtitle = skillSubtitle(skill),
|
||||
leading = { ClawTextBadge(text = skillBadge(skill)) },
|
||||
trailing = { ClawStatusPill(text = skillStatusText(skill), status = skillStatus(skill)) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun skillReady(skill: GatewaySkillSummary): Boolean = !skill.disabled && skill.eligible && skill.missingCount == 0
|
||||
|
||||
private fun skillNeedsSetup(skill: GatewaySkillSummary): Boolean = !skill.disabled && (skill.blockedByAllowlist || !skill.eligible || skill.missingCount > 0)
|
||||
|
||||
private fun skillStatusText(skill: GatewaySkillSummary): String =
|
||||
when {
|
||||
skill.disabled -> "Off"
|
||||
skillNeedsSetup(skill) -> "Setup"
|
||||
else -> "Ready"
|
||||
}
|
||||
|
||||
private fun skillStatus(skill: GatewaySkillSummary): ClawStatus =
|
||||
when {
|
||||
skill.disabled -> ClawStatus.Neutral
|
||||
skillNeedsSetup(skill) -> ClawStatus.Warning
|
||||
else -> ClawStatus.Success
|
||||
}
|
||||
|
||||
private fun skillSubtitle(skill: GatewaySkillSummary): String {
|
||||
val issue =
|
||||
when {
|
||||
skill.disabled -> "Disabled"
|
||||
skill.blockedByAllowlist -> "Blocked"
|
||||
skill.missingCount > 0 -> "${skill.missingCount} missing"
|
||||
!skill.eligible -> "Needs setup"
|
||||
else -> null
|
||||
}
|
||||
return listOfNotNull(skill.description, skillSourceLabel(skill), issue).joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun skillSourceLabel(skill: GatewaySkillSummary): String =
|
||||
when (skill.source) {
|
||||
"openclaw-bundled" -> if (skill.bundled) "Built-in" else "Bundled"
|
||||
"openclaw-managed" -> "Installed"
|
||||
"openclaw-workspace" -> "Workspace"
|
||||
"openclaw-extra" -> "Extra"
|
||||
else -> "Skill"
|
||||
}
|
||||
|
||||
private fun skillBadge(skill: GatewaySkillSummary): String {
|
||||
skill.emoji?.let { return it }
|
||||
return skill.name
|
||||
.split(' ', '-', '_')
|
||||
.filter { it.isNotBlank() }
|
||||
.take(2)
|
||||
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
|
||||
.joinToString("")
|
||||
.ifBlank { "S" }
|
||||
}
|
||||
954
apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt
Normal file
954
apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt
Normal file
@@ -0,0 +1,954 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.VoiceCaptureMode
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import ai.openclaw.app.voice.VoiceConversationEntry
|
||||
import ai.openclaw.app.voice.VoiceConversationRole
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeOff
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.GraphicEq
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MicOff
|
||||
import androidx.compose.material.icons.filled.Phone
|
||||
import androidx.compose.material.icons.filled.PhoneDisabled
|
||||
import androidx.compose.material.icons.filled.RecordVoiceOver
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.TextFields
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
@Composable
|
||||
fun VoiceScreen(
|
||||
viewModel: MainViewModel,
|
||||
onOpenCommand: () -> Unit,
|
||||
onOpenVoiceSettings: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val gatewayStatus by viewModel.statusText.collectAsState()
|
||||
val voiceCaptureMode by viewModel.voiceCaptureMode.collectAsState()
|
||||
val micEnabled by viewModel.micEnabled.collectAsState()
|
||||
val micCooldown by viewModel.micCooldown.collectAsState()
|
||||
val speakerEnabled by viewModel.speakerEnabled.collectAsState()
|
||||
val micStatusText by viewModel.micStatusText.collectAsState()
|
||||
val micLiveTranscript by viewModel.micLiveTranscript.collectAsState()
|
||||
val micQueuedMessages by viewModel.micQueuedMessages.collectAsState()
|
||||
val micConversation by viewModel.micConversation.collectAsState()
|
||||
val micIsSending by viewModel.micIsSending.collectAsState()
|
||||
val talkModeEnabled by viewModel.talkModeEnabled.collectAsState()
|
||||
val talkModeListening by viewModel.talkModeListening.collectAsState()
|
||||
val talkModeSpeaking by viewModel.talkModeSpeaking.collectAsState()
|
||||
val talkModeConversation by viewModel.talkModeConversation.collectAsState()
|
||||
|
||||
var pendingAction by remember { mutableStateOf<VoiceAction?>(null) }
|
||||
var hasMicPermission by remember { mutableStateOf(context.hasRecordAudioPermission()) }
|
||||
val requestMicPermission =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
hasMicPermission = granted
|
||||
if (granted) {
|
||||
when (pendingAction) {
|
||||
VoiceAction.Talk -> viewModel.setTalkModeEnabled(true)
|
||||
VoiceAction.Dictation -> viewModel.setMicEnabled(true)
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
pendingAction = null
|
||||
}
|
||||
|
||||
val activeConversation = if (voiceCaptureMode == VoiceCaptureMode.TalkMode) talkModeConversation else micConversation
|
||||
val voiceActive = micEnabled || micIsSending || talkModeEnabled
|
||||
val activeStatus =
|
||||
voiceStatusLabel(
|
||||
gatewayStatus = gatewayStatus,
|
||||
voiceCaptureMode = voiceCaptureMode,
|
||||
micStatusText = micStatusText,
|
||||
micQueuedMessages = micQueuedMessages.size,
|
||||
micIsSending = micIsSending,
|
||||
talkModeListening = talkModeListening,
|
||||
talkModeSpeaking = talkModeSpeaking,
|
||||
)
|
||||
|
||||
if (talkModeEnabled) {
|
||||
TalkSessionScreen(
|
||||
entries = talkModeConversation,
|
||||
listening = talkModeListening,
|
||||
speaking = talkModeSpeaking,
|
||||
speakerEnabled = speakerEnabled,
|
||||
onToggleSpeaker = { viewModel.setSpeakerEnabled(!speakerEnabled) },
|
||||
onEndTalk = { viewModel.setTalkModeEnabled(false) },
|
||||
onOpenVoiceSettings = onOpenVoiceSettings,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (voiceCaptureMode == VoiceCaptureMode.ManualMic || micEnabled || micIsSending) {
|
||||
DictationScreen(
|
||||
liveTranscript = micLiveTranscript,
|
||||
conversation = micConversation,
|
||||
listening = micEnabled,
|
||||
sending = micIsSending,
|
||||
statusText = activeStatus,
|
||||
gatewayStatus = gatewayStatus,
|
||||
onCancel = { viewModel.cancelMicCapture() },
|
||||
onSend = { viewModel.setMicEnabled(false) },
|
||||
onOpenVoiceSettings = onOpenVoiceSettings,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
VoiceHeader(
|
||||
statusText = if (voiceActive) activeStatus else "Your voice command center.",
|
||||
speakerEnabled = speakerEnabled,
|
||||
onToggleSpeaker = { viewModel.setSpeakerEnabled(!speakerEnabled) },
|
||||
onOpenCommand = onOpenCommand,
|
||||
)
|
||||
|
||||
VoiceHero(
|
||||
gatewayStatus = gatewayStatus,
|
||||
voiceCaptureMode = voiceCaptureMode,
|
||||
micEnabled = micEnabled,
|
||||
talkModeEnabled = talkModeEnabled,
|
||||
talkModeListening = talkModeListening,
|
||||
talkModeSpeaking = talkModeSpeaking,
|
||||
micLiveTranscript = micLiveTranscript,
|
||||
onStartTalk = {
|
||||
runVoiceAction(
|
||||
action = VoiceAction.Talk,
|
||||
hasMicPermission = hasMicPermission,
|
||||
requestPermission = {
|
||||
pendingAction = VoiceAction.Talk
|
||||
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
|
||||
},
|
||||
run = { viewModel.setTalkModeEnabled(!talkModeEnabled) },
|
||||
)
|
||||
},
|
||||
onStartDictation = {
|
||||
if (micCooldown) return@VoiceHero
|
||||
runVoiceAction(
|
||||
action = VoiceAction.Dictation,
|
||||
hasMicPermission = hasMicPermission,
|
||||
requestPermission = {
|
||||
pendingAction = VoiceAction.Dictation
|
||||
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
|
||||
},
|
||||
run = { viewModel.setMicEnabled(!micEnabled) },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
if (!hasMicPermission) {
|
||||
VoicePermissionPanel(
|
||||
onRequestPermission = {
|
||||
pendingAction = VoiceAction.Talk
|
||||
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
VoiceTranscript(
|
||||
entries = activeConversation,
|
||||
showThinking = micIsSending && activeConversation.none { it.role == VoiceConversationRole.Assistant && it.isStreaming },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DictationScreen(
|
||||
liveTranscript: String?,
|
||||
conversation: List<VoiceConversationEntry>,
|
||||
listening: Boolean,
|
||||
sending: Boolean,
|
||||
statusText: String,
|
||||
gatewayStatus: String,
|
||||
onCancel: () -> Unit,
|
||||
onSend: () -> Unit,
|
||||
onOpenVoiceSettings: () -> Unit,
|
||||
) {
|
||||
val lastUserText = conversation.lastOrNull { it.role == VoiceConversationRole.User }?.text
|
||||
val draftText = liveTranscript?.takeIf { it.isNotBlank() } ?: lastUserText.orEmpty()
|
||||
val speechProviderReady = gatewayStatus.isVoiceGatewayReady()
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
VoicePlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onCancel)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(text = "Dictation", style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
|
||||
Text(text = "Transcribe then send", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
VoicePlainIconButton(icon = Icons.Default.Settings, contentDescription = "Dictation settings", onClick = onOpenVoiceSettings)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(0.82f),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.canvas,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp, vertical = 12.dp), verticalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = draftText.ifBlank { if (sending) "Sending to chat..." else "Start speaking..." },
|
||||
style = ClawTheme.type.title.copy(fontSize = 15.sp, lineHeight = 19.sp),
|
||||
color = if (draftText.isBlank()) ClawTheme.colors.textSubtle else ClawTheme.colors.text,
|
||||
maxLines = 7,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
DictationWaveform(active = listening || sending)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(7.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(imageVector = Icons.Default.Mic, contentDescription = null, modifier = Modifier.size(15.dp), tint = if (listening) ClawTheme.colors.success else ClawTheme.colors.textMuted)
|
||||
Text(text = statusText, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Surface(
|
||||
modifier = Modifier.size(30.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.surfacePressed,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(16.dp), tint = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(text = "Speech provider", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = gatewayStatus.voiceGatewayLabel(), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text =
|
||||
when {
|
||||
sending -> "Sending"
|
||||
speechProviderReady -> "Ready"
|
||||
else -> "Offline"
|
||||
},
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
|
||||
color =
|
||||
when {
|
||||
sending -> ClawTheme.colors.warning
|
||||
speechProviderReady -> ClawTheme.colors.success
|
||||
else -> ClawTheme.colors.textMuted
|
||||
},
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
when {
|
||||
sending -> ClawTheme.colors.warning
|
||||
speechProviderReady -> ClawTheme.colors.success
|
||||
else -> ClawTheme.colors.textSubtle
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Icon(imageVector = Icons.Default.Info, contentDescription = null, modifier = Modifier.size(16.dp), tint = ClawTheme.colors.textMuted)
|
||||
Text(text = "Tip: stop listening to send the captured turn.", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
ClawSecondaryButton(text = "Cancel", icon = Icons.Default.Close, onClick = onCancel, modifier = Modifier.weight(0.95f))
|
||||
ClawPrimaryButton(text = if (sending) "Sending" else "Send to Chat", icon = Icons.AutoMirrored.Filled.Send, onClick = onSend, enabled = !sending, modifier = Modifier.weight(1.25f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DictationWaveform(active: Boolean) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
List(48) { index ->
|
||||
val height = if (active) 3 + ((index * 7) % 16) else 3 + (index % 3) * 2
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(width = 2.dp, height = height.dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TalkSessionScreen(
|
||||
entries: List<VoiceConversationEntry>,
|
||||
listening: Boolean,
|
||||
speaking: Boolean,
|
||||
speakerEnabled: Boolean,
|
||||
onToggleSpeaker: () -> Unit,
|
||||
onEndTalk: () -> Unit,
|
||||
onOpenVoiceSettings: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(11.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
VoicePlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onEndTalk)
|
||||
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "Realtime Talk", style = ClawTheme.type.title.copy(fontSize = 14.sp, lineHeight = 17.sp), color = ClawTheme.colors.text)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(if (speaking || listening) ClawTheme.colors.success else ClawTheme.colors.textSubtle))
|
||||
Text(
|
||||
text =
|
||||
if (speaking) {
|
||||
"OpenClaw speaking"
|
||||
} else if (listening) {
|
||||
"Realtime voice"
|
||||
} else {
|
||||
"Connected"
|
||||
},
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
VoicePlainIconButton(icon = Icons.Default.Info, contentDescription = "Talk settings", onClick = onOpenVoiceSettings)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().height(58.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.canvas,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
TalkWaveform(active = listening || speaking)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(text = "Live transcript", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
TalkTranscript(entries = entries, modifier = Modifier.weight(1f))
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TalkControl(icon = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff, label = if (speakerEnabled) "Mute" else "Unmute", onClick = onToggleSpeaker)
|
||||
TalkControl(icon = Icons.Default.PhoneDisabled, label = "End", primary = true, onClick = onEndTalk)
|
||||
TalkControl(icon = Icons.Default.GraphicEq, label = "Voice", onClick = onOpenVoiceSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TalkTranscript(
|
||||
entries: List<VoiceConversationEntry>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
if (entries.isEmpty()) {
|
||||
item {
|
||||
TalkTranscriptCard(label = "OpenClaw", text = "Listening for your next turn.", muted = true)
|
||||
}
|
||||
} else {
|
||||
items(entries.takeLast(6), key = { it.id }) { entry ->
|
||||
TalkTranscriptCard(
|
||||
label = if (entry.role == VoiceConversationRole.User) "You" else "OpenClaw",
|
||||
text = if (entry.isStreaming && entry.text.isBlank()) "Listening response..." else entry.text,
|
||||
muted = entry.isStreaming,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TalkTranscriptCard(
|
||||
label: String,
|
||||
text: String,
|
||||
muted: Boolean = false,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.surface,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text(text = label, style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = text, style = ClawTheme.type.body, color = if (muted) ClawTheme.colors.textMuted else ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TalkControl(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
primary: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
|
||||
shape = CircleShape,
|
||||
color = if (primary) ClawTheme.colors.primary else ClawTheme.colors.canvas,
|
||||
contentColor = if (primary) ClawTheme.colors.primaryText else ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (primary) ClawTheme.colors.primary else ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = label, modifier = Modifier.size(if (primary) 20.dp else 18.dp))
|
||||
}
|
||||
}
|
||||
Text(text = label, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TalkWaveform(active: Boolean) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
listOf(4, 12, 24, 34, 46, 28, 12, 38, 44, 24, 12, 30, 42, 18, 6).forEachIndexed { index, height ->
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(width = 3.dp, height = (if (active) height else 6 + index % 4 * 5).dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceHeader(
|
||||
statusText: String,
|
||||
speakerEnabled: Boolean,
|
||||
onToggleSpeaker: () -> Unit,
|
||||
onOpenCommand: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "O P E N C L A W",
|
||||
style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp),
|
||||
color = ClawTheme.colors.text,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
VoicePlainIconButton(icon = Icons.Default.Search, contentDescription = "Search voice", onClick = onOpenCommand)
|
||||
VoiceAvatar(text = "OC")
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "Voice", style = ClawTheme.type.display.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = statusText,
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
VoicePlainIconButton(
|
||||
icon = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
|
||||
contentDescription = if (speakerEnabled) "Mute speaker" else "Unmute speaker",
|
||||
onClick = onToggleSpeaker,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceAvatar(text: String) {
|
||||
Surface(
|
||||
modifier = Modifier.size(34.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(text = text.take(2).uppercase(), style = ClawTheme.type.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoicePlainIconButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceHero(
|
||||
gatewayStatus: String,
|
||||
voiceCaptureMode: VoiceCaptureMode,
|
||||
micEnabled: Boolean,
|
||||
talkModeEnabled: Boolean,
|
||||
talkModeListening: Boolean,
|
||||
talkModeSpeaking: Boolean,
|
||||
micLiveTranscript: String?,
|
||||
onStartTalk: () -> Unit,
|
||||
onStartDictation: () -> Unit,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
VoiceOrb(
|
||||
active = micEnabled || talkModeEnabled,
|
||||
listening = talkModeListening || voiceCaptureMode == VoiceCaptureMode.ManualMic,
|
||||
speaking = talkModeSpeaking,
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (micEnabled || talkModeEnabled) ClawTheme.colors.success else ClawTheme.colors.textSubtle),
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
when {
|
||||
talkModeSpeaking -> "OpenClaw is replying"
|
||||
talkModeListening -> "Listening"
|
||||
talkModeEnabled -> "Talk is live"
|
||||
micEnabled -> "Dictation is listening"
|
||||
else -> "Ready to talk"
|
||||
},
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
if (!micLiveTranscript.isNullOrBlank()) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.surface,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
) {
|
||||
Text(
|
||||
text = micLiveTranscript.trim(),
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.text,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
VoiceModeRow(
|
||||
title = if (talkModeEnabled) "End Talk" else "Realtime Talk",
|
||||
subtitle = if (talkModeEnabled) "Conversation is live" else "Natural conversation in real time",
|
||||
icon = if (talkModeEnabled) Icons.Default.PhoneDisabled else Icons.Default.RecordVoiceOver,
|
||||
onClick = onStartTalk,
|
||||
)
|
||||
VoiceModeRow(
|
||||
title = if (micEnabled) "Stop Dictation" else "Dictation",
|
||||
subtitle = if (micEnabled) "Listening for one turn" else "Convert speech to text",
|
||||
icon = if (micEnabled) Icons.Default.MicOff else Icons.Default.TextFields,
|
||||
onClick = onStartDictation,
|
||||
)
|
||||
}
|
||||
|
||||
VoiceProviderCard(gatewayStatus = gatewayStatus)
|
||||
|
||||
VoicePrimaryAction(
|
||||
text = if (talkModeEnabled) "End Talk" else "Start Talk",
|
||||
icon = if (talkModeEnabled) Icons.Default.PhoneDisabled else Icons.Default.Phone,
|
||||
onClick = onStartTalk,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceModeRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 60.dp).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(34.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.surface,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = subtitle, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, modifier = Modifier.size(21.dp), tint = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceProviderCard(gatewayStatus: String) {
|
||||
val ready = gatewayStatus.isVoiceGatewayReady()
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.surface,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(34.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.canvas,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(17.dp))
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(text = "Provider", style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = gatewayStatus.voiceGatewayLabel(), style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (ready) ClawTheme.colors.success else ClawTheme.colors.textSubtle),
|
||||
)
|
||||
Text(text = if (ready) "Ready" else "Offline", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoicePrimaryAction(
|
||||
text: String,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth().height(ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.primary,
|
||||
contentColor = ClawTheme.colors.primaryText,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(17.dp))
|
||||
Text(text = text, modifier = Modifier.padding(start = 8.dp), style = ClawTheme.type.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceOrb(
|
||||
active: Boolean,
|
||||
listening: Boolean,
|
||||
speaking: Boolean,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(132.dp),
|
||||
shape = CircleShape,
|
||||
color = if (active) ClawTheme.colors.surfacePressed else ClawTheme.colors.surface,
|
||||
border = BorderStroke(1.dp, if (active) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(
|
||||
imageVector =
|
||||
when {
|
||||
speaking -> Icons.Default.RecordVoiceOver
|
||||
listening -> Icons.Default.GraphicEq
|
||||
else -> Icons.Default.Mic
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(38.dp),
|
||||
tint = ClawTheme.colors.text,
|
||||
)
|
||||
Waveform(active = active)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Waveform(active: Boolean) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(3.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
listOf(6, 11, 17, 23, 14, 9, 20, 14, 7).forEachIndexed { index, height ->
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(width = 2.dp, height = (if (active) height else 6 + index % 3 * 3).dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceTranscript(
|
||||
entries: List<VoiceConversationEntry>,
|
||||
showThinking: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(entries.size, showThinking) {
|
||||
if (entries.isNotEmpty() || showThinking) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
contentPadding = PaddingValues(bottom = 8.dp),
|
||||
) {
|
||||
if (showThinking) {
|
||||
item(key = "thinking") {
|
||||
VoiceThinkingCard()
|
||||
}
|
||||
}
|
||||
|
||||
items(entries.asReversed(), key = { it.id }) { entry ->
|
||||
VoiceTurnCard(entry = entry)
|
||||
}
|
||||
|
||||
if (entries.isEmpty() && !showThinking) {
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(text = "Live transcript", style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle)
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 10.dp, vertical = 9.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(text = "No transcript yet", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = "Your words and OpenClaw replies will appear here.",
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceTurnCard(entry: VoiceConversationEntry) {
|
||||
val isUser = entry.role == VoiceConversationRole.User
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(if (isUser) 0.82f else 0.92f),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = if (isUser) ClawTheme.colors.surfacePressed else ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (entry.isStreaming) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text(
|
||||
text = if (isUser) "You" else "OpenClaw",
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp, fontWeight = FontWeight.SemiBold),
|
||||
color = ClawTheme.colors.textSubtle,
|
||||
)
|
||||
Text(
|
||||
text = if (entry.isStreaming && entry.text.isBlank()) "Listening..." else entry.text,
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.text,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceThinkingCard() {
|
||||
ClawPanel {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ClawStatusPill(text = "Sending", status = ClawStatus.Warning)
|
||||
Text(text = "OpenClaw is preparing a response.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoicePermissionPanel(onRequestPermission: () -> Unit) {
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawStatusPill(text = "Permission needed", status = ClawStatus.Warning)
|
||||
Text(text = "Microphone access is needed.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = "OpenClaw only listens when you start Talk or Dictation.",
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
ClawSecondaryButton(text = "Enable Microphone", icon = Icons.Default.Mic, onClick = onRequestPermission)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class VoiceAction {
|
||||
Talk,
|
||||
Dictation,
|
||||
}
|
||||
|
||||
private fun runVoiceAction(
|
||||
action: VoiceAction,
|
||||
hasMicPermission: Boolean,
|
||||
requestPermission: () -> Unit,
|
||||
run: () -> Unit,
|
||||
) {
|
||||
if (hasMicPermission) {
|
||||
run()
|
||||
} else {
|
||||
requestPermission()
|
||||
}
|
||||
}
|
||||
|
||||
private fun voiceStatusLabel(
|
||||
gatewayStatus: String,
|
||||
voiceCaptureMode: VoiceCaptureMode,
|
||||
micStatusText: String,
|
||||
micQueuedMessages: Int,
|
||||
micIsSending: Boolean,
|
||||
talkModeListening: Boolean,
|
||||
talkModeSpeaking: Boolean,
|
||||
): String =
|
||||
when {
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeSpeaking -> "OpenClaw is speaking"
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeListening -> "Listening"
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode -> "Talk is live"
|
||||
micIsSending -> "Sending dictation"
|
||||
voiceCaptureMode == VoiceCaptureMode.ManualMic -> micStatusText.ifBlank { "Listening" }
|
||||
micQueuedMessages > 0 -> "$micQueuedMessages queued"
|
||||
!gatewayStatus.isVoiceGatewayReady() -> "Gateway offline"
|
||||
else -> "Ready to talk"
|
||||
}
|
||||
|
||||
private fun String.isVoiceGatewayReady(): Boolean {
|
||||
val status = lowercase()
|
||||
return !status.contains("offline") && !status.contains("not connected") && !status.contains("failed") && !status.contains("error")
|
||||
}
|
||||
|
||||
private fun String.voiceGatewayLabel(): String = if (isVoiceGatewayReady()) "Connected and ready" else "Gateway not connected"
|
||||
|
||||
private fun Context.hasRecordAudioPermission(): Boolean = ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||
@@ -141,7 +141,7 @@ fun ChatComposer(
|
||||
|
||||
if (!healthOk) {
|
||||
Text(
|
||||
text = "Gateway is offline. Connect first in the Connect tab.",
|
||||
text = "Gateway is offline. Open Settings to reconnect.",
|
||||
style = mobileCallout,
|
||||
color = ai.openclaw.app.ui.mobileWarning,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,853 @@
|
||||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.chat.ChatMessage
|
||||
import ai.openclaw.app.chat.ChatMessageContent
|
||||
import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
import ai.openclaw.app.chat.OutgoingAttachment
|
||||
import ai.openclaw.app.ui.design.ClawListItem
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawStatus
|
||||
import ai.openclaw.app.ui.design.ClawStatusPill
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
onVoice: () -> Unit,
|
||||
) {
|
||||
val messages by viewModel.chatMessages.collectAsState()
|
||||
val errorText by viewModel.chatError.collectAsState()
|
||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||
val healthOk by viewModel.chatHealthOk.collectAsState()
|
||||
val sessionKey by viewModel.chatSessionKey.collectAsState()
|
||||
val mainSessionKey by viewModel.mainSessionKey.collectAsState()
|
||||
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
|
||||
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
|
||||
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
val chatDraft by viewModel.chatDraft.collectAsState()
|
||||
val pendingAssistantAutoSend by viewModel.pendingAssistantAutoSend.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val resolver = context.contentResolver
|
||||
val scope = rememberCoroutineScope()
|
||||
val attachments = remember { mutableStateListOf<PendingImageAttachment>() }
|
||||
val pickImages =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
|
||||
if (uris.isNullOrEmpty()) return@rememberLauncherForActivityResult
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val next =
|
||||
uris.take(8).mapNotNull { uri ->
|
||||
try {
|
||||
loadSizedImageAttachment(resolver, uri)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
attachments.addAll(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val loadSessionKey = resolveInitialChatLoadSessionKey(sessionKey, mainSessionKey)
|
||||
if (loadSessionKey != null) {
|
||||
viewModel.loadChat(loadSessionKey)
|
||||
}
|
||||
viewModel.refreshChatSessions(limit = 100)
|
||||
}
|
||||
|
||||
LaunchedEffect(pendingAssistantAutoSend, healthOk, pendingRunCount, thinkingLevel) {
|
||||
val accepted =
|
||||
dispatchPendingAssistantAutoSend(
|
||||
pendingPrompt = pendingAssistantAutoSend,
|
||||
healthOk = healthOk,
|
||||
pendingRunCount = pendingRunCount,
|
||||
) { prompt ->
|
||||
viewModel.sendChatAwaitAcceptance(message = prompt, thinking = thinkingLevel, attachments = emptyList())
|
||||
}
|
||||
if (accepted) {
|
||||
viewModel.clearPendingAssistantAutoSend()
|
||||
}
|
||||
}
|
||||
|
||||
var input by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(chatDraft) {
|
||||
val draft = chatDraft?.trim()?.ifEmpty { null } ?: return@LaunchedEffect
|
||||
input = draft
|
||||
viewModel.clearChatDraft()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 18.dp, vertical = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
ChatHeader(
|
||||
sessionTitle = currentSessionTitle(sessionKey = sessionKey, sessions = sessions),
|
||||
thinkingLevel = thinkingLevel,
|
||||
healthOk = healthOk,
|
||||
pendingRunCount = pendingRunCount,
|
||||
onBack = onBack,
|
||||
onMore = {
|
||||
viewModel.refreshChat()
|
||||
viewModel.refreshChatSessions(limit = 100)
|
||||
},
|
||||
)
|
||||
|
||||
errorText?.takeIf { it.isNotBlank() }?.let { error ->
|
||||
ChatNotice(title = "Chat needs attention", body = userFacingChatError(error))
|
||||
}
|
||||
|
||||
ChatMessageList(
|
||||
messages = messages,
|
||||
pendingRunCount = pendingRunCount,
|
||||
pendingToolCalls = pendingToolCalls,
|
||||
streamingAssistantText = streamingAssistantText,
|
||||
healthOk = healthOk,
|
||||
onStarterPrompt = { prompt -> input = prompt },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
ChatComposer(
|
||||
value = input,
|
||||
onValueChange = { input = it },
|
||||
attachments = attachments,
|
||||
thinkingLevel = thinkingLevel,
|
||||
healthOk = healthOk,
|
||||
pendingRunCount = pendingRunCount,
|
||||
onThinkingLevelChange = viewModel::setChatThinkingLevel,
|
||||
onPickImages = { pickImages.launch("image/*") },
|
||||
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
|
||||
onVoice = onVoice,
|
||||
onAbort = viewModel::abortChat,
|
||||
onSend = {
|
||||
val message = input.trim()
|
||||
if (message.isEmpty() && attachments.isEmpty()) return@ChatComposer
|
||||
val outgoing =
|
||||
attachments.map { attachment ->
|
||||
OutgoingAttachment(
|
||||
type = "image",
|
||||
mimeType = attachment.mimeType,
|
||||
fileName = attachment.fileName,
|
||||
base64 = attachment.base64,
|
||||
)
|
||||
}
|
||||
input = ""
|
||||
attachments.clear()
|
||||
scope.launch {
|
||||
viewModel.sendChat(message = message, thinking = thinkingLevel, attachments = outgoing)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatHeader(
|
||||
sessionTitle: String,
|
||||
thinkingLevel: String,
|
||||
healthOk: Boolean,
|
||||
pendingRunCount: Int,
|
||||
onBack: () -> Unit,
|
||||
onMore: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
HeaderIcon(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", onClick = onBack)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Text(
|
||||
text = sessionTitle,
|
||||
style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp),
|
||||
color = ClawTheme.colors.text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
ModelPill(
|
||||
text =
|
||||
when {
|
||||
pendingRunCount > 0 -> "Working"
|
||||
healthOk -> "auto"
|
||||
else -> "offline"
|
||||
},
|
||||
status =
|
||||
when {
|
||||
pendingRunCount > 0 -> ClawStatus.Warning
|
||||
healthOk -> ClawStatus.Neutral
|
||||
else -> ClawStatus.Danger
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
HeaderIcon(icon = Icons.Default.Refresh, contentDescription = "Refresh chat", onClick = onMore)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModelPill(
|
||||
text: String,
|
||||
status: ClawStatus,
|
||||
) {
|
||||
val borderColor =
|
||||
if (status == ClawStatus.Warning) {
|
||||
ClawTheme.colors.warning
|
||||
} else {
|
||||
ClawTheme.colors.border
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.textMuted,
|
||||
border = BorderStroke(1.dp, borderColor),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.padding(horizontal = 7.dp, vertical = 1.5.dp),
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderIcon(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
|
||||
shape = CircleShape,
|
||||
color = Color.Transparent,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatMessageList(
|
||||
messages: List<ChatMessage>,
|
||||
pendingRunCount: Int,
|
||||
pendingToolCalls: List<ChatPendingToolCall>,
|
||||
streamingAssistantText: String?,
|
||||
healthOk: Boolean,
|
||||
onStarterPrompt: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val displayMessages = remember(messages) { messages.asReversed() }
|
||||
val stream = streamingAssistantText?.trim()
|
||||
|
||||
LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size) {
|
||||
listState.animateScrollToItem(index = 0)
|
||||
}
|
||||
LaunchedEffect(stream) {
|
||||
if (!stream.isNullOrEmpty()) {
|
||||
listState.scrollToItem(index = 0)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
contentPadding = PaddingValues(top = 6.dp, bottom = 3.dp),
|
||||
) {
|
||||
if (!stream.isNullOrEmpty()) {
|
||||
item(key = "stream") {
|
||||
ChatBubble(role = "assistant", live = true, content = listOf(ChatMessageContent(text = stream)), timestampMs = null)
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingToolCalls.isNotEmpty()) {
|
||||
item(key = "tools") {
|
||||
ToolBubble(toolCalls = pendingToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingRunCount > 0) {
|
||||
item(key = "thinking") {
|
||||
ChatThinkingBubble()
|
||||
}
|
||||
}
|
||||
|
||||
items(items = displayMessages, key = { it.id }) { message ->
|
||||
ChatBubble(role = message.role, live = false, content = message.content, timestampMs = message.timestampMs)
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && stream.isNullOrBlank()) {
|
||||
EmptyChatHint(healthOk = healthOk, onStarterPrompt = onStarterPrompt, modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyChatHint(
|
||||
healthOk: Boolean,
|
||||
onStarterPrompt: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text(text = if (healthOk) "Ready when you are" else "Gateway offline", style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp), color = ClawTheme.colors.text)
|
||||
Text(
|
||||
text =
|
||||
if (healthOk) {
|
||||
"Start with a prompt, or use voice."
|
||||
} else {
|
||||
"Reconnect from Settings to send messages."
|
||||
},
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
if (healthOk) {
|
||||
StarterPromptList(onStarterPrompt = onStarterPrompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StarterPromptList(onStarterPrompt: (String) -> Unit) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
starterPrompts.forEachIndexed { index, prompt ->
|
||||
StarterPromptRow(prompt = prompt, onClick = { onStarterPrompt(prompt.message) })
|
||||
if (index != starterPrompts.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StarterPromptRow(
|
||||
prompt: StarterPrompt,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 54.dp).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(30.dp)
|
||||
.background(ClawTheme.colors.surfacePressed, RoundedCornerShape(ClawTheme.radii.row)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(text = prompt.mark, style = ClawTheme.type.label, color = ClawTheme.colors.text)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = prompt.title, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = prompt.subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class StarterPrompt(
|
||||
val mark: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
private val starterPrompts =
|
||||
listOf(
|
||||
StarterPrompt(mark = "1", title = "Catch me up", subtitle = "Summarize recent sessions and next steps.", message = "Catch me up on my recent OpenClaw sessions and suggest next steps."),
|
||||
StarterPrompt(mark = "2", title = "Plan the work", subtitle = "Turn a goal into an actionable checklist.", message = "Help me turn this goal into a practical checklist: "),
|
||||
StarterPrompt(mark = "3", title = "Use this phone", subtitle = "Ask OpenClaw to use Android capabilities.", message = "What can you help me do from this phone right now?"),
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun ChatBubble(
|
||||
role: String,
|
||||
live: Boolean,
|
||||
content: List<ChatMessageContent>,
|
||||
timestampMs: Long?,
|
||||
) {
|
||||
val normalizedRole = role.trim().lowercase(Locale.US)
|
||||
val isUser = normalizedRole == "user"
|
||||
val displayableContent =
|
||||
content.filter { part ->
|
||||
when (part.type) {
|
||||
"text" -> !part.text.isNullOrBlank()
|
||||
else -> part.base64 != null
|
||||
}
|
||||
}
|
||||
if (displayableContent.isEmpty()) return
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(if (isUser) 0.64f else 0.56f),
|
||||
shape = RoundedCornerShape(7.dp),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (live) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.5.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text =
|
||||
when {
|
||||
live -> "OpenClaw · Live"
|
||||
isUser -> "You"
|
||||
normalizedRole == "system" -> "System"
|
||||
else -> "OpenClaw"
|
||||
},
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp, fontWeight = FontWeight.SemiBold),
|
||||
color = ClawTheme.colors.text,
|
||||
)
|
||||
displayableContent.forEach { part ->
|
||||
if (part.type == "text") {
|
||||
ChatText(text = part.text.orEmpty(), textColor = ClawTheme.colors.text)
|
||||
} else {
|
||||
Text(text = part.fileName ?: "Attachment", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
timestampMs?.let {
|
||||
Text(
|
||||
text = formatChatTimestamp(it),
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
|
||||
color = ClawTheme.colors.textMuted,
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatText(
|
||||
text: String,
|
||||
textColor: Color,
|
||||
) {
|
||||
if (text.hasMarkdownSyntax()) {
|
||||
ChatMarkdown(text = text, textColor = textColor)
|
||||
} else {
|
||||
Text(
|
||||
text = text,
|
||||
style = ClawTheme.type.body,
|
||||
color = textColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolBubble(toolCalls: List<ChatPendingToolCall>) {
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawStatusPill(text = "Tools running", status = ClawStatus.Warning)
|
||||
toolCalls.take(4).forEach { tool ->
|
||||
ClawListItem(title = tool.name, subtitle = "OpenClaw is working")
|
||||
}
|
||||
if (toolCalls.size > 4) {
|
||||
Text(text = "+${toolCalls.size - 4} more", style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatThinkingBubble() {
|
||||
ClawPanel {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ClawStatusPill(text = "Thinking", status = ClawStatus.Warning)
|
||||
Text(text = "OpenClaw is preparing a response.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatNotice(
|
||||
title: String,
|
||||
body: String,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.surface,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(6.dp).background(ClawTheme.colors.warning, CircleShape))
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = body, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatComposer(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
attachments: List<PendingImageAttachment>,
|
||||
thinkingLevel: String,
|
||||
healthOk: Boolean,
|
||||
pendingRunCount: Int,
|
||||
onThinkingLevelChange: (String) -> Unit,
|
||||
onPickImages: () -> Unit,
|
||||
onRemoveAttachment: (String) -> Unit,
|
||||
onVoice: () -> Unit,
|
||||
onAbort: () -> Unit,
|
||||
onSend: () -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().imePadding(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (attachments.isNotEmpty()) {
|
||||
AttachmentStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment)
|
||||
}
|
||||
|
||||
ChatContextMeter(thinkingLevel = thinkingLevel, onClick = { onThinkingLevelChange(nextThinkingValue(thinkingLevel)) })
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
ChatInputPill(value = value, onValueChange = onValueChange, onPickImages = onPickImages, onVoice = onVoice, modifier = Modifier.weight(1f))
|
||||
SendButton(
|
||||
enabled = healthOk && pendingRunCount == 0 && (value.trim().isNotEmpty() || attachments.isNotEmpty()),
|
||||
onClick = onSend,
|
||||
)
|
||||
}
|
||||
|
||||
if (pendingRunCount > 0) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||
Surface(
|
||||
onClick = onAbort,
|
||||
modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.canvas,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(8.dp).background(ClawTheme.colors.danger, RoundedCornerShape(2.dp)))
|
||||
Text(text = "Stop", style = ClawTheme.type.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatContextMeter(
|
||||
thinkingLevel: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.width(178.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(7.dp),
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.canvas,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.textSubtle)
|
||||
Text(text = "Context ${contextPercent(thinkingLevel)}%", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.height(3.dp)
|
||||
.background(ClawTheme.colors.surfacePressed, RoundedCornerShape(999.dp)),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(thinkingMeterWidth(thinkingLevel))
|
||||
.height(3.dp)
|
||||
.background(ClawTheme.colors.primary, RoundedCornerShape(999.dp)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatInputPill(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
onPickImages: () -> Unit,
|
||||
onVoice: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.heightIn(min = ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.control),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(7.dp),
|
||||
) {
|
||||
Surface(onClick = onPickImages, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = ClawTheme.colors.surfaceRaised, contentColor = ClawTheme.colors.text) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.Default.AttachFile, contentDescription = "Attach image", modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
textStyle = ClawTheme.type.body.copy(color = ClawTheme.colors.text),
|
||||
cursorBrush = SolidColor(ClawTheme.colors.primary),
|
||||
minLines = 1,
|
||||
maxLines = 4,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterStart) {
|
||||
if (value.isEmpty()) {
|
||||
Text(text = "Message OpenClaw", style = ClawTheme.type.body, color = ClawTheme.colors.textSubtle)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
onClick = onVoice,
|
||||
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.Default.Mic, contentDescription = "Voice", modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttachmentStrip(
|
||||
attachments: List<PendingImageAttachment>,
|
||||
onRemoveAttachment: (String) -> Unit,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
attachments.forEach { attachment ->
|
||||
AttachmentChip(fileName = attachment.fileName, onRemove = { onRemoveAttachment(attachment.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttachmentChip(
|
||||
fileName: String,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 9.dp, top = 5.dp, end = 5.dp, bottom = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(text = fileName, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Surface(onClick = onRemove, modifier = Modifier.size(22.dp), shape = CircleShape, color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.Default.Close, contentDescription = "Remove attachment", modifier = Modifier.size(13.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentSessionTitle(
|
||||
sessionKey: String,
|
||||
sessions: List<ai.openclaw.app.chat.ChatSessionEntry>,
|
||||
): String {
|
||||
val entry = sessions.firstOrNull { it.key == sessionKey }
|
||||
val name = entry?.displayName?.takeIf { it.isNotBlank() } ?: return "New chat"
|
||||
return friendlySessionName(name)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SendButton(
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
|
||||
shape = CircleShape,
|
||||
color = if (enabled) ClawTheme.colors.primary else ClawTheme.colors.surfacePressed,
|
||||
contentColor = if (enabled) ClawTheme.colors.primaryText else ClawTheme.colors.textSubtle,
|
||||
border = BorderStroke(1.dp, if (enabled) ClawTheme.colors.primary else ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = "Send", modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun userFacingChatError(error: String): String {
|
||||
val lower = error.lowercase(Locale.US)
|
||||
return when {
|
||||
lower.contains("not connected") -> "Gateway is offline. Open Settings to reconnect."
|
||||
lower.contains("unauthorized") || lower.contains("auth") -> "Gateway authentication needs attention."
|
||||
else -> error
|
||||
}
|
||||
}
|
||||
|
||||
private fun thinkingDisplay(value: String): String =
|
||||
when (value.lowercase(Locale.US)) {
|
||||
"low" -> "Low"
|
||||
"medium" -> "Medium"
|
||||
"high" -> "High"
|
||||
else -> "Off"
|
||||
}
|
||||
|
||||
private fun thinkingValue(display: String): String =
|
||||
when (display.lowercase(Locale.US)) {
|
||||
"low" -> "low"
|
||||
"medium" -> "medium"
|
||||
"high" -> "high"
|
||||
else -> "off"
|
||||
}
|
||||
|
||||
private fun nextThinkingValue(value: String): String =
|
||||
when (value.lowercase(Locale.US)) {
|
||||
"off" -> "low"
|
||||
"low" -> "medium"
|
||||
"medium" -> "high"
|
||||
else -> "off"
|
||||
}
|
||||
|
||||
private fun thinkingMeterWidth(value: String): Float =
|
||||
when (value.lowercase(Locale.US)) {
|
||||
"low" -> 0.34f
|
||||
"medium" -> 0.58f
|
||||
"high" -> 0.82f
|
||||
else -> 0.18f
|
||||
}
|
||||
|
||||
private fun contextPercent(value: String): Int = (thinkingMeterWidth(value) * 100).toInt()
|
||||
|
||||
private fun formatChatTimestamp(timestampMs: Long): String = DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()).format(Date(timestampMs))
|
||||
|
||||
private fun String.hasMarkdownSyntax(): Boolean =
|
||||
any { it == '#' || it == '*' || it == '`' || it == '[' || it == '|' } ||
|
||||
contains("\n- ") ||
|
||||
contains("\n1. ")
|
||||
@@ -71,6 +71,16 @@ internal suspend fun dispatchPendingAssistantAutoSend(
|
||||
return dispatch(prompt)
|
||||
}
|
||||
|
||||
internal fun resolveInitialChatLoadSessionKey(
|
||||
sessionKey: String,
|
||||
mainSessionKey: String,
|
||||
): String? {
|
||||
val current = sessionKey.trim()
|
||||
val main = mainSessionKey.trim().ifEmpty { "main" }
|
||||
if (current.isNotEmpty() && current != "main" && current != main) return null
|
||||
return main
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
val messages by viewModel.chatMessages.collectAsState()
|
||||
@@ -87,7 +97,10 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
val pendingAssistantAutoSend by viewModel.pendingAssistantAutoSend.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadChat(mainSessionKey)
|
||||
val loadSessionKey = resolveInitialChatLoadSessionKey(sessionKey, mainSessionKey)
|
||||
if (loadSessionKey != null) {
|
||||
viewModel.loadChat(loadSessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(pendingAssistantAutoSend, healthOk, pendingRunCount, thinkingLevel) {
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
package ai.openclaw.app.ui.design
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChatBubble
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
internal enum class ClawStatus {
|
||||
Neutral,
|
||||
Success,
|
||||
Warning,
|
||||
Danger,
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawScaffold(
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(horizontal = ClawTheme.spacing.lg, vertical = ClawTheme.spacing.lg),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(ClawTheme.colors.canvas)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(contentPadding),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawSectionHeader(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
action: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = ClawTheme.type.section,
|
||||
color = ClawTheme.colors.text,
|
||||
)
|
||||
action?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawPrimaryButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
icon: ImageVector? = null,
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier.heightIn(min = ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = ClawTheme.colors.primary,
|
||||
contentColor = ClawTheme.colors.primaryText,
|
||||
disabledContainerColor = ClawTheme.colors.surfacePressed,
|
||||
disabledContentColor = ClawTheme.colors.textSubtle,
|
||||
),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp, pressedElevation = 0.dp),
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(text = text, style = ClawTheme.type.label, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawSecondaryButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
icon: ImageVector? = null,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier.heightIn(min = ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = if (enabled) ClawTheme.colors.surfaceRaised else ClawTheme.colors.surface,
|
||||
contentColor = if (enabled) ClawTheme.colors.text else ClawTheme.colors.textSubtle,
|
||||
border = BorderStroke(1.dp, if (enabled) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(7.dp))
|
||||
}
|
||||
Text(text = text, style = ClawTheme.type.label, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier.size(ClawTheme.spacing.touchTarget),
|
||||
shape = CircleShape,
|
||||
color = if (enabled) ClawTheme.colors.surfaceRaised else ClawTheme.colors.surface,
|
||||
contentColor = if (enabled) ClawTheme.colors.text else ClawTheme.colors.textSubtle,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawStatusPill(
|
||||
text: String,
|
||||
status: ClawStatus,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = ClawTheme.colors
|
||||
val (dotColor, backgroundColor) =
|
||||
when (status) {
|
||||
ClawStatus.Neutral -> colors.textSubtle to colors.surfaceRaised
|
||||
ClawStatus.Success -> colors.success to colors.successSoft
|
||||
ClawStatus.Warning -> colors.warning to colors.warningSoft
|
||||
ClawStatus.Danger -> colors.danger to colors.dangerSoft
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = backgroundColor,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(7.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(dotColor),
|
||||
)
|
||||
Text(text = text, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawPill(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
selected: Boolean = false,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val surfaceModifier =
|
||||
if (onClick == null) {
|
||||
modifier
|
||||
} else {
|
||||
modifier.clickable(onClick = onClick)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = surfaceModifier,
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = if (selected) ClawTheme.colors.primary else ClawTheme.colors.surfaceRaised,
|
||||
contentColor = if (selected) ClawTheme.colors.primaryText else ClawTheme.colors.textMuted,
|
||||
border = BorderStroke(1.dp, if (selected) ClawTheme.colors.primary else ClawTheme.colors.border),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
style = ClawTheme.type.caption,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun <T> ClawListPanel(
|
||||
items: List<T>,
|
||||
modifier: Modifier = Modifier,
|
||||
row: @Composable (T) -> Unit,
|
||||
) {
|
||||
ClawPanel(modifier = modifier, contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
ClawSeparatedColumn(items = items, row = row)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun <T> ClawSeparatedColumn(
|
||||
items: List<T>,
|
||||
modifier: Modifier = Modifier,
|
||||
row: @Composable (T) -> Unit,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
items.forEachIndexed { index, item ->
|
||||
row(item)
|
||||
if (index != items.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawDetailRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
modifier: Modifier = Modifier,
|
||||
leading: @Composable () -> Unit,
|
||||
trailing: @Composable () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 52.dp)
|
||||
.padding(horizontal = 12.dp, vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
leading()
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(text = subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
trailing()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawTextBadge(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.size(30.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.surfacePressed,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
contentColor = ClawTheme.colors.text,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(text = text, style = ClawTheme.type.label, color = ClawTheme.colors.text, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawIconBadge(
|
||||
icon: ImageVector,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.size(30.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.surfacePressed,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
contentColor = ClawTheme.colors.text,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(14.dp), tint = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawListItem(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
subtitle: String? = null,
|
||||
metadata: String? = null,
|
||||
leading: (@Composable () -> Unit)? = null,
|
||||
trailing: (@Composable () -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val rowModifier =
|
||||
if (onClick == null) {
|
||||
modifier
|
||||
} else {
|
||||
modifier.clickable(onClick = onClick)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
rowModifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = ClawTheme.spacing.touchTarget)
|
||||
.clip(RoundedCornerShape(ClawTheme.radii.row))
|
||||
.padding(horizontal = 2.dp, vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
leading?.invoke()
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = ClawTheme.type.caption,
|
||||
color = ClawTheme.colors.textSubtle,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (metadata != null) {
|
||||
Text(text = metadata, style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 1)
|
||||
}
|
||||
trailing?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawSegmentedControl(
|
||||
options: List<String>,
|
||||
selected: String,
|
||||
onSelect: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.clip(RoundedCornerShape(ClawTheme.radii.pill))
|
||||
.border(1.dp, ClawTheme.colors.border, RoundedCornerShape(ClawTheme.radii.pill))
|
||||
.padding(2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
options.forEach { option ->
|
||||
val active = option == selected
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(ClawTheme.radii.pill))
|
||||
.background(if (active) ClawTheme.colors.primary else Color.Transparent)
|
||||
.clickable { onSelect(option) }
|
||||
.padding(horizontal = 9.dp, vertical = 7.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = option,
|
||||
style = ClawTheme.type.caption,
|
||||
color = if (active) ClawTheme.colors.primaryText else ClawTheme.colors.textMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawTextField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
modifier: Modifier = Modifier,
|
||||
minLines: Int = 1,
|
||||
) {
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(ClawTheme.radii.control))
|
||||
.background(ClawTheme.colors.surfaceRaised)
|
||||
.border(1.dp, ClawTheme.colors.border, RoundedCornerShape(ClawTheme.radii.control))
|
||||
.padding(horizontal = 11.dp, vertical = 8.dp),
|
||||
textStyle = ClawTheme.type.body.copy(color = ClawTheme.colors.text),
|
||||
cursorBrush = SolidColor(ClawTheme.colors.primary),
|
||||
minLines = minLines,
|
||||
decorationBox = { innerTextField ->
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
if (value.isEmpty()) {
|
||||
Text(text = placeholder, style = ClawTheme.type.body, color = ClawTheme.colors.textSubtle)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawComponentShowcase(modifier: Modifier = Modifier) {
|
||||
var selected by rememberSaveable { mutableStateOf("Chat") }
|
||||
var prompt by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
ClawScaffold(modifier = modifier) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
ClawTopBar(
|
||||
title = "OpenClaw",
|
||||
subtitle = "Local command center",
|
||||
navigation = { ClawAvatarMark(text = "OC") },
|
||||
actions = {
|
||||
ClawIconButton(icon = Icons.Default.Search, contentDescription = "Search", onClick = {})
|
||||
},
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(text = "OpenClaw", style = ClawTheme.type.display, color = ClawTheme.colors.text)
|
||||
Text(text = "Design system prototype", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
ClawStatusPill(text = "Connected", status = ClawStatus.Success)
|
||||
}
|
||||
|
||||
ClawSegmentedControl(
|
||||
options = listOf("Chat", "Voice", "Sessions"),
|
||||
selected = selected,
|
||||
onSelect = { selected = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
ClawSectionHeader(title = "Sessions")
|
||||
ClawListItem(
|
||||
title = "Testing testing 1 2 3",
|
||||
subtitle = "14 messages · Android",
|
||||
metadata = "now",
|
||||
)
|
||||
ClawListItem(
|
||||
title = "Provider setup",
|
||||
subtitle = "OpenClaw gateway",
|
||||
metadata = "8m",
|
||||
)
|
||||
}
|
||||
|
||||
ClawTextField(value = prompt, onValueChange = { prompt = it }, placeholder = "Ask OpenClaw anything", minLines = 3)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ClawPrimaryButton(text = "Start Chat", onClick = {}, modifier = Modifier.weight(1f))
|
||||
ClawSecondaryButton(text = "Voice", onClick = {}, modifier = Modifier.weight(1f))
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawPill(text = "Realtime", selected = true)
|
||||
ClawPill(text = "Dictation")
|
||||
ClawPill(text = "Screen")
|
||||
}
|
||||
|
||||
ClawEmptyState(
|
||||
title = "Nothing needs your attention",
|
||||
body = "OpenClaw will surface approvals, failed jobs, and channel issues here.",
|
||||
)
|
||||
|
||||
ClawBottomNav(
|
||||
items =
|
||||
listOf(
|
||||
ClawNavItem(key = "overview", label = "Home", icon = Icons.Default.Home),
|
||||
ClawNavItem(key = "chat", label = "Chat", icon = Icons.Default.ChatBubble),
|
||||
ClawNavItem(key = "voice", label = "Voice", icon = Icons.Default.Mic),
|
||||
ClawNavItem(key = "settings", label = "Settings", icon = Icons.Default.Settings),
|
||||
),
|
||||
selectedKey = "chat",
|
||||
onSelect = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package ai.openclaw.app.ui.design
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Immutable
|
||||
internal data class ClawNavItem(
|
||||
val key: String,
|
||||
val label: String,
|
||||
val icon: ImageVector,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun ClawTopBar(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
subtitle: String? = null,
|
||||
navigation: (@Composable () -> Unit)? = null,
|
||||
actions: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = ClawTheme.spacing.lg, vertical = ClawTheme.spacing.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
navigation?.invoke()
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = ClawTheme.type.section,
|
||||
color = ClawTheme.colors.text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = ClawTheme.type.caption,
|
||||
color = ClawTheme.colors.textSubtle,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
actions?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawBottomNav(
|
||||
items: List<ClawNavItem>,
|
||||
selectedKey: String,
|
||||
onSelect: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val safeInsets = WindowInsets.navigationBars.only(androidx.compose.foundation.layout.WindowInsetsSides.Bottom)
|
||||
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
color = ClawTheme.colors.surface.copy(alpha = 0.96f),
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.windowInsetsPadding(safeInsets)
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items.forEach { item ->
|
||||
ClawBottomNavItem(
|
||||
item = item,
|
||||
selected = item.key == selectedKey,
|
||||
onClick = { onSelect(item.key) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClawBottomNavItem(
|
||||
item: ClawNavItem,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier.heightIn(min = 48.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.control),
|
||||
color = if (selected) ClawTheme.colors.primary else Color.Transparent,
|
||||
contentColor = if (selected) ClawTheme.colors.primaryText else ClawTheme.colors.textSubtle,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 5.dp, vertical = 6.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Icon(imageVector = item.icon, contentDescription = item.label, modifier = Modifier.size(18.dp))
|
||||
Text(text = item.label, style = ClawTheme.type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawAvatarMark(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.size(38.dp),
|
||||
shape = CircleShape,
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(text = text.take(2).uppercase(), style = ClawTheme.type.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package ai.openclaw.app.ui.design
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
@Preview(
|
||||
name = "OpenClaw Design System",
|
||||
showBackground = true,
|
||||
backgroundColor = 0xFF030303,
|
||||
)
|
||||
@Composable
|
||||
private fun ClawComponentShowcasePreview() {
|
||||
ClawDesignTheme {
|
||||
ClawComponentShowcase()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package ai.openclaw.app.ui.design
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun ClawPanel(
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(9.dp),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(contentPadding)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawSheetSurface(
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(18.dp),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
|
||||
color = ClawTheme.colors.surface,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(contentPadding)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawEmptyState(
|
||||
title: String,
|
||||
body: String,
|
||||
modifier: Modifier = Modifier,
|
||||
action: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
ClawPanel(modifier = modifier) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = body, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
action?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawLoadingState(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ClawPanel(modifier = modifier) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
CircularProgressIndicator(color = ClawTheme.colors.primary, strokeWidth = 2.dp)
|
||||
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawErrorState(
|
||||
title: String,
|
||||
body: String,
|
||||
modifier: Modifier = Modifier,
|
||||
action: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
ClawPanel(modifier = modifier) {
|
||||
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawStatusPill(text = "Needs attention", status = ClawStatus.Danger)
|
||||
Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = body, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
action?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package ai.openclaw.app.ui.design
|
||||
|
||||
import ai.openclaw.app.ui.mobileFontFamily
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Immutable
|
||||
internal data class ClawColors(
|
||||
val canvas: Color,
|
||||
val surface: Color,
|
||||
val surfaceRaised: Color,
|
||||
val surfacePressed: Color,
|
||||
val border: Color,
|
||||
val borderStrong: Color,
|
||||
val text: Color,
|
||||
val textMuted: Color,
|
||||
val textSubtle: Color,
|
||||
val primary: Color,
|
||||
val primaryText: Color,
|
||||
val success: Color,
|
||||
val successSoft: Color,
|
||||
val warning: Color,
|
||||
val warningSoft: Color,
|
||||
val danger: Color,
|
||||
val dangerSoft: Color,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
internal data class ClawSpacing(
|
||||
val xxxs: Dp = 4.dp,
|
||||
val xxs: Dp = 8.dp,
|
||||
val xs: Dp = 12.dp,
|
||||
val sm: Dp = 16.dp,
|
||||
val md: Dp = 20.dp,
|
||||
val lg: Dp = 24.dp,
|
||||
val xl: Dp = 32.dp,
|
||||
val xxl: Dp = 40.dp,
|
||||
val touchTarget: Dp = 48.dp,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
internal data class ClawRadii(
|
||||
val row: Dp = 4.dp,
|
||||
val panel: Dp = 7.dp,
|
||||
val control: Dp = 8.dp,
|
||||
val sheet: Dp = 12.dp,
|
||||
val pill: Dp = 999.dp,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
internal data class ClawTypography(
|
||||
val display: TextStyle,
|
||||
val title: TextStyle,
|
||||
val section: TextStyle,
|
||||
val body: TextStyle,
|
||||
val label: TextStyle,
|
||||
val caption: TextStyle,
|
||||
val mono: TextStyle,
|
||||
)
|
||||
|
||||
private val ClawDarkColors =
|
||||
ClawColors(
|
||||
canvas = Color(0xFF030303),
|
||||
surface = Color(0xFF0A0A0A),
|
||||
surfaceRaised = Color(0xFF111111),
|
||||
surfacePressed = Color(0xFF1A1A1A),
|
||||
border = Color(0xFF242424),
|
||||
borderStrong = Color(0xFF3A3A3A),
|
||||
text = Color(0xFFF8F8F8),
|
||||
textMuted = Color(0xFFA8A8A8),
|
||||
textSubtle = Color(0xFF707070),
|
||||
primary = Color(0xFFFFFFFF),
|
||||
primaryText = Color(0xFF050505),
|
||||
success = Color(0xFF3EDB82),
|
||||
successSoft = Color(0xFF102719),
|
||||
warning = Color(0xFFE6B956),
|
||||
warningSoft = Color(0xFF2B2412),
|
||||
danger = Color(0xFFFF6B6B),
|
||||
dangerSoft = Color(0xFF2C1414),
|
||||
)
|
||||
|
||||
private val ClawLightColors =
|
||||
ClawColors(
|
||||
canvas = Color(0xFFF7F7F7),
|
||||
surface = Color(0xFFFFFFFF),
|
||||
surfaceRaised = Color(0xFFFFFFFF),
|
||||
surfacePressed = Color(0xFFEDEDED),
|
||||
border = Color(0xFFE0E0E0),
|
||||
borderStrong = Color(0xFFBDBDBD),
|
||||
text = Color(0xFF070707),
|
||||
textMuted = Color(0xFF595959),
|
||||
textSubtle = Color(0xFF8A8A8A),
|
||||
primary = Color(0xFF050505),
|
||||
primaryText = Color(0xFFFFFFFF),
|
||||
success = Color(0xFF157A3E),
|
||||
successSoft = Color(0xFFEAF8EF),
|
||||
warning = Color(0xFF9A6A12),
|
||||
warningSoft = Color(0xFFFFF5DD),
|
||||
danger = Color(0xFFB42323),
|
||||
dangerSoft = Color(0xFFFFE9E9),
|
||||
)
|
||||
|
||||
private val LocalClawColors = staticCompositionLocalOf { ClawDarkColors }
|
||||
private val LocalClawSpacing = staticCompositionLocalOf { ClawSpacing() }
|
||||
private val LocalClawRadii = staticCompositionLocalOf { ClawRadii() }
|
||||
private val LocalClawTypography = staticCompositionLocalOf { clawTypography(mobileFontFamily) }
|
||||
|
||||
internal object ClawTheme {
|
||||
val colors: ClawColors
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = LocalClawColors.current
|
||||
|
||||
val spacing: ClawSpacing
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = LocalClawSpacing.current
|
||||
|
||||
val radii: ClawRadii
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = LocalClawRadii.current
|
||||
|
||||
val type: ClawTypography
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = LocalClawTypography.current
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ClawDesignTheme(
|
||||
dark: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colors = if (dark) ClawDarkColors else ClawLightColors
|
||||
val typography = clawTypography(mobileFontFamily)
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalClawColors provides colors,
|
||||
LocalClawSpacing provides ClawSpacing(),
|
||||
LocalClawRadii provides ClawRadii(),
|
||||
LocalClawTypography provides typography,
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = clawMaterialColorScheme(colors, dark),
|
||||
typography = materialTypography(typography),
|
||||
shapes = Shapes(),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun rememberClawDarkPreference(): Boolean = isSystemInDarkTheme()
|
||||
|
||||
private fun clawTypography(fontFamily: FontFamily) =
|
||||
ClawTypography(
|
||||
display =
|
||||
TextStyle(
|
||||
fontFamily = fontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 26.sp,
|
||||
lineHeight = 32.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
title =
|
||||
TextStyle(
|
||||
fontFamily = fontFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 25.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
section =
|
||||
TextStyle(
|
||||
fontFamily = fontFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
body =
|
||||
TextStyle(
|
||||
fontFamily = fontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 19.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
label =
|
||||
TextStyle(
|
||||
fontFamily = fontFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
caption =
|
||||
TextStyle(
|
||||
fontFamily = fontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.5.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
mono =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 18.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
)
|
||||
|
||||
private fun materialTypography(type: ClawTypography) =
|
||||
Typography(
|
||||
displayMedium = type.display,
|
||||
titleLarge = type.title,
|
||||
titleMedium = type.section,
|
||||
bodyLarge = type.body,
|
||||
labelLarge = type.label,
|
||||
labelSmall = type.caption,
|
||||
)
|
||||
|
||||
private fun clawMaterialColorScheme(
|
||||
colors: ClawColors,
|
||||
dark: Boolean,
|
||||
) = if (dark) {
|
||||
darkColorScheme(
|
||||
primary = colors.primary,
|
||||
onPrimary = colors.primaryText,
|
||||
background = colors.canvas,
|
||||
onBackground = colors.text,
|
||||
surface = colors.surface,
|
||||
onSurface = colors.text,
|
||||
surfaceVariant = colors.surfaceRaised,
|
||||
onSurfaceVariant = colors.textMuted,
|
||||
outline = colors.border,
|
||||
error = colors.danger,
|
||||
onError = colors.primaryText,
|
||||
)
|
||||
} else {
|
||||
lightColorScheme(
|
||||
primary = colors.primary,
|
||||
onPrimary = colors.primaryText,
|
||||
background = colors.canvas,
|
||||
onBackground = colors.text,
|
||||
surface = colors.surface,
|
||||
onSurface = colors.text,
|
||||
surfaceVariant = colors.surfaceRaised,
|
||||
onSurfaceVariant = colors.textMuted,
|
||||
outline = colors.border,
|
||||
error = colors.danger,
|
||||
onError = colors.primaryText,
|
||||
)
|
||||
}
|
||||
@@ -186,6 +186,15 @@ class MicCaptureManager(
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelMicCapture() {
|
||||
transcriptionDrainJob?.cancel()
|
||||
transcriptionDrainJob = null
|
||||
_micEnabled.value = false
|
||||
_micCooldown.value = false
|
||||
_liveTranscript.value = null
|
||||
stop()
|
||||
}
|
||||
|
||||
suspend fun pauseForTts() {
|
||||
val shouldPause =
|
||||
synchronized(ttsPauseLock) {
|
||||
|
||||
12
apps/android/app/src/play/AndroidManifest.xml
Normal file
12
apps/android/app/src/play/AndroidManifest.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_MEDIA_IMAGES"
|
||||
tools:node="remove" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"
|
||||
tools:node="remove" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
tools:node="remove" />
|
||||
</manifest>
|
||||
@@ -3,4 +3,5 @@ package ai.openclaw.app
|
||||
object SensitiveFeatureConfig {
|
||||
const val smsEnabled: Boolean = false
|
||||
const val callLogEnabled: Boolean = false
|
||||
const val photosEnabled: Boolean = false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class CronJobStatusParsingTest {
|
||||
@Test
|
||||
fun cronJobLastRunStatusReadsGatewayLastStatus() {
|
||||
val state =
|
||||
buildJsonObject {
|
||||
put("lastStatus", JsonPrimitive(" error "))
|
||||
put("lastRunStatus", JsonPrimitive("success"))
|
||||
}
|
||||
|
||||
assertEquals("error", cronJobLastRunStatus(state))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cronJobLastRunStatusReadsLastRunStatus() {
|
||||
val state =
|
||||
buildJsonObject {
|
||||
put("lastRunStatus", JsonPrimitive("error"))
|
||||
}
|
||||
|
||||
assertEquals("error", cronJobLastRunStatus(state))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cronJobLastRunStatusIgnoresEmptyStatus() {
|
||||
val state =
|
||||
buildJsonObject {
|
||||
put("lastStatus", JsonPrimitive(" "))
|
||||
}
|
||||
|
||||
assertNull(cronJobLastRunStatus(state))
|
||||
}
|
||||
}
|
||||
@@ -78,4 +78,91 @@ class ChatControllerMessageIdentityTest {
|
||||
assertEquals("new-2", reconciled[1].id)
|
||||
assertNotEquals(reconciled[0].id, reconciled[1].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeOptimisticMessagesKeepsOutgoingUserTurnWhenHistoryOmitsIt() {
|
||||
val optimistic =
|
||||
ChatMessage(
|
||||
id = "local-user",
|
||||
role = "user",
|
||||
content = listOf(ChatMessageContent(type = "text", text = "Testing testing 1 2 3")),
|
||||
timestampMs = 1000L,
|
||||
)
|
||||
val assistant =
|
||||
ChatMessage(
|
||||
id = "remote-assistant",
|
||||
role = "assistant",
|
||||
content = listOf(ChatMessageContent(type = "text", text = "Received.")),
|
||||
timestampMs = 2000L,
|
||||
)
|
||||
|
||||
val merged = mergeOptimisticMessages(incoming = listOf(assistant), optimistic = listOf(optimistic))
|
||||
|
||||
assertEquals(listOf("local-user", "remote-assistant"), merged.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeOptimisticMessagesDoesNotDuplicateHistoryTurns() {
|
||||
val user =
|
||||
ChatMessage(
|
||||
id = "local-user",
|
||||
role = "user",
|
||||
content = listOf(ChatMessageContent(type = "text", text = "hello")),
|
||||
timestampMs = 1000L,
|
||||
)
|
||||
val remoteUser = user.copy(id = "remote-user")
|
||||
|
||||
val merged = mergeOptimisticMessages(incoming = listOf(remoteUser), optimistic = listOf(user))
|
||||
|
||||
assertEquals(listOf("remote-user"), merged.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeOptimisticMessagesDoesNotDuplicateGatewayPersistedUserTurnWithDifferentTimestamp() {
|
||||
val optimistic =
|
||||
ChatMessage(
|
||||
id = "local-user",
|
||||
role = "user",
|
||||
content = listOf(ChatMessageContent(type = "text", text = "hello")),
|
||||
timestampMs = 1000L,
|
||||
)
|
||||
val remoteUser = optimistic.copy(id = "remote-user", timestampMs = 2000L)
|
||||
|
||||
val merged = mergeOptimisticMessages(incoming = listOf(remoteUser), optimistic = listOf(optimistic))
|
||||
|
||||
assertEquals(listOf("remote-user"), merged.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeOptimisticMessagesKeepsRepeatedOptimisticTurnWhenHistoryOnlyHasOneMatch() {
|
||||
val first =
|
||||
ChatMessage(
|
||||
id = "local-user-1",
|
||||
role = "user",
|
||||
content = listOf(ChatMessageContent(type = "text", text = "hello")),
|
||||
timestampMs = 1000L,
|
||||
)
|
||||
val second = first.copy(id = "local-user-2", timestampMs = 1100L)
|
||||
val remoteUser = first.copy(id = "remote-user", timestampMs = 2000L)
|
||||
|
||||
val merged = mergeOptimisticMessages(incoming = listOf(remoteUser), optimistic = listOf(first, second))
|
||||
|
||||
assertEquals(listOf("local-user-2", "remote-user"), merged.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeOptimisticMessagesDoesNotConsumeOlderIdenticalHistoryTurn() {
|
||||
val optimistic =
|
||||
ChatMessage(
|
||||
id = "local-user",
|
||||
role = "user",
|
||||
content = listOf(ChatMessageContent(type = "text", text = "ok")),
|
||||
timestampMs = 2000L,
|
||||
)
|
||||
val oldHistoryUser = optimistic.copy(id = "remote-old-user", timestampMs = 1000L)
|
||||
|
||||
val merged = mergeOptimisticMessages(incoming = listOf(oldHistoryUser), optimistic = listOf(optimistic))
|
||||
|
||||
assertEquals(listOf("remote-old-user", "local-user"), merged.map { it.id })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@ class GatewaySessionInvokeTest {
|
||||
connectResponseFrame(
|
||||
id,
|
||||
authJson =
|
||||
"""{"deviceToken":"bootstrap-node-token","role":"node","scopes":[],"deviceTokens":[{"deviceToken":"bootstrap-operator-token","role":"operator","scopes":["operator.admin","operator.approvals","operator.read","operator.talk.secrets","operator.write"]}]}""",
|
||||
"""{"deviceToken":"bootstrap-node-token","role":"node","scopes":[],"deviceTokens":[{"deviceToken":"bootstrap-operator-token","role":"operator","scopes":["operator.admin","operator.approvals","operator.pairing","operator.read","operator.talk.secrets","operator.write"]}]}""",
|
||||
),
|
||||
)
|
||||
webSocket.close(1000, "done")
|
||||
@@ -365,7 +365,7 @@ class GatewaySessionInvokeTest {
|
||||
assertEquals(emptyList<String>(), nodeEntry?.scopes)
|
||||
assertEquals("bootstrap-operator-token", operatorEntry?.token)
|
||||
assertEquals(
|
||||
listOf("operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"),
|
||||
listOf("operator.approvals", "operator.pairing", "operator.read", "operator.write"),
|
||||
operatorEntry?.scopes,
|
||||
)
|
||||
} finally {
|
||||
@@ -642,7 +642,7 @@ class GatewaySessionInvokeTest {
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
onConnected = {
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
|
||||
@@ -241,7 +241,7 @@ class GatewaySessionReconnectTest {
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = ReconnectDeviceAuthStore(),
|
||||
onConnected = { _, _, _ -> },
|
||||
onConnected = {},
|
||||
onDisconnected = { _ -> },
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { GatewaySession.InvokeResult.ok("""{"handled":true}""") },
|
||||
|
||||
@@ -11,6 +11,7 @@ import ai.openclaw.app.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCapability
|
||||
import ai.openclaw.app.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.app.protocol.OpenClawMotionCommand
|
||||
import ai.openclaw.app.protocol.OpenClawPhotosCommand
|
||||
import ai.openclaw.app.protocol.OpenClawSmsCommand
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -367,6 +368,21 @@ class ConnectionManagerTest {
|
||||
assertEquals(false, params?.allowTOFU)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildOperatorConnectOptions_requestsQrBootstrapHandoffScopes() {
|
||||
val options = newManager().buildOperatorConnectOptions()
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"operator.approvals",
|
||||
"operator.pairing",
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
),
|
||||
options.scopes,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildNodeConnectOptions_advertisesRequestableSmsSearchWithoutSmsCapability() {
|
||||
val options =
|
||||
@@ -431,6 +447,7 @@ class ConnectionManagerTest {
|
||||
voiceWakeMode = VoiceWakeMode.Always,
|
||||
motionActivityAvailable = true,
|
||||
callLogAvailable = true,
|
||||
photosAvailable = true,
|
||||
hasRecordAudioPermission = true,
|
||||
).buildNodeConnectOptions()
|
||||
|
||||
@@ -438,10 +455,12 @@ class ConnectionManagerTest {
|
||||
assertTrue(options.commands.contains(OpenClawLocationCommand.Get.rawValue))
|
||||
assertTrue(options.commands.contains(OpenClawMotionCommand.Activity.rawValue))
|
||||
assertTrue(options.commands.contains(OpenClawCallLogCommand.Search.rawValue))
|
||||
assertTrue(options.commands.contains(OpenClawPhotosCommand.Latest.rawValue))
|
||||
assertTrue(options.caps.contains(OpenClawCapability.Camera.rawValue))
|
||||
assertTrue(options.caps.contains(OpenClawCapability.Location.rawValue))
|
||||
assertTrue(options.caps.contains(OpenClawCapability.Motion.rawValue))
|
||||
assertTrue(options.caps.contains(OpenClawCapability.CallLog.rawValue))
|
||||
assertTrue(options.caps.contains(OpenClawCapability.Photos.rawValue))
|
||||
assertTrue(options.caps.contains(OpenClawCapability.VoiceWake.rawValue))
|
||||
}
|
||||
|
||||
@@ -457,12 +476,13 @@ class ConnectionManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildNodeConnectOptions_omitsUnavailableCameraLocationAndCallLogSurfaces() {
|
||||
fun buildNodeConnectOptions_omitsUnavailableCameraLocationCallLogAndPhotosSurfaces() {
|
||||
val options =
|
||||
newManager(
|
||||
cameraEnabled = false,
|
||||
locationMode = LocationMode.Off,
|
||||
callLogAvailable = false,
|
||||
photosAvailable = false,
|
||||
).buildNodeConnectOptions()
|
||||
|
||||
assertFalse(options.commands.contains(OpenClawCameraCommand.List.rawValue))
|
||||
@@ -470,9 +490,11 @@ class ConnectionManagerTest {
|
||||
assertFalse(options.commands.contains(OpenClawCameraCommand.Clip.rawValue))
|
||||
assertFalse(options.commands.contains(OpenClawLocationCommand.Get.rawValue))
|
||||
assertFalse(options.commands.contains(OpenClawCallLogCommand.Search.rawValue))
|
||||
assertFalse(options.commands.contains(OpenClawPhotosCommand.Latest.rawValue))
|
||||
assertFalse(options.caps.contains(OpenClawCapability.Camera.rawValue))
|
||||
assertFalse(options.caps.contains(OpenClawCapability.Location.rawValue))
|
||||
assertFalse(options.caps.contains(OpenClawCapability.CallLog.rawValue))
|
||||
assertFalse(options.caps.contains(OpenClawCapability.Photos.rawValue))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -511,6 +533,7 @@ class ConnectionManagerTest {
|
||||
readSmsAvailable: Boolean = false,
|
||||
smsSearchPossible: Boolean = false,
|
||||
callLogAvailable: Boolean = false,
|
||||
photosAvailable: Boolean = false,
|
||||
hasRecordAudioPermission: Boolean = false,
|
||||
): ConnectionManager {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
@@ -531,6 +554,7 @@ class ConnectionManagerTest {
|
||||
readSmsAvailable = { readSmsAvailable },
|
||||
smsSearchPossible = { smsSearchPossible },
|
||||
callLogAvailable = { callLogAvailable },
|
||||
photosAvailable = { photosAvailable },
|
||||
hasRecordAudioPermission = { hasRecordAudioPermission },
|
||||
manualTls = { false },
|
||||
)
|
||||
|
||||
@@ -28,7 +28,6 @@ class InvokeCommandRegistryTest {
|
||||
OpenClawCapability.Notifications.rawValue,
|
||||
OpenClawCapability.System.rawValue,
|
||||
OpenClawCapability.Talk.rawValue,
|
||||
OpenClawCapability.Photos.rawValue,
|
||||
OpenClawCapability.Contacts.rawValue,
|
||||
OpenClawCapability.Calendar.rawValue,
|
||||
)
|
||||
@@ -41,6 +40,7 @@ class InvokeCommandRegistryTest {
|
||||
OpenClawCapability.CallLog.rawValue,
|
||||
OpenClawCapability.VoiceWake.rawValue,
|
||||
OpenClawCapability.Motion.rawValue,
|
||||
OpenClawCapability.Photos.rawValue,
|
||||
)
|
||||
|
||||
private val coreCommands =
|
||||
@@ -56,7 +56,6 @@ class InvokeCommandRegistryTest {
|
||||
OpenClawTalkCommand.PttStop.rawValue,
|
||||
OpenClawTalkCommand.PttCancel.rawValue,
|
||||
OpenClawTalkCommand.PttOnce.rawValue,
|
||||
OpenClawPhotosCommand.Latest.rawValue,
|
||||
OpenClawContactsCommand.Search.rawValue,
|
||||
OpenClawContactsCommand.Add.rawValue,
|
||||
OpenClawCalendarCommand.Events.rawValue,
|
||||
@@ -74,6 +73,7 @@ class InvokeCommandRegistryTest {
|
||||
OpenClawSmsCommand.Send.rawValue,
|
||||
OpenClawSmsCommand.Search.rawValue,
|
||||
OpenClawCallLogCommand.Search.rawValue,
|
||||
OpenClawPhotosCommand.Latest.rawValue,
|
||||
)
|
||||
|
||||
private val debugCommands = setOf("debug.logs", "debug.ed25519")
|
||||
@@ -97,6 +97,7 @@ class InvokeCommandRegistryTest {
|
||||
readSmsAvailable = true,
|
||||
smsSearchPossible = true,
|
||||
callLogAvailable = true,
|
||||
photosAvailable = true,
|
||||
voiceWakeEnabled = true,
|
||||
motionActivityAvailable = true,
|
||||
motionPedometerAvailable = true,
|
||||
@@ -125,6 +126,7 @@ class InvokeCommandRegistryTest {
|
||||
readSmsAvailable = true,
|
||||
smsSearchPossible = true,
|
||||
callLogAvailable = true,
|
||||
photosAvailable = true,
|
||||
motionActivityAvailable = true,
|
||||
motionPedometerAvailable = true,
|
||||
debugBuild = true,
|
||||
@@ -145,6 +147,7 @@ class InvokeCommandRegistryTest {
|
||||
readSmsAvailable = false,
|
||||
smsSearchPossible = false,
|
||||
callLogAvailable = false,
|
||||
photosAvailable = false,
|
||||
voiceWakeEnabled = false,
|
||||
motionActivityAvailable = true,
|
||||
motionPedometerAvailable = false,
|
||||
@@ -212,6 +215,17 @@ class InvokeCommandRegistryTest {
|
||||
assertFalse(capabilities.contains(OpenClawCapability.CallLog.rawValue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedPhotosSurface_respectsFeatureAvailability() {
|
||||
val disabledFlags = defaultFlags(photosAvailable = false)
|
||||
val enabledFlags = defaultFlags(photosAvailable = true)
|
||||
|
||||
assertFalse(InvokeCommandRegistry.advertisedCapabilities(disabledFlags).contains(OpenClawCapability.Photos.rawValue))
|
||||
assertFalse(InvokeCommandRegistry.advertisedCommands(disabledFlags).contains(OpenClawPhotosCommand.Latest.rawValue))
|
||||
assertTrue(InvokeCommandRegistry.advertisedCapabilities(enabledFlags).contains(OpenClawCapability.Photos.rawValue))
|
||||
assertTrue(InvokeCommandRegistry.advertisedCommands(enabledFlags).contains(OpenClawPhotosCommand.Latest.rawValue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCapabilities_includesVoiceWakeWithoutAdvertisingCommands() {
|
||||
val capabilities = InvokeCommandRegistry.advertisedCapabilities(defaultFlags(voiceWakeEnabled = true))
|
||||
@@ -244,6 +258,7 @@ class InvokeCommandRegistryTest {
|
||||
readSmsAvailable: Boolean = false,
|
||||
smsSearchPossible: Boolean = false,
|
||||
callLogAvailable: Boolean = false,
|
||||
photosAvailable: Boolean = false,
|
||||
voiceWakeEnabled: Boolean = false,
|
||||
motionActivityAvailable: Boolean = false,
|
||||
motionPedometerAvailable: Boolean = false,
|
||||
@@ -256,6 +271,7 @@ class InvokeCommandRegistryTest {
|
||||
readSmsAvailable = readSmsAvailable,
|
||||
smsSearchPossible = smsSearchPossible,
|
||||
callLogAvailable = callLogAvailable,
|
||||
photosAvailable = photosAvailable,
|
||||
voiceWakeEnabled = voiceWakeEnabled,
|
||||
motionActivityAvailable = motionActivityAvailable,
|
||||
motionPedometerAvailable = motionPedometerAvailable,
|
||||
|
||||
@@ -6,6 +6,7 @@ import ai.openclaw.app.protocol.OpenClawCallLogCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.app.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.app.protocol.OpenClawMotionCommand
|
||||
import ai.openclaw.app.protocol.OpenClawPhotosCommand
|
||||
import ai.openclaw.app.protocol.OpenClawSmsCommand
|
||||
import ai.openclaw.app.protocol.OpenClawTalkCommand
|
||||
import android.content.Context
|
||||
@@ -201,6 +202,15 @@ class InvokeDispatcherTest {
|
||||
assertEquals("CALL_LOG_UNAVAILABLE: call log not available on this build", result.error?.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleInvoke_blocksPhotosWhenUnavailable() =
|
||||
runTest {
|
||||
val result = newDispatcher(photosAvailable = false).handleInvoke(OpenClawPhotosCommand.Latest.rawValue, null)
|
||||
|
||||
assertEquals("PHOTOS_UNAVAILABLE", result.error?.code)
|
||||
assertEquals("PHOTOS_UNAVAILABLE: photos not available on this build", result.error?.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleInvoke_treatsDebugCommandsAsUnknownOutsideDebugBuilds() =
|
||||
runTest {
|
||||
@@ -239,6 +249,7 @@ class InvokeDispatcherTest {
|
||||
smsFeatureEnabled: Boolean = true,
|
||||
smsTelephonyAvailable: Boolean = true,
|
||||
callLogAvailable: Boolean = false,
|
||||
photosAvailable: Boolean = true,
|
||||
debugBuild: Boolean = false,
|
||||
motionActivityAvailable: Boolean = false,
|
||||
motionPedometerAvailable: Boolean = false,
|
||||
@@ -285,6 +296,7 @@ class InvokeDispatcherTest {
|
||||
smsFeatureEnabled = { smsFeatureEnabled },
|
||||
smsTelephonyAvailable = { smsTelephonyAvailable },
|
||||
callLogAvailable = { callLogAvailable },
|
||||
photosAvailable = { photosAvailable },
|
||||
debugBuild = { debugBuild },
|
||||
onCanvasA2uiPush = {},
|
||||
onCanvasA2uiReset = {},
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ProviderModelStatusTest {
|
||||
@Test
|
||||
fun staticProviderStatusIsReady() {
|
||||
assertTrue(modelProviderReady("static"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingProviderStatusIsNotReady() {
|
||||
assertFalse(modelProviderReady("missing"))
|
||||
}
|
||||
}
|
||||
@@ -71,4 +71,25 @@ class ChatSheetContentTest {
|
||||
assertTrue(consumed)
|
||||
assertEquals("summarize mail", dispatchedPrompt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialChatLoadUsesMainWhenNoSessionIsSelected() {
|
||||
assertEquals(
|
||||
"agent:ops:device",
|
||||
resolveInitialChatLoadSessionKey(
|
||||
sessionKey = "main",
|
||||
mainSessionKey = "agent:ops:device",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialChatLoadPreservesSelectedSession() {
|
||||
assertNull(
|
||||
resolveInitialChatLoadSessionKey(
|
||||
sessionKey = "session:history",
|
||||
mainSessionKey = "agent:ops:device",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@ class TalkModeManagerTest {
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = InMemoryDeviceAuthStore(),
|
||||
onConnected = { _, _, _ -> },
|
||||
onConnected = {},
|
||||
onDisconnected = {},
|
||||
onEvent = { _, _ -> },
|
||||
)
|
||||
|
||||
@@ -3,4 +3,5 @@ package ai.openclaw.app
|
||||
object SensitiveFeatureConfig {
|
||||
const val smsEnabled: Boolean = true
|
||||
const val callLogEnabled: Boolean = true
|
||||
const val photosEnabled: Boolean = true
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# OpenClaw iOS Changelog
|
||||
|
||||
## 2026.5.21 - 2026-05-21
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.5.20 - 2026-05-20
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.5.19 - 2026-05-19
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// Source of truth: apps/ios/version.json
|
||||
// Generated by scripts/ios-sync-versioning.ts.
|
||||
|
||||
OPENCLAW_IOS_VERSION = 2026.5.19
|
||||
OPENCLAW_MARKETING_VERSION = 2026.5.19
|
||||
OPENCLAW_IOS_VERSION = 2026.5.21
|
||||
OPENCLAW_MARKETING_VERSION = 2026.5.21
|
||||
OPENCLAW_BUILD_VERSION = 1
|
||||
|
||||
#include? "../build/Version.xcconfig"
|
||||
|
||||
31
apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift
Normal file
31
apps/ios/Sources/Onboarding/GatewayOnboardingReset.swift
Normal file
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
enum GatewayOnboardingReset {
|
||||
@MainActor
|
||||
static func reset(
|
||||
appModel: NodeAppModel,
|
||||
instanceId: String,
|
||||
defaults: UserDefaults = .standard)
|
||||
{
|
||||
appModel.disconnectGateway()
|
||||
|
||||
let trimmedInstanceId = instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedInstanceId.isEmpty {
|
||||
GatewaySettingsStore.deleteGatewayCredentials(instanceId: trimmedInstanceId)
|
||||
}
|
||||
|
||||
GatewaySettingsStore.clearLastGatewayConnection()
|
||||
GatewaySettingsStore.clearPreferredGatewayStableID()
|
||||
GatewaySettingsStore.clearLastDiscoveredGatewayStableID()
|
||||
GatewayTLSStore.clearAllFingerprints()
|
||||
OnboardingStateStore.reset(defaults: defaults)
|
||||
|
||||
defaults.set(false, forKey: "gateway.onboardingComplete")
|
||||
defaults.set(false, forKey: "gateway.hasConnectedOnce")
|
||||
defaults.set(false, forKey: "gateway.manual.enabled")
|
||||
defaults.set("", forKey: "gateway.manual.host")
|
||||
defaults.set("", forKey: "gateway.setupCode")
|
||||
defaults.set(defaults.integer(forKey: "onboarding.requestID") + 1, forKey: "onboarding.requestID")
|
||||
}
|
||||
}
|
||||
@@ -1016,10 +1016,24 @@ struct OnboardingWizardView: View {
|
||||
}
|
||||
|
||||
private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String {
|
||||
problem.canTrustRotatedCertificate ? "Trust certificate" : "Retry connection"
|
||||
if problem.suggestsOnboardingReset { return "Scan QR again" }
|
||||
return problem.canTrustRotatedCertificate ? "Trust certificate" : "Retry connection"
|
||||
}
|
||||
|
||||
private func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) async {
|
||||
if problem.suggestsOnboardingReset {
|
||||
GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId)
|
||||
self.gatewayToken = ""
|
||||
self.gatewayPassword = ""
|
||||
self.connectingGatewayID = nil
|
||||
self.connectMessage = nil
|
||||
self.issue = .none
|
||||
self.pairingRequestId = nil
|
||||
self.statusLine = "Scan a fresh setup QR code from this gateway."
|
||||
self.step = .connect
|
||||
self.showQRScanner = true
|
||||
return
|
||||
}
|
||||
if problem.canTrustRotatedCertificate {
|
||||
self.connectingGatewayID = "trust-certificate"
|
||||
self.connectMessage = "Updating gateway certificate…"
|
||||
|
||||
@@ -15,6 +15,7 @@ struct RootCanvas: View {
|
||||
@AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0
|
||||
@AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false
|
||||
@AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false
|
||||
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
|
||||
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
||||
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
|
||||
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
|
||||
@@ -102,6 +103,9 @@ struct RootCanvas: View {
|
||||
},
|
||||
retryGatewayConnection: {
|
||||
Task { await self.gatewayController.connectLastKnown() }
|
||||
},
|
||||
resetOnboarding: {
|
||||
self.resetOnboardingFromGatewayProblem()
|
||||
})
|
||||
.preferredColorScheme(.dark)
|
||||
|
||||
@@ -429,6 +433,13 @@ struct RootCanvas: View {
|
||||
guard shouldPresent else { return }
|
||||
self.presentedSheet = .quickSetup
|
||||
}
|
||||
|
||||
private func resetOnboardingFromGatewayProblem() {
|
||||
GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId)
|
||||
self.presentedSheet = nil
|
||||
self.onboardingAllowSkip = false
|
||||
self.showOnboarding = true
|
||||
}
|
||||
}
|
||||
|
||||
private struct HomeCanvasPayload: Codable {
|
||||
@@ -469,6 +480,7 @@ private struct CanvasContent: View {
|
||||
var openChat: () -> Void
|
||||
var openSettings: () -> Void
|
||||
var retryGatewayConnection: () -> Void
|
||||
var resetOnboarding: () -> Void
|
||||
|
||||
private var brightenButtons: Bool {
|
||||
self.systemColorScheme == .light
|
||||
@@ -578,12 +590,15 @@ private struct CanvasContent: View {
|
||||
|
||||
private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String {
|
||||
if problem.canTrustRotatedCertificate { return "Trust certificate" }
|
||||
if problem.suggestsOnboardingReset { return "Reset onboarding" }
|
||||
return problem.retryable ? "Retry" : "Open Settings"
|
||||
}
|
||||
|
||||
private func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) {
|
||||
if problem.canTrustRotatedCertificate {
|
||||
Task { await self.gatewayController.trustRotatedGatewayCertificate(from: problem) }
|
||||
} else if problem.suggestsOnboardingReset {
|
||||
self.resetOnboarding()
|
||||
} else if problem.retryable {
|
||||
self.retryGatewayConnection()
|
||||
} else {
|
||||
|
||||
@@ -1057,10 +1057,15 @@ struct SettingsTab: View {
|
||||
}
|
||||
|
||||
private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String {
|
||||
problem.canTrustRotatedCertificate ? "Trust certificate" : "Retry connection"
|
||||
if problem.suggestsOnboardingReset { return "Reset onboarding" }
|
||||
return problem.canTrustRotatedCertificate ? "Trust certificate" : "Retry connection"
|
||||
}
|
||||
|
||||
private func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) async {
|
||||
if problem.suggestsOnboardingReset {
|
||||
self.resetOnboarding()
|
||||
return
|
||||
}
|
||||
if problem.canTrustRotatedCertificate {
|
||||
_ = await self.gatewayController.trustRotatedGatewayCertificate(from: problem)
|
||||
return
|
||||
@@ -1070,7 +1075,6 @@ struct SettingsTab: View {
|
||||
|
||||
private func resetOnboarding() {
|
||||
// Disconnect first so RootCanvas doesn't instantly mark onboarding complete again.
|
||||
self.appModel.disconnectGateway()
|
||||
self.connectingGatewayID = nil
|
||||
self.setupStatusText = nil
|
||||
self.setupCode = ""
|
||||
@@ -1082,19 +1086,7 @@ struct SettingsTab: View {
|
||||
self.gatewayToken = ""
|
||||
self.gatewayPassword = ""
|
||||
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedInstanceId.isEmpty {
|
||||
GatewaySettingsStore.deleteGatewayCredentials(instanceId: trimmedInstanceId)
|
||||
}
|
||||
|
||||
// Reset onboarding state + clear saved gateway connection (the two things RootCanvas checks).
|
||||
GatewaySettingsStore.clearLastGatewayConnection()
|
||||
GatewaySettingsStore.clearPreferredGatewayStableID()
|
||||
GatewaySettingsStore.clearLastDiscoveredGatewayStableID()
|
||||
// Resetting onboarding should also forget trusted gateway TLS fingerprints.
|
||||
// Otherwise a restarted dev gateway can stay stuck in a local TLS cancel loop.
|
||||
GatewayTLSStore.clearAllFingerprints()
|
||||
OnboardingStateStore.reset()
|
||||
GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId)
|
||||
|
||||
// RootCanvas also short-circuits onboarding when these are true.
|
||||
self.onboardingComplete = false
|
||||
|
||||
@@ -35,6 +35,7 @@ Sources/Model/NodeAppModel+WatchNotifyNormalization.swift
|
||||
Sources/Model/NodeAppModel.swift
|
||||
Sources/Model/WatchReplyCoordinator.swift
|
||||
Sources/Motion/MotionService.swift
|
||||
Sources/Onboarding/GatewayOnboardingReset.swift
|
||||
Sources/Onboarding/GatewayOnboardingView.swift
|
||||
Sources/Onboarding/OnboardingStateStore.swift
|
||||
Sources/Onboarding/OnboardingWizardView.swift
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "2026.5.19"
|
||||
"version": "2026.5.21"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "284269c447b94311beae65318f1912f813261bfdc559185028fc1233ce288efa",
|
||||
"originHash" : "92edc1a12985a9d17dc33bfc8b590ab5f5a9566bb2bdd8debdb79e6a8d445908",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "axorcist",
|
||||
@@ -42,8 +42,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/Peekaboo.git",
|
||||
"state" : {
|
||||
"revision" : "41180ca7e391c2a05e7cfa9eb6390812805d4f22",
|
||||
"version" : "3.0.0"
|
||||
"revision" : "36108b4ea7d7848d616c3cad1e7a03e40d6be857",
|
||||
"version" : "3.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@ let package = Package(
|
||||
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.4.0"),
|
||||
.package(url: "https://github.com/apple/swift-log.git", from: "1.10.1"),
|
||||
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.0"),
|
||||
.package(url: "https://github.com/steipete/Peekaboo.git", exact: "3.0.0"),
|
||||
.package(url: "https://github.com/steipete/Peekaboo.git", exact: "3.2.1"),
|
||||
.package(path: "../shared/OpenClawKit"),
|
||||
.package(path: "../swabble"),
|
||||
],
|
||||
|
||||
@@ -77,7 +77,7 @@ struct AboutSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
Text("© 2025 Peter Steinberger — MIT License.")
|
||||
Text("© 2026 OpenClaw Foundation — MIT License.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 4)
|
||||
|
||||
@@ -192,8 +192,6 @@ enum HostEnvSecurityPolicy {
|
||||
"VIRTUAL_ENV",
|
||||
"VISUAL",
|
||||
"WGETRC",
|
||||
"XDG_CONFIG_DIRS",
|
||||
"XDG_CONFIG_HOME",
|
||||
"YARN_RC_FILENAME"
|
||||
]
|
||||
|
||||
@@ -437,8 +435,13 @@ enum HostEnvSecurityPolicy {
|
||||
"VISUAL",
|
||||
"WGETRC",
|
||||
"WINDIR",
|
||||
"XDG_CACHE_HOME",
|
||||
"XDG_CONFIG_DIRS",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_DATA_DIRS",
|
||||
"XDG_DATA_HOME",
|
||||
"XDG_RUNTIME_DIR",
|
||||
"XDG_STATE_HOME",
|
||||
"YARN_RC_FILENAME",
|
||||
"ZDOTDIR"
|
||||
]
|
||||
|
||||
@@ -163,6 +163,7 @@ private final class OpenClawPeekabooBridgeServices: PeekabooBridgeServiceProvidi
|
||||
let dock: any DockServiceProtocol
|
||||
let dialogs: any DialogServiceProtocol
|
||||
let snapshots: any SnapshotManagerProtocol
|
||||
let desktopObservation: any DesktopObservationServiceProtocol
|
||||
|
||||
init() {
|
||||
let logging = LoggingService(subsystem: "ai.openclaw.peekaboo")
|
||||
@@ -175,19 +176,29 @@ private final class OpenClawPeekabooBridgeServices: PeekabooBridgeServiceProvidi
|
||||
let applications = ApplicationService(feedbackClient: feedbackClient)
|
||||
|
||||
let screenCapture = ScreenCaptureService(loggingService: logging)
|
||||
let automation = UIAutomationService(
|
||||
snapshotManager: snapshots,
|
||||
loggingService: logging,
|
||||
searchPolicy: .balanced,
|
||||
feedbackClient: feedbackClient)
|
||||
let menu = MenuService(applicationService: applications, feedbackClient: feedbackClient)
|
||||
let screens = ScreenService()
|
||||
|
||||
self.permissions = PermissionsService()
|
||||
self.snapshots = snapshots
|
||||
self.applications = applications
|
||||
self.screenCapture = screenCapture
|
||||
self.automation = UIAutomationService(
|
||||
snapshotManager: snapshots,
|
||||
loggingService: logging,
|
||||
searchPolicy: .balanced,
|
||||
feedbackClient: feedbackClient)
|
||||
self.automation = automation
|
||||
self.windows = WindowManagementService(applicationService: applications, feedbackClient: feedbackClient)
|
||||
self.menu = MenuService(applicationService: applications, feedbackClient: feedbackClient)
|
||||
self.menu = menu
|
||||
self.dock = DockService(feedbackClient: feedbackClient)
|
||||
self.dialogs = DialogService(feedbackClient: feedbackClient)
|
||||
self.desktopObservation = DesktopObservationService(
|
||||
screenCapture: screenCapture,
|
||||
automation: automation,
|
||||
applications: applications,
|
||||
menu: menu,
|
||||
screens: screens,
|
||||
snapshotManager: snapshots)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.5.19</string>
|
||||
<string>2026.5.21</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2026051900</string>
|
||||
<string>2026052100</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -601,7 +601,6 @@ public actor GatewayChannelActor {
|
||||
let allowedOperatorScopes: Set = [
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
]
|
||||
return Array(Set(scopes.filter { allowedOperatorScopes.contains($0) })).sorted()
|
||||
|
||||
@@ -121,6 +121,10 @@ public struct GatewayConnectionProblem: Equatable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public var suggestsOnboardingReset: Bool {
|
||||
self.kind == .gatewayAuthTokenMismatch
|
||||
}
|
||||
|
||||
public var statusText: String {
|
||||
switch self.kind {
|
||||
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired,
|
||||
|
||||
@@ -70,6 +70,19 @@ import Testing
|
||||
#expect(problem?.needsCredentialUpdate == false)
|
||||
}
|
||||
|
||||
@Test func tokenMismatchSuggestsOnboardingReset() {
|
||||
let error = GatewayConnectAuthError(
|
||||
message: "token mismatch",
|
||||
detailCode: GatewayConnectAuthDetailCode.authTokenMismatch.rawValue,
|
||||
canRetryWithDeviceToken: false)
|
||||
|
||||
let problem = GatewayConnectionProblemMapper.map(error: error)
|
||||
|
||||
#expect(problem?.kind == .gatewayAuthTokenMismatch)
|
||||
#expect(problem?.suggestsOnboardingReset == true)
|
||||
#expect(problem?.needsCredentialUpdate == true)
|
||||
}
|
||||
|
||||
@Test func cancelledTransportDoesNotReplaceStructuredPairingProblem() {
|
||||
let pairing = GatewayConnectAuthError(
|
||||
message: "pairing required",
|
||||
|
||||
@@ -405,7 +405,6 @@ struct GatewayNodeSessionTests {
|
||||
#expect(operatorEntry.scopes == [
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
])
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Peter Steinberger
|
||||
Copyright (c) 2026 OpenClaw Foundation
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
82d56352536e75291ec81540bd4d93e22aeae282e2ef864aa0f231b6deb11bba config-baseline.json
|
||||
5d6aa4d0789482b1bdb6681d19fe193a8696ca25c20cbb9e07edb6d1b23ad8f2 config-baseline.core.json
|
||||
e068db276fdff1727939d4f3a8001376e550c444bdff3e3443ab26812e2f8c5d config-baseline.channel.json
|
||||
a87fc4c9bc6499c5fb9d9343b8c1c4f0c3381a6afbdb0a676dc8ba9e03ff5755 config-baseline.plugin.json
|
||||
4f9946cf7d5afea985c8b9be185c7d8b420e038d915ce91be7476dd6541e0f08 config-baseline.json
|
||||
35d17c60d2858a9cbc875807cdfc7f2fc8ba4745aa4e140a3cdf7ecf38b8a034 config-baseline.core.json
|
||||
11839c7a1b858c66075156f0e203aa8367cd8321047684679a18e18b7c8fe1f7 config-baseline.channel.json
|
||||
a0a88df97080adf50c2c2bccd2ca076ad43e81b24dd25f3c3cace41f09a7c8f0 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
86a7102dd571ca4c14f45c890392a95a51244e9bfba108986d87ca37bfb0b3c4 plugin-sdk-api-baseline.json
|
||||
810d6807ff58ae79178dd0fadc84c10ce04813bbff738cc2fdaf12d1c2acef16 plugin-sdk-api-baseline.jsonl
|
||||
bb0da3ba4560521d2c9725cd96429f64ce8e6150972ba77be71fdf8ea03e0234 plugin-sdk-api-baseline.json
|
||||
4d951b989cc00a86f64907bb28d52a950a466116382c9877f24807b0fba3df44 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -51,7 +51,7 @@ Cron is the Gateway's built-in scheduler. It persists jobs, wakes the agent at t
|
||||
- Isolated cron runs best-effort close tracked browser tabs/processes for their `cron:<jobId>` session when the run completes, so detached browser automation does not leave orphaned processes behind.
|
||||
- Isolated cron runs that receive the narrow cron self-cleanup grant can still read scheduler status, a self-filtered list of their current job, and that job's run history, so status/heartbeat checks can inspect their own schedule without gaining broader cron mutation access.
|
||||
- Isolated cron runs also guard against stale acknowledgement replies. If the first result is just an interim status update (`on it`, `pulling everything together`, and similar hints) and no descendant subagent run is still responsible for the final answer, OpenClaw re-prompts once for the actual result before delivery.
|
||||
- Isolated cron runs prefer structured execution-denial metadata from the embedded run, then fall back to known final summary/output markers such as `SYSTEM_RUN_DENIED` and `INVALID_REQUEST`, so a blocked command is not reported as a green run.
|
||||
- Isolated cron runs use structured execution-denial metadata from the embedded run, including node-host `UNAVAILABLE` wrappers whose nested error message starts with `SYSTEM_RUN_DENIED` or `INVALID_REQUEST`, so a blocked command is not reported as a green run while ordinary assistant prose is not treated as a denial.
|
||||
- Isolated cron runs also treat run-level agent failures as job errors even when no reply payload is produced, so model/provider failures increment error counters and trigger failure notifications instead of clearing the job as successful.
|
||||
- When an isolated agent-turn job reaches `timeoutSeconds`, cron aborts the underlying agent run and gives it a short cleanup window. If the run does not drain, Gateway-owned cleanup force-clears that run's session ownership before cron records the timeout, so queued chat work is not left behind a stale processing session.
|
||||
- If an isolated agent-turn stalls before the runner starts or before the first model call, cron records a phase-specific timeout such as `setup timed out before runner start` or `stalled before first model call (last phase: context-engine)`. These watchdogs cover embedded providers and CLI-backed providers before their external CLI process is actually started, and are capped independently from long `timeoutSeconds` values so cold-start/auth/context failures surface quickly instead of waiting for the full job budget.
|
||||
@@ -90,14 +90,14 @@ This fires ~5–6 times per month instead of 0–1 times per month. OpenClaw use
|
||||
|
||||
| Style | `--session` value | Runs in | Best for |
|
||||
| --------------- | ------------------- | ------------------------ | ------------------------------- |
|
||||
| Main session | `main` | Next heartbeat turn | Reminders, system events |
|
||||
| Main session | `main` | Dedicated cron wake lane | Reminders, system events |
|
||||
| Isolated | `isolated` | Dedicated `cron:<jobId>` | Reports, background chores |
|
||||
| Current session | `current` | Bound at creation time | Context-aware recurring work |
|
||||
| Custom session | `session:custom-id` | Persistent named session | Workflows that build on history |
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Main session vs isolated vs custom">
|
||||
**Main session** jobs enqueue a system event and optionally wake the heartbeat (`--wake now` or `--wake next-heartbeat`). Those system events do not extend daily/idle reset freshness for the target session. **Isolated** jobs run a dedicated agent turn with a fresh session. **Custom sessions** (`session:xxx`) persist context across runs, enabling workflows like daily standups that build on previous summaries.
|
||||
**Main session** jobs enqueue a system event into a cron-owned run lane and optionally wake the heartbeat (`--wake now` or `--wake next-heartbeat`). They can use the target main session's last delivery context for replies, but they do not append routine cron turns to the human chat lane and do not extend daily/idle reset freshness for the target session. **Isolated** jobs run a dedicated agent turn with a fresh session. **Custom sessions** (`session:xxx`) persist context across runs, enabling workflows like daily standups that build on previous summaries.
|
||||
</Accordion>
|
||||
<Accordion title="What 'fresh session' means for isolated jobs">
|
||||
For isolated jobs, "fresh session" means a new transcript/session id for each run. OpenClaw may carry safe preferences such as thinking/fast/verbose settings, labels, and explicit user-selected model/auth overrides, but it does not inherit ambient conversation context from an older cron row: channel/group routing, send or queue policy, elevation, origin, or ACP runtime binding. Use `current` or `session:<id>` when a recurring job should deliberately build on the same conversation context.
|
||||
|
||||
@@ -352,6 +352,8 @@ By default, components are single use. Set `components.reusable=true` to allow b
|
||||
|
||||
To restrict who can click a button, set `allowedUsers` on that button (Discord user IDs, tags, or `*`). When configured, unmatched users receive an ephemeral denial.
|
||||
|
||||
Component callbacks expire after 30 minutes by default. Set `channels.discord.agentComponents.ttlMs` to change that callback registry lifetime for the default Discord account, or `channels.discord.accounts.<accountId>.agentComponents.ttlMs` to override one account in a multi-account setup. The value is milliseconds, must be a positive integer, and is capped at `86400000` (24 hours). Longer TTLs are useful for review or approval workflows that need buttons to remain usable, but they also extend the window where an old Discord message can still trigger an action. Prefer the shortest TTL that fits the workflow, and keep the default when stale callbacks would be surprising.
|
||||
|
||||
The `/model` and `/models` slash commands open an interactive model picker with provider, model, and compatible runtime dropdowns plus a Submit step. `/models add` is deprecated and now returns a deprecation message instead of registering models from chat. The picker reply is ephemeral and only the invoking user can use it. Discord select menus are limited to 25 options, so add `provider/*` entries to `agents.defaults.models` when you want the picker to show dynamically discovered models only for selected providers such as `openai-codex` or `vllm`.
|
||||
|
||||
File attachments:
|
||||
@@ -1136,6 +1138,7 @@ OpenClaw uses Discord components v2 for exec approvals and cross-context markers
|
||||
|
||||
- `channels.discord.ui.components.accentColor` sets the accent color used by Discord component containers (hex).
|
||||
- Set per account with `channels.discord.accounts.<id>.ui.components.accentColor`.
|
||||
- `channels.discord.agentComponents.ttlMs` controls how long sent Discord component callbacks remain registered (default `1800000`, maximum `86400000`). Set per account with `channels.discord.accounts.<id>.agentComponents.ttlMs`.
|
||||
- `embeds` are ignored when components v2 are present.
|
||||
- Plain URL previews are suppressed by default. Set `suppressEmbeds: false` on a message action when a single outbound link should expand.
|
||||
|
||||
@@ -1226,15 +1229,17 @@ Notes:
|
||||
- `voice.mode` controls the conversation path. The default is `agent-proxy`: a realtime voice front end handles turn timing, interruption, and playback, delegates substantive work to the routed OpenClaw agent through `openclaw_agent_consult`, and treats the result like a typed Discord prompt from that speaker. `stt-tts` keeps the older batch STT plus TTS flow. `bidi` lets the realtime model converse directly while exposing `openclaw_agent_consult` for the OpenClaw brain.
|
||||
- `voice.agentSession` controls which OpenClaw conversation receives voice turns. Leave it unset for the voice channel's own session, or set `{ mode: "target", target: "channel:<text-channel-id>" }` to make the voice channel act as the microphone/speaker extension of an existing Discord text channel session such as `#maintainers`.
|
||||
- `voice.model` overrides the OpenClaw agent brain for Discord voice responses and realtime consults. Leave it unset to inherit the routed agent model. It is separate from `voice.realtime.model`.
|
||||
- `voice.followUsers` lets the bot join, move, and leave Discord voice with selected users. See [Follow users in voice](#follow-users-in-voice) for behavior rules and examples.
|
||||
- `agent-proxy` routes speech through `discord-voice`, which preserves normal owner/tool authorization for the speaker and target session but hides the agent `tts` tool because Discord voice owns playback. By default, `agent-proxy` gives the consult full owner-equivalent tool access for owner speakers (`voice.realtime.toolPolicy: "owner"`) and strongly prefers consulting the OpenClaw agent before substantive answers (`voice.realtime.consultPolicy: "always"`). In that default `always` mode, the realtime layer does not auto-speak filler before the consult answer; it captures and transcribes speech, then speaks the routed OpenClaw answer. If multiple forced consult answers finish while Discord is still playing the first answer, later exact-speech answers are queued until playback idles instead of replacing speech mid-sentence.
|
||||
- In `stt-tts` mode, STT uses `tools.media.audio`; `voice.model` does not affect transcription.
|
||||
- In realtime modes, `voice.realtime.provider`, `voice.realtime.model`, and `voice.realtime.voice` configure the realtime audio session. For OpenAI Realtime 2 plus the Codex brain, use `voice.realtime.model: "gpt-realtime-2"` and `voice.model: "openai-codex/gpt-5.5"`.
|
||||
- Realtime voice modes include small `IDENTITY.md`, `USER.md`, and `SOUL.md` profile files in the realtime provider instructions by default so fast direct turns keep the same identity, user grounding, and persona as the routed OpenClaw agent. Set `voice.realtime.bootstrapContextFiles` to a subset to customize this, or `[]` to disable it. The supported realtime bootstrap files are limited to those profile files; `AGENTS.md` stays in the normal agent context. The injected profile context does not replace `openclaw_agent_consult` for workspace work, current facts, memory lookup, or tool-backed actions.
|
||||
- The OpenAI realtime provider accepts current Realtime 2 event names and legacy Codex-compatible aliases for output audio and transcript events, so compatible provider snapshots can drift without dropping assistant audio.
|
||||
- `voice.realtime.bargeIn` controls whether Discord speaker-start events interrupt active realtime playback. If unset, it follows the realtime provider's input-audio interruption setting.
|
||||
- `voice.realtime.minBargeInAudioEndMs` controls the minimum assistant playback duration before an OpenAI realtime barge-in truncates audio. Default: `250`. Set `0` for immediate interruption in low-echo rooms, or raise it for echo-heavy speaker setups.
|
||||
- For an OpenAI voice on Discord playback, set `voice.tts.provider: "openai"` and choose a Text-to-speech voice under `voice.tts.openai.voice` or `voice.tts.providers.openai.voice`. `cedar` is a good masculine-sounding choice on the current OpenAI TTS model.
|
||||
- Per-channel Discord `systemPrompt` overrides apply to voice transcript turns for that voice channel.
|
||||
- Voice transcript turns derive owner status from Discord `allowFrom` (or `dm.allowFrom`); non-owner speakers cannot access owner-only tools (for example `gateway` and `cron`).
|
||||
- Voice transcript turns derive owner status from Discord `allowFrom` (or `dm.allowFrom`) for owner-gated commands and channel actions. Agent tool visibility follows the configured tool policy for the routed session.
|
||||
- Discord voice is opt-in for text-only configs; set `channels.discord.voice.enabled=true` (or keep an existing `channels.discord.voice` block) to enable `/vc` commands, the voice runtime, and the `GuildVoiceStates` gateway intent.
|
||||
- `channels.discord.intents.voiceStates` can explicitly override voice-state intent subscription. Leave it unset for the intent to follow effective voice enablement.
|
||||
- If `voice.autoJoin` has multiple entries for the same guild, OpenClaw joins the last configured channel for that guild.
|
||||
@@ -1254,6 +1259,47 @@ Notes:
|
||||
- Verbose Discord voice logs include a bounded one-line STT transcript preview for each accepted speaker segment, so debugging shows both the user side and the agent reply side without dumping unbounded transcript text.
|
||||
- In `agent-proxy` mode, forced consult fallback skips likely incomplete transcript fragments such as text ending in `...` or a trailing connector like `and`, plus obvious non-actionable closings like “be right back” or “bye”. Logs show `forced agent consult skipped reason=...` when this prevents a stale queued answer.
|
||||
|
||||
### Follow users in voice
|
||||
|
||||
Use `voice.followUsers` when you want the Discord voice bot to stay with one or more known Discord users instead of joining a fixed channel at startup or waiting for `/vc join`.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
voice: {
|
||||
enabled: true,
|
||||
followUsersEnabled: true,
|
||||
followUsers: ["discord:123456789012345678"],
|
||||
allowedChannels: [
|
||||
{
|
||||
guildId: "123456789012345678",
|
||||
channelId: "234567890123456789",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- `followUsers` accepts raw Discord user IDs and `discord:<id>` values. OpenClaw normalizes both forms before matching voice-state events.
|
||||
- `followUsersEnabled` defaults to `true` when `followUsers` is configured. Set it to `false` to keep the saved list but stop automatic voice following.
|
||||
- When a followed user joins an allowed voice channel, OpenClaw joins that channel. When the user moves, OpenClaw moves with them. When the active followed user disconnects, OpenClaw leaves.
|
||||
- If multiple followed users are in the same guild and the active followed user leaves, OpenClaw moves to another tracked followed user's channel before leaving the guild. If several followed users move at once, the latest observed voice-state event wins.
|
||||
- `allowedChannels` still applies. A followed user in a disallowed channel is ignored, and a follow-owned session moves to another followed user or leaves.
|
||||
- OpenClaw reconciles missed voice-state events on startup and at a bounded interval. Reconciliation samples configured guilds and caps REST lookups per run, so very large `followUsers` lists may take more than one interval to converge.
|
||||
- If Discord or an admin moves the bot while it is following a user, OpenClaw rebuilds the voice session and preserves follow ownership when the destination is allowed. If the bot is moved outside `allowedChannels`, OpenClaw leaves and rejoins the configured target when one exists.
|
||||
- DAVE receive recovery may leave and rejoin the same channel after repeated decrypt failures. Follow-owned sessions keep their follow ownership through that recovery path, so a later followed-user disconnect still leaves the channel.
|
||||
|
||||
Choose between the join modes:
|
||||
|
||||
- Use `followUsers` for personal or operator setups where the bot should automatically be in voice when you are.
|
||||
- Use `autoJoin` for fixed-room bots that should be present even when no tracked user is in voice.
|
||||
- Use `/vc join` for one-off joins or rooms where automatic voice presence would be surprising.
|
||||
|
||||
Native opus setup for source checkouts:
|
||||
|
||||
```bash
|
||||
@@ -1288,6 +1334,8 @@ Default agent-proxy voice-channel session example:
|
||||
voice: {
|
||||
enabled: true,
|
||||
model: "openai-codex/gpt-5.5",
|
||||
followUsersEnabled: true,
|
||||
followUsers: ["123456789012345678"],
|
||||
realtime: {
|
||||
provider: "openai",
|
||||
model: "gpt-realtime-2",
|
||||
@@ -1680,7 +1728,7 @@ Primary reference: [Configuration reference - Discord](/gateway/config-channels#
|
||||
- actions: `actions.*`
|
||||
- presence: `activity`, `status`, `activityType`, `activityUrl`
|
||||
- UI: `ui.components.accentColor`
|
||||
- features: `threadBindings`, top-level `bindings[]` (`type: "acp"`), `pluralkit`, `execApprovals`, `intents`, `agentComponents`, `heartbeat`, `responsePrefix`
|
||||
- features: `threadBindings`, top-level `bindings[]` (`type: "acp"`), `pluralkit`, `execApprovals`, `intents`, `agentComponents.enabled`, `agentComponents.ttlMs`, `heartbeat`, `responsePrefix`
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user