mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-11 08:21:38 +08:00
Compare commits
341 Commits
fix/sqlite
...
fix/memory
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc15253f59 | ||
|
|
3bc770664c | ||
|
|
57c8d487eb | ||
|
|
13dce48321 | ||
|
|
1b23e73830 | ||
|
|
54db3f9df5 | ||
|
|
cae66a7d5b | ||
|
|
2db65e5778 | ||
|
|
c7ed990769 | ||
|
|
a450ff036a | ||
|
|
a7b0d325af | ||
|
|
0923ee251e | ||
|
|
e15b646f18 | ||
|
|
3faf669801 | ||
|
|
2d404f1b86 | ||
|
|
8042ec4cb8 | ||
|
|
f1f00cbf1d | ||
|
|
e6b0a22f36 | ||
|
|
2129d5b3ab | ||
|
|
797aab5ab7 | ||
|
|
d7f211b2b4 | ||
|
|
e6e27c354c | ||
|
|
a69dd43de5 | ||
|
|
781e03a179 | ||
|
|
fbb1eab4c7 | ||
|
|
5d2eb3c3e1 | ||
|
|
aeb537c286 | ||
|
|
caa83b0c42 | ||
|
|
c944986c7b | ||
|
|
a427aff304 | ||
|
|
32f8411256 | ||
|
|
40da4a31d2 | ||
|
|
ea7e5ee436 | ||
|
|
756e9d16a0 | ||
|
|
80dc6cf6ab | ||
|
|
8f82f5809f | ||
|
|
389c0aba98 | ||
|
|
41000143a1 | ||
|
|
a1546fae80 | ||
|
|
551664c3c5 | ||
|
|
7a4f678a29 | ||
|
|
c7c695c208 | ||
|
|
9ef34da5a9 | ||
|
|
9922da3965 | ||
|
|
50c2068268 | ||
|
|
b0520fa320 | ||
|
|
cdd923c286 | ||
|
|
09356fb985 | ||
|
|
fb9dc867b1 | ||
|
|
2e4bf410a2 | ||
|
|
33a3e05683 | ||
|
|
49737a50af | ||
|
|
78a5e3ede7 | ||
|
|
7926acee98 | ||
|
|
17be26bc4f | ||
|
|
7a34986ef0 | ||
|
|
e24c3df27d | ||
|
|
dbcae5b78d | ||
|
|
0e7b5c3429 | ||
|
|
ade5ac0350 | ||
|
|
ac21e89c13 | ||
|
|
b71d8e1c32 | ||
|
|
4ecec2f9e2 | ||
|
|
bb6e47729c | ||
|
|
050c0813b3 | ||
|
|
7200c316e5 | ||
|
|
3078a8335d | ||
|
|
709855d623 | ||
|
|
272b599369 | ||
|
|
05a3b44c93 | ||
|
|
c84e521920 | ||
|
|
86215c9634 | ||
|
|
514ac29d03 | ||
|
|
b08eef0508 | ||
|
|
9c6186de43 | ||
|
|
0d41316bcc | ||
|
|
9297c20a85 | ||
|
|
b2dec9cd50 | ||
|
|
a283df4bfe | ||
|
|
cab62324f4 | ||
|
|
1ec04c6baa | ||
|
|
f0206d935a | ||
|
|
800f4dcb62 | ||
|
|
43bbde4830 | ||
|
|
7d3e8dc963 | ||
|
|
3b13d6ae38 | ||
|
|
69aca06e02 | ||
|
|
0766a5c2ec | ||
|
|
2c146261a2 | ||
|
|
87abb8defb | ||
|
|
3b180d5d99 | ||
|
|
b9095bf70d | ||
|
|
049c3c4877 | ||
|
|
1265da2a5c | ||
|
|
cfdabfbaab | ||
|
|
e3fe6715af | ||
|
|
0948bd648a | ||
|
|
d07cd4c968 | ||
|
|
db5b883a9c | ||
|
|
fd7e181500 | ||
|
|
3407402b2c | ||
|
|
05a0dfdd08 | ||
|
|
7f9f687d82 | ||
|
|
7f1d82ab25 | ||
|
|
9408380ae7 | ||
|
|
0a3aa5f278 | ||
|
|
a3d5e5bc72 | ||
|
|
7cb2571a99 | ||
|
|
7b7e8f6e88 | ||
|
|
60459d4061 | ||
|
|
54288a1e7c | ||
|
|
de4b8d8ebf | ||
|
|
52bc2a12bc | ||
|
|
ca4e4d93d2 | ||
|
|
92418fc9da | ||
|
|
c7b4c6bfc5 | ||
|
|
c39dea917a | ||
|
|
40aad24e8a | ||
|
|
69a73b6278 | ||
|
|
54c400a975 | ||
|
|
e9671ed603 | ||
|
|
b9280d5863 | ||
|
|
b4cdd92119 | ||
|
|
5a0b95269d | ||
|
|
69b95c3447 | ||
|
|
bf89552e67 | ||
|
|
a8d33f23a0 | ||
|
|
6c045c5ca3 | ||
|
|
bfccbc3fee | ||
|
|
9a1f2022b1 | ||
|
|
5967ae61bd | ||
|
|
48ec58a584 | ||
|
|
c0a4a7890d | ||
|
|
0a6a10193d | ||
|
|
c350c35fad | ||
|
|
56dc53f6d2 | ||
|
|
ec0f311f7f | ||
|
|
f0d8048aa3 | ||
|
|
54415d322f | ||
|
|
3a9ea1d85b | ||
|
|
a90eb93452 | ||
|
|
468db12c21 | ||
|
|
4ca6ac326e | ||
|
|
9833f3ea9b | ||
|
|
e9bd90d209 | ||
|
|
314de694c4 | ||
|
|
4648701fc1 | ||
|
|
3b7631e50d | ||
|
|
21104cd52e | ||
|
|
6ab084e89d | ||
|
|
e16193bad5 | ||
|
|
31d49c59d7 | ||
|
|
ef2ca9e50e | ||
|
|
09854d9de7 | ||
|
|
d31d26ef42 | ||
|
|
440284f879 | ||
|
|
784e86433c | ||
|
|
a82abc771a | ||
|
|
a93bc61a84 | ||
|
|
b502a92bf1 | ||
|
|
21410d1c32 | ||
|
|
a4e02cd1dd | ||
|
|
b6a3f2988c | ||
|
|
bf95883812 | ||
|
|
d2ddc26e89 | ||
|
|
96a49caffa | ||
|
|
2649064548 | ||
|
|
370cef2e3b | ||
|
|
a2dd821908 | ||
|
|
443115c632 | ||
|
|
5b9cb3bd3a | ||
|
|
8b84e951e5 | ||
|
|
52154eda0d | ||
|
|
3853eb15af | ||
|
|
696c1ecd20 | ||
|
|
2da7dc9f2c | ||
|
|
439b0582e2 | ||
|
|
f13b6ea151 | ||
|
|
56d201fa67 | ||
|
|
9bb68b55dd | ||
|
|
d48778994f | ||
|
|
479e2aaae3 | ||
|
|
1893a0727a | ||
|
|
61e93a800d | ||
|
|
da7f9c51df | ||
|
|
55156a1241 | ||
|
|
cc856cde1b | ||
|
|
4106f446bd | ||
|
|
8c3ba33463 | ||
|
|
86bab9699d | ||
|
|
d2a6529f04 | ||
|
|
2dcfd9f218 | ||
|
|
d86069ded0 | ||
|
|
7269b26926 | ||
|
|
224ea76d29 | ||
|
|
cc0a18da4f | ||
|
|
48b3cb69b7 | ||
|
|
cfe0bac99a | ||
|
|
7f69fe009a | ||
|
|
7cdec28706 | ||
|
|
9f413acc18 | ||
|
|
98d5c46530 | ||
|
|
65848d0b45 | ||
|
|
9ef67ea405 | ||
|
|
66749a3713 | ||
|
|
73ce4fdcbb | ||
|
|
a36e05050a | ||
|
|
2f02bbcbb3 | ||
|
|
5e1fbca3cb | ||
|
|
e949809f6e | ||
|
|
25160515e0 | ||
|
|
af5c7c5fd0 | ||
|
|
cd0bca0823 | ||
|
|
0a1cf8a776 | ||
|
|
446936d600 | ||
|
|
257b251e26 | ||
|
|
c517162536 | ||
|
|
1240de7588 | ||
|
|
93d540d67b | ||
|
|
1727ec7b2d | ||
|
|
b08e1109c6 | ||
|
|
d12b7b0551 | ||
|
|
994f4f99fe | ||
|
|
f57c3b55fd | ||
|
|
56fe1e0c95 | ||
|
|
7128fa8832 | ||
|
|
e103d1231d | ||
|
|
a1fb8cf304 | ||
|
|
ea51c3ea50 | ||
|
|
34e76e6e6f | ||
|
|
090c492759 | ||
|
|
09a5cdaca3 | ||
|
|
c93e837336 | ||
|
|
9cf46d7e5a | ||
|
|
5e4a160f54 | ||
|
|
33cac9092b | ||
|
|
0c60bad890 | ||
|
|
a172db54b4 | ||
|
|
5f13d0c817 | ||
|
|
d0a84089a0 | ||
|
|
1ba782f286 | ||
|
|
3137110167 | ||
|
|
4c98a547d0 | ||
|
|
634bcf6667 | ||
|
|
e1978cf73c | ||
|
|
7e3100a120 | ||
|
|
03a8d18cd4 | ||
|
|
6fcc945702 | ||
|
|
b3c946999d | ||
|
|
f05e9873c6 | ||
|
|
9fdd56da21 | ||
|
|
4c55dd8549 | ||
|
|
162957565a | ||
|
|
c8a8152cd7 | ||
|
|
84acb74a6a | ||
|
|
9bbde70458 | ||
|
|
099abea089 | ||
|
|
e8cf6df3a3 | ||
|
|
9210d8f7d9 | ||
|
|
c9050c982d | ||
|
|
0933726574 | ||
|
|
9f48254f09 | ||
|
|
329fa44d23 | ||
|
|
aef1fad58d | ||
|
|
82afc4678a | ||
|
|
aa935ddeb2 | ||
|
|
fff5261ade | ||
|
|
5ef0d6c693 | ||
|
|
38a11944f4 | ||
|
|
1559c16a76 | ||
|
|
280d1cb977 | ||
|
|
14b1ebd640 | ||
|
|
5097749de3 | ||
|
|
27189b3e74 | ||
|
|
79c6136a9e | ||
|
|
c4a0ca0b7a | ||
|
|
dfb44912ed | ||
|
|
80f1ae6ffe | ||
|
|
2c6bdc8b28 | ||
|
|
72e40833ba | ||
|
|
5b76436c45 | ||
|
|
9082233a43 | ||
|
|
24196e05f5 | ||
|
|
93313c95a5 | ||
|
|
be5bfdccd1 | ||
|
|
372f85d368 | ||
|
|
3cf94309d9 | ||
|
|
f45cd5e57e | ||
|
|
c13802c912 | ||
|
|
c3cdd4971b | ||
|
|
5f6ee9f913 | ||
|
|
ebb9c6a013 | ||
|
|
0df7fe3056 | ||
|
|
50130d32a9 | ||
|
|
7a0e65773a | ||
|
|
c7b01cf201 | ||
|
|
bad449301f | ||
|
|
2a611865f4 | ||
|
|
1019b591d5 | ||
|
|
ff5fac1439 | ||
|
|
f29248fa62 | ||
|
|
04b8c4f313 | ||
|
|
f1a1fce982 | ||
|
|
9faa741536 | ||
|
|
dc51c57e29 | ||
|
|
95c72dde0f | ||
|
|
37c1e2725a | ||
|
|
06b226e8b5 | ||
|
|
3ed8d5f2c3 | ||
|
|
2d5bf186c1 | ||
|
|
3a2176267c | ||
|
|
4b55a0e04d | ||
|
|
9cdf853409 | ||
|
|
cff8154954 | ||
|
|
55de547b52 | ||
|
|
505b23a137 | ||
|
|
5496044f6d | ||
|
|
20604f7a8f | ||
|
|
a0f76b2b25 | ||
|
|
fb97b3b4b3 | ||
|
|
112e98faa2 | ||
|
|
646bc0d274 | ||
|
|
c967172f69 | ||
|
|
6aa89bb5f8 | ||
|
|
a54f50a41b | ||
|
|
f9f7475dbf | ||
|
|
b875c812f7 | ||
|
|
6c4fb997e5 | ||
|
|
b14923d1f3 | ||
|
|
4c5d8afa38 | ||
|
|
9aa6bfccce | ||
|
|
b0998f7d15 | ||
|
|
46f4db6bbd | ||
|
|
9220761fba | ||
|
|
a7847ac484 | ||
|
|
d4c6662341 | ||
|
|
224086e28b | ||
|
|
5f6d4277b1 | ||
|
|
105d77d486 | ||
|
|
4eb4b87c8e | ||
|
|
0176429ad7 |
@@ -19,7 +19,7 @@ attribution.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Target base version: `YYYY.M.D`, without beta suffix.
|
||||
- Target base version: `YYYY.M.PATCH`, without beta suffix.
|
||||
- Base tag: last reachable shipped release tag, usually the previous stable or
|
||||
the previous beta train requested by the operator.
|
||||
- Target ref: exact branch/SHA being released.
|
||||
@@ -37,7 +37,7 @@ attribution.
|
||||
3. Read linked PRs/issues or diffs for ambiguous commits. Direct commits matter;
|
||||
infer notes from subject, body, touched files, tests, and nearby commits.
|
||||
4. Rewrite one stable-base section only:
|
||||
- use `## YYYY.M.D`
|
||||
- use `## YYYY.M.PATCH`
|
||||
- do not create beta-specific headings
|
||||
- do not leave a stale `## Unreleased` section above the target release
|
||||
- if `Unreleased` contains release-bound notes, fold them into the target
|
||||
@@ -93,7 +93,7 @@ attribution.
|
||||
10. Validate and ship:
|
||||
- `git diff --check`
|
||||
- for docs/changelog-only changes, no broad tests are required
|
||||
- commit with `scripts/committer "docs(changelog): refresh YYYY.M.D notes" CHANGELOG.md`
|
||||
- commit with `scripts/committer "docs(changelog): refresh YYYY.M.PATCH notes" CHANGELOG.md`
|
||||
- push, pull/rebase if needed, then branch/rebase release from latest `main`
|
||||
|
||||
## Quota / API Outage Rule
|
||||
|
||||
@@ -36,8 +36,8 @@ Do not update these from mixed sources. All three ASC fields must come from the
|
||||
## Workflow Shape
|
||||
|
||||
- Public release branch may carry mac-only packaging fixes after the stable tag/npm are already live.
|
||||
- Use `source_ref=release/YYYY.M.D` for private mac preflight/validation when building that branch variation.
|
||||
- Keep `tag=vYYYY.M.D` pointing at the original stable release commit.
|
||||
- Use `source_ref=release/YYYY.M.PATCH` for private mac preflight/validation when building that branch variation.
|
||||
- Keep `tag=vYYYY.M.PATCH` pointing at the original stable release commit.
|
||||
- Real mac publish must reuse:
|
||||
- a successful private mac preflight run for the same tag/source SHA
|
||||
- a successful private mac validation run for the same tag/source SHA
|
||||
@@ -56,37 +56,37 @@ Private preflight:
|
||||
|
||||
```bash
|
||||
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
|
||||
-f tag=vYYYY.M.D \
|
||||
-f source_ref=release/YYYY.M.D \
|
||||
-f tag=vYYYY.M.PATCH \
|
||||
-f source_ref=release/YYYY.M.PATCH \
|
||||
-f preflight_only=true \
|
||||
-f smoke_test_only=false \
|
||||
-f allow_late_calver_recovery=false \
|
||||
-f public_release_branch=release/YYYY.M.D
|
||||
-f public_release_branch=release/YYYY.M.PATCH
|
||||
```
|
||||
|
||||
Private validation for a branch-variation preflight:
|
||||
|
||||
```bash
|
||||
gh workflow run openclaw-macos-validate.yml --repo openclaw/releases-private --ref main \
|
||||
-f tag=vYYYY.M.D \
|
||||
-f source_ref=release/YYYY.M.D
|
||||
-f tag=vYYYY.M.PATCH \
|
||||
-f source_ref=release/YYYY.M.PATCH
|
||||
```
|
||||
|
||||
Real publish:
|
||||
|
||||
```bash
|
||||
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
|
||||
-f tag=vYYYY.M.D \
|
||||
-f tag=vYYYY.M.PATCH \
|
||||
-f preflight_only=false \
|
||||
-f smoke_test_only=false \
|
||||
-f preflight_run_id=<successful-preflight-run> \
|
||||
-f validate_run_id=<successful-validation-run> \
|
||||
-f allow_late_calver_recovery=false \
|
||||
-f public_release_branch=release/YYYY.M.D
|
||||
-f public_release_branch=release/YYYY.M.PATCH
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
- `gh release view vYYYY.M.D --repo openclaw/openclaw` shows zip, dmg, dSYM zip, not draft, not prerelease.
|
||||
- Public `main` `appcast.xml` points at `OpenClaw-YYYY.M.D.zip`.
|
||||
- `gh release view vYYYY.M.PATCH --repo openclaw/openclaw` shows zip, dmg, dSYM zip, not draft, not prerelease.
|
||||
- Public `main` `appcast.xml` points at `OpenClaw-YYYY.M.PATCH.zip`.
|
||||
- Appcast entry has `sparkle:version`, `sparkle:shortVersionString`, length, and `sparkle:edSignature`.
|
||||
|
||||
@@ -10,12 +10,15 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
## Respect release guardrails
|
||||
|
||||
- Do not change version numbers without explicit operator approval.
|
||||
- Versions use `YYYY.M.PATCH`, where `PATCH` is the sequential release-train number within the month, not the calendar day.
|
||||
- Choose a new beta train from stable and beta releases only. Alpha-only tags do not consume or advance the beta/stable patch number. Continue the highest existing unpublished/published beta train with the next `beta.N` when appropriate; otherwise increment the highest stable/beta patch by one and start at `beta.1`.
|
||||
- Example: after stable `2026.6.5`, the next new beta train is `2026.6.6-beta.1`, even if automated alpha-only tags such as `2026.6.10-alpha.1` exist.
|
||||
- Ask permission before any npm publish or release step.
|
||||
- This skill should be sufficient to drive the normal release flow end-to-end.
|
||||
- Use the private maintainer release docs for credentials, recovery steps, and mac signing/notary specifics, and use `docs/reference/RELEASING.md` for public policy.
|
||||
- Core `openclaw` publish is manual `workflow_dispatch`; creating or pushing a tag does not publish by itself.
|
||||
- Normal release work happens on a branch cut from `main`, not directly on
|
||||
`main`. Use `release/YYYY.M.D` for the branch name.
|
||||
`main`. Use `release/YYYY.M.PATCH` for the branch name.
|
||||
- If the operator asks for a release without saying stable/full, default to
|
||||
beta only. Continue from beta to stable only when the operator explicitly asks
|
||||
for the full release or an automated beta-and-stable train.
|
||||
@@ -92,7 +95,7 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
## Keep release channel naming aligned
|
||||
|
||||
- `stable`: tagged releases only, published to npm `beta` by default; operators may target npm `latest` explicitly or promote later
|
||||
- `beta`: prerelease tags like `vYYYY.M.D-beta.N`, with npm dist-tag `beta`
|
||||
- `beta`: prerelease tags like `vYYYY.M.PATCH-beta.N`, with npm dist-tag `beta`
|
||||
- Prefer `-beta.N`; do not mint new `-1` or `-2` beta suffixes
|
||||
- `dev`: moving head on `main`
|
||||
- When using a beta Git tag, publish npm with the matching beta version suffix so the plain version is not consumed or blocked
|
||||
@@ -108,7 +111,7 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
- `docs/install/updating.md`
|
||||
- Peekaboo Xcode project and plist version fields
|
||||
- Before creating a release tag, make every version location above match the version encoded by that tag.
|
||||
- For fallback correction tags like `vYYYY.M.D-N`, the repo version locations still stay at `YYYY.M.D`.
|
||||
- For fallback correction tags like `vYYYY.M.PATCH-N`, the repo version locations still stay at `YYYY.M.PATCH`.
|
||||
- “Bump version everywhere” means all version locations above except `appcast.xml`.
|
||||
- Release signing and notary credentials live outside the repo in the private maintainer docs.
|
||||
- Every stable OpenClaw release ships the npm package, macOS app, and signed
|
||||
@@ -129,19 +132,19 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
tagged commit when the delta is mac packaging, signing, workflow, or
|
||||
validation-only release machinery. If mac packaging needs release-branch-only
|
||||
fixes after the stable npm package or GitHub tag is already published, do not
|
||||
create a `vYYYY.M.D-N` correction tag just to change the workflow source.
|
||||
Dispatch the private mac workflows for the original `tag=vYYYY.M.D` with
|
||||
`source_ref=release/YYYY.M.D` and `public_release_branch=release/YYYY.M.D`;
|
||||
create a `vYYYY.M.PATCH-N` correction tag just to change the workflow source.
|
||||
Dispatch the private mac workflows for the original `tag=vYYYY.M.PATCH` with
|
||||
`source_ref=release/YYYY.M.PATCH` and `public_release_branch=release/YYYY.M.PATCH`;
|
||||
provenance checks must prove the source SHA descends from the tag and
|
||||
validation/preflight use the same source. Reserve `vYYYY.M.D-N` correction
|
||||
validation/preflight use the same source. Reserve `vYYYY.M.PATCH-N` correction
|
||||
tags for emergency hotfixes that must publish a new npm package/release
|
||||
identity, not for ordinary mac-only packaging recovery.
|
||||
- The production Sparkle feed lives at `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`, and the canonical published file is `appcast.xml` on `main` in the `openclaw` repo.
|
||||
- That shared production Sparkle feed is stable-only. Beta mac releases may
|
||||
upload assets to the GitHub prerelease, but they must not replace the shared
|
||||
`appcast.xml` unless a separate beta feed exists.
|
||||
- For fallback correction tags like `vYYYY.M.D-N`, the repo version still stays
|
||||
at `YYYY.M.D`, but the mac release must use a strictly higher numeric
|
||||
- For fallback correction tags like `vYYYY.M.PATCH-N`, the repo version still stays
|
||||
at `YYYY.M.PATCH`, but the mac release must use a strictly higher numeric
|
||||
`APP_BUILD` / Sparkle build than the original release so existing installs
|
||||
see it as newer.
|
||||
- Stable Windows Hub release closeout requires the signed
|
||||
@@ -151,7 +154,7 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
workflow after the matching `openclaw/openclaw-windows-node` release exists;
|
||||
it verifies Authenticode signatures on Windows before uploading assets.
|
||||
- Website Windows Hub download links should target exact canonical
|
||||
`openclaw/openclaw/releases/download/vYYYY.M.D/...` assets for the current
|
||||
`openclaw/openclaw/releases/download/vYYYY.M.PATCH/...` assets for the current
|
||||
stable release, or `releases/latest/download/...` only after verifying the
|
||||
redirect resolves to that same tag, so the installable signed Windows artifact
|
||||
is visible from both the GitHub release page and openclaw.ai.
|
||||
@@ -165,7 +168,7 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
beta release tag as the base, then inspect every commit through the target
|
||||
release SHA.
|
||||
- The changelog rewrite is not optional for beta reruns: any `beta.N` after a
|
||||
rebase or backport must refresh the same stable-base `## YYYY.M.D` section
|
||||
rebase or backport must refresh the same stable-base `## YYYY.M.PATCH` section
|
||||
before the new version/tag commit.
|
||||
- Include both merged PR commits and direct commits on `main`. Direct commits
|
||||
matter: infer notes from their subject, body, touched files, linked issues,
|
||||
@@ -188,11 +191,11 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
- Changelog entries should be user-facing, not internal release-process notes.
|
||||
- GitHub release and prerelease bodies must use the full matching
|
||||
`CHANGELOG.md` version section, not highlights or an excerpt. When creating
|
||||
or editing a release, extract from `## YYYY.M.D` through the line before the
|
||||
or editing a release, extract from `## YYYY.M.PATCH` through the line before the
|
||||
next level-2 heading and use that complete block as the release notes.
|
||||
- To update an existing GitHub Release body, resolve the numeric release id and
|
||||
patch that resource with the notes file as the `body` field:
|
||||
`gh api repos/openclaw/openclaw/releases/tags/vYYYY.M.D --jq .id`, then
|
||||
`gh api repos/openclaw/openclaw/releases/tags/vYYYY.M.PATCH --jq .id`, then
|
||||
`gh api -X PATCH repos/openclaw/openclaw/releases/<id> -F body=@/tmp/notes.md`.
|
||||
Do not trust `gh release edit --notes-file` or `--input` JSON if verification
|
||||
disagrees; verify with `gh api repos/openclaw/openclaw/releases/<id>` because
|
||||
@@ -205,10 +208,10 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
record's `docsPath` or `/plugins/compatibility` when no more specific
|
||||
deprecation page exists.
|
||||
- When cutting a mac release with a beta GitHub prerelease:
|
||||
- tag `vYYYY.M.D-beta.N` from the release commit
|
||||
- create a prerelease titled `openclaw YYYY.M.D-beta.N`
|
||||
- tag `vYYYY.M.PATCH-beta.N` from the release commit
|
||||
- create a prerelease titled `openclaw YYYY.M.PATCH-beta.N`
|
||||
- use release notes from the stable base `CHANGELOG.md` version section
|
||||
(`## YYYY.M.D`), not a beta-specific heading
|
||||
(`## YYYY.M.PATCH`), not a beta-specific heading
|
||||
- attach at least the zip and dSYM zip, plus dmg if available
|
||||
- Keep the top version entries in `CHANGELOG.md` sorted by impact:
|
||||
- `### Changes` first
|
||||
@@ -218,10 +221,10 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
|
||||
Use the OpenClaw account's existing release-post style:
|
||||
|
||||
- Format: `OpenClaw YYYY.M.D 🦞` or `🦞 OpenClaw YYYY.M.D is live`, blank line,
|
||||
- Format: `OpenClaw YYYY.M.PATCH 🦞` or `🦞 OpenClaw YYYY.M.PATCH is live`, blank line,
|
||||
then 3-4 emoji-led bullets, blank line, one short punchline, then the release
|
||||
link.
|
||||
- For beta: say `OpenClaw YYYY.M.D-beta.N 🦞` or `OpenClaw YYYY.M.D beta N is
|
||||
- For beta: say `OpenClaw YYYY.M.PATCH-beta.N 🦞` or `OpenClaw YYYY.M.PATCH beta N is
|
||||
live`; keep it clearly beta and avoid implying stable promotion.
|
||||
- Lead with user-visible capabilities, then important integrations, then
|
||||
reliability/security/install fixes. Compress "lots of fixes" into one
|
||||
@@ -332,8 +335,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
```
|
||||
|
||||
- This verifies the published registry install path in a fresh temp prefix.
|
||||
- For stable correction releases like `YYYY.M.D-N`, it also verifies the
|
||||
upgrade path from `YYYY.M.D` to `YYYY.M.D-N` so a correction publish cannot
|
||||
- For stable correction releases like `YYYY.M.PATCH-N`, it also verifies the
|
||||
upgrade path from `YYYY.M.PATCH` to `YYYY.M.PATCH-N` so a correction publish cannot
|
||||
silently leave existing global installs on the old base stable payload.
|
||||
- Treat install smoke as a pack-budget gate too. `pnpm test:install:smoke`
|
||||
now fails the candidate update tarball when npm reports an oversized
|
||||
@@ -480,7 +483,7 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
`npm login --auth-type=legacy`, then confirm `npm whoami` reports
|
||||
`steipete`.
|
||||
- Promote with a fresh OTP:
|
||||
`npm dist-tag add openclaw@YYYY.M.D latest --otp "$OTP"`.
|
||||
`npm dist-tag add openclaw@YYYY.M.PATCH latest --otp "$OTP"`.
|
||||
- Verify with a cache-bypassed registry read, for example:
|
||||
`npm view openclaw dist-tags --json --prefer-online --cache /tmp/openclaw-npm-cache-verify-$$`
|
||||
and `npm view openclaw@latest version dist.tarball --json --prefer-online`.
|
||||
@@ -506,7 +509,7 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
the npm version is already published.
|
||||
- npm validation-only preflight may still be dispatched from ordinary branches
|
||||
when testing workflow changes before merge. Release checks and real publish
|
||||
use only `main` or `release/YYYY.M.D`.
|
||||
use only `main` or `release/YYYY.M.PATCH`.
|
||||
- `.github/workflows/macos-release.yml` in `openclaw/openclaw` is now a
|
||||
public validation-only handoff. It validates the tag/release state and points
|
||||
operators to the private repo. It still rebuilds the JS outputs needed for
|
||||
@@ -531,7 +534,7 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
waives the full gate; mac beta validation is still only required when
|
||||
requested.
|
||||
- Real publish runs may be dispatched from `main` or from a
|
||||
`release/YYYY.M.D` branch. For release-branch runs, the tag must be contained
|
||||
`release/YYYY.M.PATCH` branch. For release-branch runs, the tag must be contained
|
||||
in that release branch, and the real publish must reuse a successful preflight
|
||||
from the same branch.
|
||||
- The release workflows stay tag-based; rely on the documented release sequence
|
||||
@@ -599,8 +602,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
4. Pull latest `main` and confirm current `main` CI is green.
|
||||
5. Run `/changelog` for the stable base target version on `main`, commit the
|
||||
changelog rewrite immediately, push, and pull/rebase. For beta releases,
|
||||
keep the changelog heading as `## YYYY.M.D`, not `## YYYY.M.D-beta.N`.
|
||||
6. Create `release/YYYY.M.D` from that post-changelog `main` commit.
|
||||
keep the changelog heading as `## YYYY.M.PATCH`, not `## YYYY.M.PATCH-beta.N`.
|
||||
6. Create `release/YYYY.M.PATCH` from that post-changelog `main` commit.
|
||||
7. Make every repo version location match the beta tag before creating it.
|
||||
8. Commit release preparation changes on the release branch and push the branch.
|
||||
9. Immediately dispatch Actions > `OpenClaw Performance` from `main` with
|
||||
|
||||
@@ -37,9 +37,11 @@ This is good for auditability if commits are clearly machine-authored and gated
|
||||
- Branch name: `tideclaw/alpha/YYYY-MM-DD-HHMMZ`
|
||||
- Base: current `origin/main` SHA at trigger time.
|
||||
- State file: resolve from `$release-private` on the Tideclaw host.
|
||||
- Release tag: `vYYYY.M.D-alpha.N`
|
||||
- Release tag: `vYYYY.M.PATCH-alpha.N`
|
||||
- npm dist-tag: `alpha`
|
||||
|
||||
`PATCH` is a sequential monthly release-train number, never the calendar day. Determine the alpha train from stable and beta releases; ignore alpha-only patch numbers when choosing the next train. Use one greater than the highest stable/beta patch for the month, then increment only `alpha.N` for repeated nightlies on that train. If a beta exists on that next patch, move alpha to the following train. Legacy alpha-only tags with inflated patch numbers do not advance beta/stable numbering.
|
||||
|
||||
Do not reuse old alpha branches for a new run. If rerunning the same base SHA, create a new timestamped branch and record why.
|
||||
|
||||
## Start
|
||||
@@ -98,7 +100,7 @@ Tideclaw may run beta releases from `#releases` or mentioned `#maintainers` comm
|
||||
Accepted shapes:
|
||||
|
||||
```text
|
||||
@Tideclaw beta release from vYYYY.M.D-alpha.N
|
||||
@Tideclaw beta release from vYYYY.M.PATCH-alpha.N
|
||||
@Tideclaw beta release from tideclaw/alpha/YYYY-MM-DD-HHMMZ
|
||||
@Tideclaw beta release from latest proven alpha
|
||||
```
|
||||
@@ -110,7 +112,7 @@ Rules:
|
||||
3. Verify the source alpha first: GitHub release, npm `alpha` package, release CI, recorded state file, and branch/tag SHA.
|
||||
4. Create a fresh beta branch `tideclaw/beta/YYYY-MM-DD-HHMMZ` from the proven alpha source, not directly from a moving `main`.
|
||||
5. Reuse/squash only stabilization fixes already proven on alpha. Do not import unrelated alpha release mechanics unless the beta release docs require them.
|
||||
6. Compute beta as `vYYYY.M.D-beta.N`, matching npm `--tag beta`.
|
||||
6. Compute beta as `vYYYY.M.PATCH-beta.N`, matching npm `--tag beta`. Ignore alpha-only patch numbers when selecting the beta train.
|
||||
7. Run beta release validation/preflight/full release CI and fix failures on the beta branch.
|
||||
8. Publish beta only after green beta gates. Use GitHub Actions/OIDC, never direct npm publish from the host.
|
||||
9. Final Discord summary must include source alpha, beta tag/version, branch, fix commits, workflow run IDs, npm/GitHub proof, and any skipped/blocked reason.
|
||||
@@ -165,7 +167,7 @@ git push -u origin "$BRANCH"
|
||||
|
||||
After local proof:
|
||||
|
||||
1. Compute the next `vYYYY.M.D-alpha.N` from existing git tags, npm versions, and GitHub releases.
|
||||
1. Compute the next `vYYYY.M.PATCH-alpha.N` from existing git tags, npm versions, and GitHub releases. Select `PATCH` from stable/beta trains, not the date or the highest alpha-only patch. Reuse the same alpha train and increment `alpha.N` until that patch has a beta; after a beta exists, use the following patch for new alpha builds.
|
||||
2. Make the alpha branch package version and release metadata match that tag, commit it, and push the branch.
|
||||
3. Run release validation from the alpha branch, using GitHub CLI, not browser/fetch tools. On the Tideclaw host, bare `gh` is a read-only Codex sandbox wrapper; use `/usr/local/bin/gh-tideclaw-write` for write-capable commands such as `workflow run`, `run cancel`, and publish dispatch:
|
||||
|
||||
|
||||
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
@@ -293,6 +293,10 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/lobster/**"
|
||||
"extensions: llama-cpp":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/llama-cpp/**"
|
||||
"extensions: memory-core":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -2093,7 +2093,7 @@ jobs:
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.android-sdk
|
||||
key: ${{ runner.os }}-android-sdk-v1-cmdline-12266719-platform-36-build-tools-36.0.0
|
||||
key: ${{ runner.os }}-android-sdk-v1-cmdline-14742923-platform-37.0-build-tools-36.0.0
|
||||
restore-keys: |
|
||||
${{ runner.os }}-android-sdk-v1-
|
||||
|
||||
@@ -2101,7 +2101,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ANDROID_SDK_ROOT="$HOME/.android-sdk"
|
||||
CMDLINE_TOOLS_VERSION="12266719"
|
||||
CMDLINE_TOOLS_VERSION="14742923"
|
||||
ARCHIVE="commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip"
|
||||
URL="https://dl.google.com/android/repository/${ARCHIVE}"
|
||||
|
||||
@@ -2123,7 +2123,7 @@ jobs:
|
||||
yes | sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --licenses >/dev/null
|
||||
sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --install \
|
||||
"platform-tools" \
|
||||
"platforms;android-36" \
|
||||
"platforms;android-37.0" \
|
||||
"build-tools;36.0.0"
|
||||
|
||||
- name: Run Android ${{ matrix.task }}
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
java-version: "21"
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: java-kotlin
|
||||
build-mode: manual
|
||||
@@ -46,6 +46,6 @@ jobs:
|
||||
run: ./gradlew --no-daemon :app:assemblePlayDebug
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-security/android"
|
||||
|
||||
60
.github/workflows/codeql-critical-quality.yml
vendored
60
.github/workflows/codeql-critical-quality.yml
vendored
@@ -342,13 +342,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-core-auth-secrets-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/core-auth-secrets"
|
||||
|
||||
@@ -365,13 +365,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-config-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/config-boundary"
|
||||
|
||||
@@ -388,13 +388,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-gateway-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/gateway-runtime-boundary"
|
||||
|
||||
@@ -411,13 +411,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-channel-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/channel-runtime-boundary"
|
||||
|
||||
@@ -460,7 +460,7 @@ jobs:
|
||||
|
||||
- name: Initialize CodeQL
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-network-runtime-boundary-critical-quality.yml
|
||||
@@ -468,7 +468,7 @@ jobs:
|
||||
- name: Analyze
|
||||
id: analyze
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
output: sarif-results
|
||||
category: "/codeql-critical-quality/network-runtime-boundary"
|
||||
@@ -518,13 +518,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-agent-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/agent-runtime-boundary"
|
||||
|
||||
@@ -541,13 +541,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-mcp-process-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/mcp-process-runtime-boundary"
|
||||
|
||||
@@ -564,13 +564,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-memory-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/memory-runtime-boundary"
|
||||
|
||||
@@ -587,13 +587,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-session-diagnostics-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/session-diagnostics-boundary"
|
||||
|
||||
@@ -610,13 +610,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-plugin-sdk-reply-runtime-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/plugin-sdk-reply-runtime"
|
||||
|
||||
@@ -633,13 +633,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-provider-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/provider-runtime-boundary"
|
||||
|
||||
@@ -655,13 +655,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-ui-control-plane-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/ui-control-plane"
|
||||
|
||||
@@ -677,13 +677,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-web-media-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/web-media-runtime-boundary"
|
||||
|
||||
@@ -700,13 +700,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-plugin-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/plugin-boundary"
|
||||
|
||||
@@ -723,12 +723,12 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-plugin-sdk-package-contract-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/plugin-sdk-package-contract"
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
swift --version
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: swift
|
||||
build-mode: manual
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
- name: Analyze
|
||||
id: analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
output: sarif-results
|
||||
upload: failure-only
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload filtered SARIF
|
||||
uses: github/codeql-action/upload-sarif@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
sarif_file: sarif-results-filtered
|
||||
category: "/codeql-critical-security/macos"
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -101,12 +101,12 @@ jobs:
|
||||
.github/codeql
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
config-file: ${{ matrix.config_file }}
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
category: "/codeql-security-high/${{ matrix.category }}"
|
||||
|
||||
65
.github/workflows/docker-release.yml
vendored
65
.github/workflows/docker-release.yml
vendored
@@ -88,11 +88,30 @@ jobs:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Pre-pull BuildKit image
|
||||
shell: bash
|
||||
env:
|
||||
BUILDKIT_IMAGE: moby/buildkit:buildx-stable-1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3 4; do
|
||||
if docker pull "${BUILDKIT_IMAGE}"; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${attempt}" == "4" ]]; then
|
||||
echo "::error::Failed to pull ${BUILDKIT_IMAGE} after ${attempt} attempts"
|
||||
exit 1
|
||||
fi
|
||||
sleep_seconds=$((attempt * 10))
|
||||
echo "BuildKit image pull failed; retrying in ${sleep_seconds}s (${attempt}/4)."
|
||||
sleep "${sleep_seconds}"
|
||||
done
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -279,11 +298,30 @@ jobs:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Pre-pull BuildKit image
|
||||
shell: bash
|
||||
env:
|
||||
BUILDKIT_IMAGE: moby/buildkit:buildx-stable-1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3 4; do
|
||||
if docker pull "${BUILDKIT_IMAGE}"; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${attempt}" == "4" ]]; then
|
||||
echo "::error::Failed to pull ${BUILDKIT_IMAGE} after ${attempt} attempts"
|
||||
exit 1
|
||||
fi
|
||||
sleep_seconds=$((attempt * 10))
|
||||
echo "BuildKit image pull failed; retrying in ${sleep_seconds}s (${attempt}/4)."
|
||||
sleep "${sleep_seconds}"
|
||||
done
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -468,7 +506,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -561,11 +599,30 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Pre-pull BuildKit image
|
||||
shell: bash
|
||||
env:
|
||||
BUILDKIT_IMAGE: moby/buildkit:buildx-stable-1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3 4; do
|
||||
if docker pull "${BUILDKIT_IMAGE}"; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${attempt}" == "4" ]]; then
|
||||
echo "::error::Failed to pull ${BUILDKIT_IMAGE} after ${attempt} attempts"
|
||||
exit 1
|
||||
fi
|
||||
sleep_seconds=$((attempt * 10))
|
||||
echo "BuildKit image pull failed; retrying in ${sleep_seconds}s (${attempt}/4)."
|
||||
sleep "${sleep_seconds}"
|
||||
done
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
|
||||
16
.github/workflows/install-smoke.yml
vendored
16
.github/workflows/install-smoke.yml
vendored
@@ -112,7 +112,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Blacksmith Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
@@ -223,7 +223,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -245,7 +245,7 @@ jobs:
|
||||
|
||||
- name: Set up Blacksmith Docker Builder
|
||||
if: steps.existing.outputs.exists != 'true'
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
@@ -311,7 +311,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -417,7 +417,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -429,7 +429,7 @@ jobs:
|
||||
run: timeout --kill-after=30s 600s docker pull "$IMAGE_REF"
|
||||
|
||||
- name: Set up Blacksmith Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
@@ -503,7 +503,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -542,7 +542,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Blacksmith Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
|
||||
@@ -29,14 +29,14 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up Blacksmith Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
|
||||
2
.github/workflows/mantis-discord-smoke.yml
vendored
2
.github/workflows/mantis-discord-smoke.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const allowed = new Set(["admin", "maintain", "write"]);
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const allowed = new Set(["admin", "maintain", "write"]);
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
steps:
|
||||
- name: Resolve refs and target PR
|
||||
id: resolve
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const defaultBaseline = "0bf06e953fdda290799fc9fb9244a8f67fdae593";
|
||||
@@ -581,7 +581,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove workflow eyes reaction
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const allowed = new Set(["admin", "maintain", "write"]);
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
steps:
|
||||
- name: Resolve refs and target PR
|
||||
id: resolve
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const defaultBaseline = "synthetic-reverted-thread-filepath-fix";
|
||||
@@ -603,7 +603,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove workflow eyes reaction
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const allowed = new Set(["admin", "maintain", "write"]);
|
||||
@@ -180,7 +180,7 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
- name: Cache Mantis candidate pnpm store
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.local/share/pnpm/store
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === "pull_request_target") {
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
steps:
|
||||
- name: Resolve refs and target PR
|
||||
id: resolve
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const eventName = context.eventName;
|
||||
@@ -709,7 +709,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove workflow eyes reaction
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
8
.github/workflows/mantis-telegram-live.yml
vendored
8
.github/workflows/mantis-telegram-live.yml
vendored
@@ -68,7 +68,7 @@ jobs:
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const allowed = new Set(["admin", "maintain", "write"]);
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
steps:
|
||||
- name: Resolve refs and target PR
|
||||
id: resolve
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const eventName = context.eventName;
|
||||
@@ -327,7 +327,7 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
- name: Cache Mantis candidate pnpm store
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.local/share/pnpm/store
|
||||
@@ -573,7 +573,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove workflow eyes reaction
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
2
.github/workflows/npm-telegram-beta-e2e.yml
vendored
2
.github/workflows/npm-telegram-beta-e2e.yml
vendored
@@ -126,7 +126,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Blacksmith Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
|
||||
@@ -1497,37 +1497,72 @@ jobs:
|
||||
|
||||
- name: Setup Docker builder
|
||||
if: steps.image_exists.outputs.needs_build == '1'
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
- name: Build and push bare Docker E2E image
|
||||
if: steps.plan.outputs.needs_bare_image == '1' && steps.image_exists.outputs.bare_exists != '1'
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
with:
|
||||
context: .
|
||||
file: ./scripts/e2e/Dockerfile
|
||||
target: bare
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.image.outputs.bare_image }}
|
||||
sbom: true
|
||||
provenance: mode=max
|
||||
push: true
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_REF: ${{ steps.image.outputs.bare_image }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
build_cmd=(
|
||||
docker buildx build
|
||||
--file ./scripts/e2e/Dockerfile
|
||||
--target bare
|
||||
--platform linux/amd64
|
||||
--tag "$IMAGE_REF"
|
||||
--sbom=true
|
||||
--provenance=mode=max
|
||||
--push
|
||||
.
|
||||
)
|
||||
for attempt in 1 2 3 4; do
|
||||
if "${build_cmd[@]}"; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$attempt" == "4" ]]; then
|
||||
echo "::error::Failed to build Docker E2E bare image after ${attempt} attempts"
|
||||
exit 1
|
||||
fi
|
||||
sleep_seconds=$((attempt * 20))
|
||||
echo "Docker E2E bare image build failed; retrying in ${sleep_seconds}s (${attempt}/4)."
|
||||
sleep "$sleep_seconds"
|
||||
done
|
||||
|
||||
- name: Build and push functional Docker E2E image
|
||||
if: steps.plan.outputs.needs_functional_image == '1' && steps.image_exists.outputs.functional_exists != '1'
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
with:
|
||||
context: .
|
||||
file: ./scripts/e2e/Dockerfile
|
||||
target: functional
|
||||
build-contexts: |
|
||||
openclaw_package=.artifacts/docker-e2e-package
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.image.outputs.functional_image }}
|
||||
sbom: true
|
||||
provenance: mode=max
|
||||
push: true
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_REF: ${{ steps.image.outputs.functional_image }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
build_cmd=(
|
||||
docker buildx build
|
||||
--file ./scripts/e2e/Dockerfile
|
||||
--target functional
|
||||
--build-context openclaw_package=.artifacts/docker-e2e-package
|
||||
--platform linux/amd64
|
||||
--tag "$IMAGE_REF"
|
||||
--sbom=true
|
||||
--provenance=mode=max
|
||||
--push
|
||||
.
|
||||
)
|
||||
for attempt in 1 2 3 4; do
|
||||
if "${build_cmd[@]}"; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$attempt" == "4" ]]; then
|
||||
echo "::error::Failed to build Docker E2E functional image after ${attempt} attempts"
|
||||
exit 1
|
||||
fi
|
||||
sleep_seconds=$((attempt * 20))
|
||||
echo "Docker E2E functional image build failed; retrying in ${sleep_seconds}s (${attempt}/4)."
|
||||
sleep "$sleep_seconds"
|
||||
done
|
||||
|
||||
prepare_live_test_image:
|
||||
needs: validate_selected_ref
|
||||
@@ -1558,8 +1593,11 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
repository="${GITHUB_REPOSITORY,,}"
|
||||
live_image="ghcr.io/${repository}-live-test:${SELECTED_SHA}"
|
||||
live_image_extensions="matrix,acpx"
|
||||
live_image_tag_suffix="${live_image_extensions//,/-}"
|
||||
live_image="ghcr.io/${repository}-live-test:${SELECTED_SHA}-${live_image_tag_suffix}"
|
||||
echo "live_image=${live_image}" >> "$GITHUB_OUTPUT"
|
||||
echo "live_image_extensions=${live_image_extensions}" >> "$GITHUB_OUTPUT"
|
||||
echo "Shared live-test image: \`${live_image}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Log in to GHCR
|
||||
@@ -1582,7 +1620,7 @@ jobs:
|
||||
|
||||
- name: Setup Docker builder
|
||||
if: steps.image_exists.outputs.exists != '1'
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
@@ -1594,7 +1632,7 @@ jobs:
|
||||
file: ./Dockerfile
|
||||
target: build
|
||||
build-args: |
|
||||
OPENCLAW_EXTENSIONS=matrix
|
||||
OPENCLAW_EXTENSIONS=${{ steps.image.outputs.live_image_extensions }}
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.image.outputs.live_image }}
|
||||
sbom: true
|
||||
|
||||
2
.github/workflows/opengrep-precise-full.yml
vendored
2
.github/workflows/opengrep-precise-full.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
scripts/run-opengrep.sh --sarif --error
|
||||
|
||||
- name: Upload SARIF to GitHub Code Scanning
|
||||
uses: github/codeql-action/upload-sarif@v4.36.1
|
||||
uses: github/codeql-action/upload-sarif@v4.36.2
|
||||
# Only upload if the scan actually produced a SARIF file.
|
||||
if: always() && hashFiles('.opengrep-out/precise.sarif') != ''
|
||||
with:
|
||||
|
||||
2
.github/workflows/opengrep-precise.yml
vendored
2
.github/workflows/opengrep-precise.yml
vendored
@@ -84,7 +84,7 @@ jobs:
|
||||
scripts/run-opengrep.sh --changed --sarif --error
|
||||
|
||||
- name: Upload SARIF to GitHub Code Scanning
|
||||
uses: github/codeql-action/upload-sarif@v4.36.1
|
||||
uses: github/codeql-action/upload-sarif@v4.36.2
|
||||
# Only upload if the scan actually produced a SARIF file.
|
||||
if: always() && hashFiles('.opengrep-out/precise.sarif') != ''
|
||||
with:
|
||||
|
||||
151
.github/workflows/plugin-clawhub-release.yml
vendored
151
.github/workflows/plugin-clawhub-release.yml
vendored
@@ -24,6 +24,11 @@ on:
|
||||
description: Approved OpenClaw Release Publish workflow run id
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: Validate the full ClawHub artifact handoff without publishing.
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: plugin-clawhub-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||
@@ -35,7 +40,7 @@ env:
|
||||
CLAWHUB_REGISTRY: "https://clawhub.ai"
|
||||
CLAWHUB_REPOSITORY: "openclaw/clawhub"
|
||||
# Pinned to a reviewed ClawHub commit so release behavior stays reproducible.
|
||||
CLAWHUB_REF: "facf20ceb6cc459e2872d941e71335a784bbc55c"
|
||||
CLAWHUB_REF: "c9bb13023598dcc547fdf4a93b9d42512b8c8854"
|
||||
|
||||
jobs:
|
||||
preview_plugins_clawhub:
|
||||
@@ -56,12 +61,6 @@ jobs:
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
install-bun: "false"
|
||||
|
||||
- name: Resolve checked-out ref
|
||||
id: ref
|
||||
env:
|
||||
@@ -107,6 +106,12 @@ jobs:
|
||||
echo "Plugin ClawHub publishes must target a commit reachable from main, release/*, or the matching Tideclaw alpha branch." >&2
|
||||
exit 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
install-bun: "false"
|
||||
|
||||
- name: Validate publishable plugin metadata
|
||||
env:
|
||||
PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }}
|
||||
@@ -326,15 +331,12 @@ jobs:
|
||||
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
|
||||
run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
|
||||
|
||||
publish_plugins_clawhub:
|
||||
pack_plugins_clawhub_artifacts:
|
||||
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:
|
||||
fail-fast: false
|
||||
max-parallel: 32
|
||||
@@ -407,73 +409,7 @@ jobs:
|
||||
chmod +x "$RUNNER_TEMP/clawhub"
|
||||
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Write ClawHub token config
|
||||
env:
|
||||
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${CLAWHUB_TOKEN}" ]]; then
|
||||
echo "No CLAWHUB_TOKEN secret configured; publish will rely on GitHub OIDC trusted publishing."
|
||||
exit 0
|
||||
fi
|
||||
node --input-type=module <<'EOF'
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const path = join(process.env.RUNNER_TEMP, "clawhub-config.json");
|
||||
writeFileSync(
|
||||
path,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
registry: process.env.CLAWHUB_REGISTRY,
|
||||
token: process.env.CLAWHUB_TOKEN,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
console.log(path);
|
||||
EOF
|
||||
echo "CLAWHUB_CONFIG_PATH=${RUNNER_TEMP}/clawhub-config.json" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check ClawHub package version
|
||||
id: clawhub_package_version
|
||||
env:
|
||||
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
|
||||
PACKAGE_VERSION: ${{ matrix.plugin.version }}
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
encoded_name="$(node -e 'console.log(encodeURIComponent(process.env.PACKAGE_NAME ?? ""))')"
|
||||
encoded_version="$(node -e 'console.log(encodeURIComponent(process.env.PACKAGE_VERSION ?? ""))')"
|
||||
url="${CLAWHUB_REGISTRY%/}/api/v1/packages/${encoded_name}/versions/${encoded_version}"
|
||||
status=""
|
||||
for attempt in $(seq 1 8); do
|
||||
status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' "${url}")"
|
||||
if [[ "${status}" == "404" || "${status}" =~ ^2 ]]; then
|
||||
break
|
||||
fi
|
||||
if [[ "${status}" == "429" || "${status}" =~ ^5 ]]; then
|
||||
echo "ClawHub availability check returned ${status} for ${PACKAGE_NAME}@${PACKAGE_VERSION}; retrying (${attempt}/8)."
|
||||
sleep 60
|
||||
continue
|
||||
fi
|
||||
break
|
||||
done
|
||||
if [[ "${status}" =~ ^2 ]]; then
|
||||
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on ClawHub."
|
||||
echo "already_published=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${status}" != "404" ]]; then
|
||||
echo "Unexpected ClawHub response (${status}) for ${PACKAGE_NAME}@${PACKAGE_VERSION}."
|
||||
exit 1
|
||||
fi
|
||||
echo "already_published=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish
|
||||
if: steps.clawhub_package_version.outputs.already_published != 'true'
|
||||
- name: Pack ClawHub package artifact
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
SOURCE_REPO: ${{ github.repository }}
|
||||
@@ -481,8 +417,65 @@ jobs:
|
||||
SOURCE_REF: ${{ github.ref }}
|
||||
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
|
||||
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
|
||||
run: bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}"
|
||||
OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR: ${{ runner.temp }}/clawhub-package-artifact
|
||||
run: bash scripts/plugin-clawhub-publish.sh --pack "${PACKAGE_DIR}"
|
||||
|
||||
- name: Upload ClawHub package artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.plugin.artifactName }}
|
||||
path: ${{ runner.temp }}/clawhub-package-artifact/*.tgz
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
approve_plugin_clawhub_release:
|
||||
needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts]
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: clawhub-plugin-release
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Approve ClawHub package publish
|
||||
run: echo "ClawHub package publish approved."
|
||||
|
||||
publish_plugins_clawhub:
|
||||
needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts, approve_plugin_clawhub_release]
|
||||
if: always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' && (inputs.dry_run == true || needs.approve_plugin_clawhub_release.result == 'success')
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 32
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
|
||||
uses: openclaw/clawhub/.github/workflows/package-publish.yml@c9bb13023598dcc547fdf4a93b9d42512b8c8854
|
||||
with:
|
||||
dry_run: ${{ inputs.dry_run }}
|
||||
json: true
|
||||
package_artifact_name: ${{ matrix.plugin.artifactName }}
|
||||
registry: https://clawhub.ai
|
||||
site: https://clawhub.ai
|
||||
source_repo: ${{ github.repository }}
|
||||
source_commit: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
|
||||
source_ref: ${{ github.ref }}
|
||||
tags: ${{ matrix.plugin.publishTag }}
|
||||
secrets:
|
||||
clawhub_token: ${{ secrets.CLAWHUB_TOKEN }}
|
||||
|
||||
verify_published_clawhub_package:
|
||||
needs: [preview_plugins_clawhub, publish_plugins_clawhub]
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 32
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Verify published ClawHub package
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
|
||||
34
.github/workflows/qa-live-transports-convex.yml
vendored
34
.github/workflows/qa-live-transports-convex.yml
vendored
@@ -65,7 +65,7 @@ jobs:
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === "schedule") {
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
run_mock_parity:
|
||||
name: Run QA Lab mock parity lane
|
||||
needs: [validate_selected_ref]
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
QA_PARITY_CONCURRENCY: "1"
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
run: pnpm build
|
||||
|
||||
- name: Run OpenAI candidate lane
|
||||
@@ -232,7 +232,7 @@ jobs:
|
||||
name: Run live runtime token-efficiency lane
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
if: github.event_name == 'schedule'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 45
|
||||
environment: qa-live-shared
|
||||
env:
|
||||
@@ -267,7 +267,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
run: pnpm build
|
||||
|
||||
- name: Run live runtime parity lane
|
||||
@@ -321,7 +321,7 @@ jobs:
|
||||
name: Run Matrix live QA lane
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.matrix_profile == 'all') }}
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
environment: qa-live-shared
|
||||
steps:
|
||||
@@ -352,7 +352,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Matrix live lane
|
||||
@@ -397,7 +397,7 @@ jobs:
|
||||
name: Run Matrix live QA lane (${{ matrix.profile }})
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.matrix_profile == 'all' }}
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
environment: qa-live-shared
|
||||
strategy:
|
||||
@@ -437,7 +437,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Matrix live lane shard
|
||||
@@ -480,7 +480,7 @@ jobs:
|
||||
run_live_telegram:
|
||||
name: Run Telegram live QA lane with Convex leases
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
environment: qa-live-shared
|
||||
steps:
|
||||
@@ -520,7 +520,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Telegram live lane
|
||||
@@ -575,7 +575,7 @@ jobs:
|
||||
run_live_discord:
|
||||
name: Run Discord live QA lane with Convex leases
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
environment: qa-live-shared
|
||||
steps:
|
||||
@@ -615,7 +615,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Discord live lane
|
||||
@@ -669,7 +669,7 @@ jobs:
|
||||
run_live_whatsapp:
|
||||
name: Run WhatsApp live QA lane with Convex leases
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
concurrency:
|
||||
group: qa-live-whatsapp-shared
|
||||
@@ -712,7 +712,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
run: pnpm build
|
||||
|
||||
- name: Run WhatsApp live lane
|
||||
@@ -766,7 +766,7 @@ jobs:
|
||||
run_live_slack:
|
||||
name: Run Slack live QA lane with Convex leases
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
environment: qa-live-shared
|
||||
steps:
|
||||
@@ -806,7 +806,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Slack live lane
|
||||
|
||||
95
.github/workflows/stale.yml
vendored
95
.github/workflows/stale.yml
vendored
@@ -509,60 +509,62 @@ jobs:
|
||||
|
||||
let locked = 0;
|
||||
let inspected = 0;
|
||||
let cursor = null;
|
||||
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const { data: issues } = await github.rest.issues.listForRepo({
|
||||
owner,
|
||||
repo,
|
||||
state: "closed",
|
||||
sort: "updated",
|
||||
direction: "desc",
|
||||
per_page: perPage,
|
||||
page,
|
||||
});
|
||||
const result = await github.graphql(
|
||||
`query ClosedIssuesForLocking(
|
||||
$owner: String!
|
||||
$repo: String!
|
||||
$cursor: String
|
||||
$perPage: Int!
|
||||
) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
issues(
|
||||
first: $perPage
|
||||
after: $cursor
|
||||
states: CLOSED
|
||||
orderBy: { field: CREATED_AT, direction: ASC }
|
||||
) {
|
||||
nodes {
|
||||
number
|
||||
locked
|
||||
closedAt
|
||||
comments(last: 1) {
|
||||
nodes {
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
cursor,
|
||||
perPage,
|
||||
},
|
||||
);
|
||||
const issues = result.repository.issues;
|
||||
|
||||
if (issues.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const issue of issues) {
|
||||
if (issue.pull_request) {
|
||||
continue;
|
||||
}
|
||||
if (issue.locked) {
|
||||
continue;
|
||||
}
|
||||
if (!issue.closed_at) {
|
||||
for (const issue of issues.nodes) {
|
||||
if (issue.locked || !issue.closedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
inspected += 1;
|
||||
const closedAtMs = Date.parse(issue.closed_at);
|
||||
if (!Number.isFinite(closedAtMs)) {
|
||||
continue;
|
||||
}
|
||||
if (closedAtMs > cutoffMs) {
|
||||
const closedAtMs = Date.parse(issue.closedAt);
|
||||
if (!Number.isFinite(closedAtMs) || closedAtMs > cutoffMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let lastCommentMs = 0;
|
||||
if (issue.comments > 0) {
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
per_page: 1,
|
||||
page: 1,
|
||||
sort: "created",
|
||||
direction: "desc",
|
||||
});
|
||||
|
||||
if (comments.length > 0) {
|
||||
lastCommentMs = Date.parse(comments[0].created_at);
|
||||
}
|
||||
}
|
||||
|
||||
const lastComment = issue.comments.nodes[0];
|
||||
const lastCommentMs = lastComment ? Date.parse(lastComment.createdAt) : 0;
|
||||
const lastActivityMs = Math.max(closedAtMs, lastCommentMs || 0);
|
||||
if (lastActivityMs > cutoffMs) {
|
||||
continue;
|
||||
@@ -578,7 +580,10 @@ jobs:
|
||||
locked += 1;
|
||||
}
|
||||
|
||||
page += 1;
|
||||
if (!issues.pageInfo.hasNextPage || !issues.pageInfo.endCursor) {
|
||||
break;
|
||||
}
|
||||
cursor = issues.pageInfo.endCursor;
|
||||
}
|
||||
|
||||
core.info(`Inspected ${inspected} closed issues; locked ${locked}.`);
|
||||
|
||||
69
.github/workflows/workflow-sanity.yml
vendored
69
.github/workflows/workflow-sanity.yml
vendored
@@ -34,10 +34,25 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
fetch_checkout_ref() {
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
if [ "$attempt" = "3" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
echo "::warning::checkout fetch for '$CHECKOUT_SHA' timed out on attempt $attempt; retrying"
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
fetch_checkout_ref
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Fail on tabs in workflow files
|
||||
@@ -78,10 +93,25 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
fetch_checkout_ref() {
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
if [ "$attempt" = "3" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
echo "::warning::checkout fetch for '$CHECKOUT_SHA' timed out on attempt $attempt; retrying"
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
fetch_checkout_ref
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Setup Python
|
||||
@@ -190,10 +220,25 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
fetch_checkout_ref() {
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
if [ "$attempt" = "3" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
echo "::warning::checkout fetch for '$CHECKOUT_SHA' timed out on attempt $attempt; retrying"
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
fetch_checkout_ref
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Setup Node environment
|
||||
|
||||
@@ -251,9 +251,11 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Lockfiles/shrinkwrap are security surface: review `pnpm-lock.yaml`, `npm-shrinkwrap.json`, `package-lock.json`; root/plugin npm packages ship shrinkwrap, not package-lock.
|
||||
- Carbon pins owner-only: do not change `@buape/carbon` unless Shadow (`@thewilloftheshadow`, verified by `gh`) asks.
|
||||
- Releases/publish/version bumps need explicit approval. Use `$release-openclaw-maintainer`.
|
||||
- Release versions use `YYYY.M.PATCH`, where `PATCH` is a sequential monthly release-train number, never the calendar day. Stable and beta tags determine the current train; alpha-only tags do not consume or advance the beta/stable patch number. After `2026.6.5`, the next beta train is `2026.6.6-beta.1` even if higher alpha-only tags exist.
|
||||
- Alpha/nightly versions use the next unreleased train plus an incrementing prerelease number. Repeated nightlies for the same train increment only `alpha.N`; they must not mint a new patch number from the date.
|
||||
- Backport means apply to newest open `release/` branch unless user names another target.
|
||||
- GHSA/advisories: `$openclaw-ghsa-maintainer` / `$security-triage`. Secret scanning: `$openclaw-secret-scanning-maintainer`.
|
||||
- Beta tag/version match: `vYYYY.M.D-beta.N` -> npm `YYYY.M.D-beta.N --tag beta`.
|
||||
- Beta tag/version match: `vYYYY.M.PATCH-beta.N` -> npm `YYYY.M.PATCH-beta.N --tag beta`.
|
||||
|
||||
## Platform / Ops
|
||||
|
||||
|
||||
36
CHANGELOG.md
36
CHANGELOG.md
@@ -2,6 +2,39 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.6.6
|
||||
|
||||
### Highlights
|
||||
|
||||
- Security boundaries are substantially tighter across transcripts, sandbox binds, host environment inheritance, MCP stdio, Codex HTTP access, native search policy, elevated sender checks, deleted-agent ACP bypasses, loopback tools, Discord moderation, and Teams group actions; exec approvals now fail closed on timeout. (#91529, #91618, #91615, #91619, #91741, #91745, #91746, #91748, #91749, #91750, #91751, #91752, #91763, #89938) Thanks @joshavant, @pgondhi987, @mmaps, @eleqtrizit, @shakkernerd, and @drobison00.
|
||||
- Telegram delivery is safer and more coherent: account-scoped topics route to the right agent, streamed text survives tool calls, `/compact` works on generic ingress, callback handling uses concrete APIs, draft chunking is shared, durable dispatch dedupe moved into the SDK, and unauthorized DM text stays out of cache and prompt context. (#91189, #88682, #89588, #90212, #91876, #91874, #91904, #91478, #91915) Thanks @codysai001, @alexzhu0, @joelnishanth, @snowzlm, @obviyus, and @sallyom.
|
||||
- iMessage recovery and delivery now cover always-on inbound restart, durable echo markers, block streaming, idle approval discovery, hardened outbound transport, and actionable inbound startup diagnostics. (#91335, #91449, #88969, #88530, #91783, #91785) Thanks @omarshahine, @jmissig, and @colmbrogan.
|
||||
- Browser and MCP connectivity gained existing-session CDP support, discovered WebSocket validation, default-profile `cdpUrl` handling, safer browser-output boundaries, Streamable HTTP loopback transport, corrected OAuth/SSE authorization handling, and broader schema compatibility. (#91422, #89851, #91736, #91747, #91451, #80143) Thanks @pgondhi987, @anagnorisis2peripeteia, @lifuyue, @eleqtrizit, @LiuwqGit, and @HemantSudarshan.
|
||||
- Control UI startup and first-reply latency are lower through cached model metadata, removal of the startup catalog wait, lazy slash-command loading, and first-event tracing with slow-reply diagnostics. (#91531, #91538, #91568, #91583, #91598)
|
||||
- Provider support expands with OpenRouter OAuth onboarding and Claude Fable 5 adaptive thinking, while Codex sessions keep correct compaction ownership, local models skip guardian review, dynamic tool progress normalizes cleanly, and Gemma 4 reasoning replay is preserved. (#91830, #91882, #91590, #88630, #88768, #91696) Thanks @Patrick-Erichsen, @joshavant, @bdjben, and @Coder-Wangyankun.
|
||||
|
||||
### Changes
|
||||
|
||||
- CLI progress: emit Claude CLI commentary progress events and bridge inter-tool commentary into channel progress without exposing internal protocol scaffolding. (#89834, #90883) Thanks @anagnorisis2peripeteia.
|
||||
- Observability: allow trusted diagnostics channels to capture tool input/output content, add first-assistant-event traces, and warn on slow initial replies. (#91256, #91568, #91583) Thanks @amknight.
|
||||
- Plugins/ClawHub: dogfood reusable package publishing, let dry runs skip publish approval, allow declared installed trusted hooks, report managed plugin version drift, and warn instead of failing on retired Skill Workshop configuration. (#91574, #91591, #90004, #90927, #90838) Thanks @Patrick-Erichsen, @brokemac79, and @lonexreb.
|
||||
- Memory/providers: move the local llama.cpp runtime into its provider plugin, batch embeddings across files, persist the agent model catalog cache, and keep QMD JSON search one-shot while filtering stale REM recall previews. (#91324, #89138, #90457, #91837, #91851) Thanks @osolmaz, @mushuiyu886, @ai-hpc, and @TurboTheTurtle.
|
||||
- Channels/mobile: add the QQBot group mention toggle, improve iPad and iPhone control surfaces, and expose the active connection host in the TUI footer. (#91423, #91557, #89909) Thanks @cxyhhhhh, @Solvely-Colin, and @baskduf.
|
||||
- Performance: prewarm TUI runtime plugins, deduplicate plugin auto-enable fanout, trim dense text-delta snapshots, and reuse prepared startup model metadata. (#90782, #89978, #91580, #91531) Thanks @RomneyDa and @ai-hpc.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agent/session recovery: drop stale approval follow-ups after session rebind, remove drained reply-queue items by identity, recover stale main and visible replies, preserve Codex context-engine compaction ownership, lower the default compaction timeout to 180 seconds while respecting explicit configuration, and keep provider-failure terminal lifecycle state correct. (#85679, #91450, #91566, #91840, #91590, #91361, #91895) Thanks @openperf, @yetval, @joshavant, @wangmiao0668000666, and @TurboTheTurtle.
|
||||
- User-visible content boundaries: suppress Codex/Harmony protocol artifacts, neutralize browser and LanceDB memory media directives, redact transcript images, and preserve native `/compact` replies through source suppression. (#89151, #91422, #91425, #91529, #90212) Thanks @joelnishanth, @pgondhi987, @joshavant, and @snowzlm.
|
||||
- Channel delivery: keep WhatsApp captured replies attached to the successor controller after restart, retry Feishu rate limits, preserve Mattermost thread replies, canonicalize LINE webhook paths, restore Discord reply hydration and runtime timeout exports, and show OpenAI Realtime WebRTC assistant transcripts. (#85823, #89659, #91684, #91649, #90263, #91686, #90426) Thanks @itsuzef, @ladygege, @jacobtomlinson, @fuller-stack-dev, and @shushushv.
|
||||
- Cron: cancel active task runs cleanly, preserve terminal timeout/cancel state, and recover no-deliver tool warnings instead of silently losing the outcome. (#90666, #90678) Thanks @ai-hpc.
|
||||
- Gateway/config/auth: share the approval runtime socket token, replace arrays explicitly in `config.patch`, skip the deleted-agent guard only for valid ACP harness sessions, surface headless LaunchAgent state, verify SQLite auth migration before cleanup, and arm QMD startup maintenance. (#87105, #91551, #91219, #91614, #91740, #91978) Thanks @fuller-stack-dev and @scotthuang.
|
||||
- Providers/Codex: clarify quota errors, restore the Codex synthetic usage line, canonicalize Codex protocol assets, require API-key auth for realtime voice, normalize ACP model refs, preserve Gemma 4 `reasoning_content`, and avoid guardian review for local models. (#91390, #91709, #91507, #91567, #88630, #91696) Thanks @hxy91819, @brokemac79, @RomneyDa, @joshavant, and @Coder-Wangyankun.
|
||||
- Updates/builds: recover package Gateway restarts after refresh failure, expose plugin convergence repair, fall back to Corepack in PATH-less pnpm environments, seed the correct Docker store packages, and keep ClawHub dry-run and publish paths reusable. (#91581, #91599, #91547, #91591) Thanks @fuller-stack-dev, @sallyom, and @Patrick-Erichsen.
|
||||
- UI: require explicit user intent before opening chat sessions and drain restored chat queues after session switches. (#91480) Thanks @TurboTheTurtle.
|
||||
- Android: avoid the `dataSync` foreground-service type for persistent nodes. (#80082) Thanks @davelutztx.
|
||||
- Native hooks: bound relay lifetimes so abandoned native hook connections cannot linger indefinitely. (#91550) Thanks @joshavant.
|
||||
|
||||
## 2026.6.5
|
||||
|
||||
### Highlights
|
||||
@@ -27,6 +60,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Docs/tooling: add Parallel search docs, refresh weather-skill guidance toward `web_fetch`, clarify legacy `openai-codex` auth, document release/test helper scripts, and tighten changed-test routing docs for CI/debugging work. (#90028, #90250) Thanks @fuller-stack-dev.
|
||||
- Release/process: switch release trains to `YYYY.M.PATCH` monthly patch numbering, keep pre-transition tags compatible, and pin the June 2026 floor at `2026.6.5` after the published beta.
|
||||
- Platform maintenance: refresh Android, Swift/macOS, Docker, CodeQL, Buildx, Docker build/push, and Codex Action dependencies for this release train. (#74980, #81757, #86481, #86483, #90601)
|
||||
- QQBot: add `/bot-group-allways on|off` slash command (with named-account and default-account support) to toggle whether group messages require an `@mention` before the bot replies, and clear the runtime config snapshot after the write so the new account-level `defaultRequireMention` takes effect immediately without restart. (#91423) Thanks @cxyhhhhh.
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -39,10 +73,12 @@ Docs: https://docs.openclaw.ai
|
||||
- Cron/update/service env: doctor config preflight now migrates legacy cron JSON stores into SQLite before runtime reads, service env planning skips unresolved placeholders that would mask state-dir `.env` values, and session transcript rewrites keep registry markers/discriminants consistent. (#90072, #90208, #90277, #90488) Thanks @MonkeyLeeT and @sallyom.
|
||||
- Security/config/tooling: guard MCP HTTP redirects, protect global agent config defaults, and keep release/test/tooling proof failures bounded and explicit. (#89732, #90145)
|
||||
- Channels: WhatsApp restarts when per-account config changes, bounds background startup waits, closes failed sockets, and preserves reconnect behavior; Mattermost slash commands keep their state on `globalThis`; Feishu streaming cards preserve full merged content; voice-call tracks Twilio streams after connect; ClickClack reply tools respect `toolsAllow`. (#87951, #87965, #90486, #68113, #90534, #90181, #90607, #89500) Thanks @MukundaKatta, @mcaxtr, @infoanton, @mushuiyu886, and @sahibzada-allahyar.
|
||||
- Feishu: retry transient send rate-limit errors (HTTP 429, per-chat code 230020, tenant-level code 11232) with linear backoff, including SDK responses that fulfill with rate-limit bodies instead of throwing, and route streaming-card sends through the retry wrapper. (#89659) Thanks @ladygege.
|
||||
- Release/CI/E2E: main CI guard drift, PR merge diff scoping, live Docker credential staging, base-image qualification, installer Docker classification, Playwright dependency install recovery, API-key auth for Codex live Docker lanes, Parallels option terminators, and JSON-mode progress handling are tighter so release proof fails cleaner. (#90532, #90287, #90058) Thanks @RomneyDa, @hxy91819, and @mrunalp.
|
||||
- Release/CI/E2E: Docker E2E and live Docker harness runs now apply default memory, CPU, and process ceilings while preserving explicit per-lane overrides.
|
||||
- Release/CI/E2E: plugin lifecycle matrix resource sampling now fails phases that exceed RSS, wall-clock, or CPU ceilings instead of only logging the measurements.
|
||||
- Release/CI/E2E: Codex npm plugin live assertions now cap transcript discovery and diagnostic log reads so failure proof stays bounded.
|
||||
- Memory: keep doctor REM harness previews aligned with live REM by dropping short-term recall snippets whose source files disappeared before rendering preview output. Thanks @samzong and @frankekn.
|
||||
- Tests/state isolation: QA Lab valid-tool-call metrics now require runtime tool-call evidence when runtime parity data is available instead of counting tool-backed scenario pass status alone.
|
||||
- Tests/state isolation: QA Lab runtime parity now fails planned-only tool-call rows without matching tool results instead of treating matching mock plans as real tool evidence.
|
||||
- Tests/state isolation: provider, media, auth, cron, task, session, sandbox, Gateway, and Codex timeout fixtures now scope more home/state/env data per test, reducing cross-test leakage and making release validation failures less noisy. (#90027, #89974)
|
||||
|
||||
@@ -48,6 +48,7 @@ These patterns are usually not vulnerabilities by themselves:
|
||||
|
||||
- Prompt injection without a policy, auth, approval, sandbox, or tool-boundary bypass.
|
||||
- A trusted operator using an intentional local feature, such as local shell access or browser/script execution.
|
||||
- A report whose only primitive is changing the process or child-process environment before running OpenClaw or an executable OpenClaw invokes.
|
||||
- A malicious plugin after a trusted operator installs or enables it.
|
||||
- Multiple adversarial users sharing one Gateway host/config and expecting per-user isolation.
|
||||
- Scanner-only, dependency-only, or stale-path reports without a working repro and demonstrated OpenClaw impact.
|
||||
@@ -103,6 +104,7 @@ These are frequently reported but are typically closed with no code change:
|
||||
- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass.
|
||||
- Archive/install extraction claims that require pre-existing local filesystem priming in trusted state (for example planting symlink/hardlink aliases under destination directories such as skills/tools paths) without showing an untrusted path that can create/control that primitive.
|
||||
- Reports that depend on replacing or rewriting an already-approved executable path on a trusted host (same-path inode/content swap) without showing an untrusted path to perform that write.
|
||||
- Reports that depend on attacker-controlled environment variables changing executable behavior, including variables that redirect lookup paths, preload code, select wrappers/interpreters, alter package-manager or runtime hooks, or make one executable call another executable. Control of the process or child-process environment is trusted host/operator control in OpenClaw's model; these reports need a separate OpenClaw boundary bypass that lets untrusted input set or mutate that environment.
|
||||
- Reports that depend on pre-existing symlinked skill/workspace filesystem state (for example symlink chains involving `skills/*/SKILL.md`) without showing an untrusted path that can create/control that state.
|
||||
- Missing HSTS findings on default local/loopback deployments.
|
||||
- Reports against test-only harnesses, QA Lab, QE Lab, E2E fixtures, benchmark rigs, or maintainer-only debugging tools when the vulnerable code is not shipped as a supported production surface.
|
||||
@@ -161,6 +163,7 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
|
||||
- Reports where exploitability depends on attacker-controlled pre-existing symlink/hardlink filesystem state in trusted local paths (for example extraction/install target trees) unless a separate untrusted boundary bypass is shown that creates that state.
|
||||
- Reports whose only claim is sandbox/workspace read expansion through trusted local skill/workspace symlink state (for example `skills/*/SKILL.md` symlink chains) unless a separate untrusted boundary bypass is shown that creates/controls that state.
|
||||
- Reports whose only claim is post-approval executable identity drift on a trusted host via same-path file replacement/rewrite unless a separate untrusted boundary bypass is shown for that host write primitive.
|
||||
- Reports whose only claim is environment-variable-driven executable behavior change, including path lookup changes, preload hooks, wrapper/interpreter selection, package-manager/runtime hooks, or variables that make an executable invoke another executable, unless a separate OpenClaw boundary bypass lets untrusted input set or mutate that environment.
|
||||
- Reports where the only demonstrated impact is an already-authorized sender intentionally invoking a local-action command (for example `/export-session` writing to an absolute host path) without bypassing auth, sandbox, or another documented boundary
|
||||
- Reports whose only claim is use of an explicit trusted-operator control surface (for example `canvas.eval`, browser evaluate/script execution, or direct `node.invoke` execution) without demonstrating an auth, policy, allowlist, approval, or sandbox bypass.
|
||||
- Reports where the only claim is that a trusted-installed/enabled plugin can execute with gateway/host privileges (documented trust model behavior).
|
||||
@@ -181,6 +184,7 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
|
||||
OpenClaw security guidance assumes:
|
||||
|
||||
- The host where OpenClaw runs is within a trusted OS/admin boundary.
|
||||
- Anyone who can set or mutate the OpenClaw process environment, launcher environment, or child-process environment is inside that trusted host/operator boundary.
|
||||
- Anyone who can modify `~/.openclaw` state/config (including `openclaw.json`) is effectively a trusted operator.
|
||||
- A single Gateway shared by mutually untrusted people is **not a recommended setup**. Use separate gateways (or at minimum separate OS users/hosts) per trust boundary.
|
||||
- Authenticated Gateway callers are treated as trusted operators. Session identifiers (for example `sessionKey`) are routing controls, not per-user authorization boundaries.
|
||||
|
||||
127
appcast.xml
127
appcast.xml
@@ -2,6 +2,86 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.6.5</title>
|
||||
<pubDate>Tue, 09 Jun 2026 19:06:49 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2606000590</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.6.5</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.6.5</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>QQBot now strips model reasoning/thinking scaffolding before native delivery, preventing raw <code><thinking></code> content from leaking into channel replies. (#89913, #90132) Thanks @openperf.</li>
|
||||
<li>MCP tool results now coerce <code>resource_link</code>, <code>resource</code>, <code>audio</code>, malformed image, and future non-text/image blocks at the materialize boundary, preventing Anthropic 400s and poisoned session history after a tool returns richer MCP content. (#90710, #90728) Thanks @RanSHammer and @849261680.</li>
|
||||
<li>Anthropic extended-thinking sessions recover after prompt-cache expiry or Gateway restart because stream start events wait for <code>message_start</code>, letting pre-generation signature errors trigger the existing recovery retry. (#90667, #90697) Thanks @openperf.</li>
|
||||
<li>Parallel is now a bundled <code>web_search</code> provider with <code>PARALLEL_API_KEY</code> discovery, guarded endpoint handling, cache-safe session ids, onboarding picker support, and docs. (#85158) Thanks @NormallyGaussian.</li>
|
||||
<li>Google Vertex ADC users get static catalog rows and runtime model resolution again, while single-provider cooldown recovery and memory adapter status checks are more reliable. (#90506, #90609, #90717, #90816) Thanks @849261680.</li>
|
||||
<li>Matrix can preflight voice notes before mention gating, preserve thread reads/replies through Matrix relations pagination, and carry QA coverage for voice and thread flows. (#78016, #90415)</li>
|
||||
<li>Auth and plugin install state is more durable: auth profiles now live in SQLite, official npm plugin install records keep their trusted pins, and prerelease fallback integrity checks avoid carrying stale integrity forward. (#89102, #88585)</li>
|
||||
<li>Agent, tool, and provider loops are stricter around MCP lease timestamps, prompt-cache tool names, local tool catalogs, unreadable dynamic tools, owner-only HTTP tools, and provider catalog metadata, reducing hidden retries and unsafe exposure. (#91124, #91233, #90022, #90261)</li>
|
||||
<li>macOS node mode no longer silently self-reconnects away from a healthy direct Gateway session, reducing unexpected companion app session churn. (#90668, #90815) Thanks @vrurg.</li>
|
||||
<li>Upgrade and service paths are safer: cron legacy JSON stores migrate during doctor preflight, service env placeholders no longer mask state-dir secrets, WhatsApp startup waits are bounded, and disabled WhatsApp accounts tear down on config reload. (#90072, #90208, #90277, #90488, #90486, #87951, #87965) Thanks @MonkeyLeeT, @sallyom, @mcaxtr, and @MukundaKatta.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Search/providers: add the Parallel bundled web-search plugin, live provider tests, registration contracts, onboarding/docs wiring, and guarded <code>api.parallel.ai/v1/search</code> support. (#85158) Thanks @NormallyGaussian.</li>
|
||||
<li>Matrix/channels: add voice-message preflight and thread-aware read/reply behavior, including Matrix QA scenario wiring and docs for voice-message behavior. (#78016, #90415)</li>
|
||||
<li>Skills/ClawHub: install ClawHub skills backed by GitHub repositories through the resolved install API, download the pinned GitHub commit, keep install-policy checks, and report install telemetry after success. (#90478) Thanks @Patrick-Erichsen.</li>
|
||||
<li>Skills/ClawHub: avoid one filesystem watcher per skill file during refresh, keeping large skill trees from exhausting watcher limits.</li>
|
||||
<li>Google Chat/channels: add native approval card actions and click handling so Google Chat approvals use platform-native cards instead of generic message flow.</li>
|
||||
<li>Mobile: Android provider/model screens now surface expiring, unavailable, unresolved, and attention states more clearly, Android adds theme mode selection, and iOS settings and Talk tabs keep diagnostics, gateway rows, attachment labels, fallback copy, and unavailable Talk controls reachable. (#90752, #91201)</li>
|
||||
<li>Memory: QMD search can use the new rerank toggle, and memory adapter status uses the resolved default model identity when checking plain status. (#61834)</li>
|
||||
<li>Docs/tooling: add Parallel search docs, refresh weather-skill guidance toward <code>web_fetch</code>, clarify legacy <code>openai-codex</code> auth, document release/test helper scripts, and tighten changed-test routing docs for CI/debugging work. (#90028, #90250) Thanks @fuller-stack-dev.</li>
|
||||
<li>Release/process: switch release trains to <code>YYYY.M.PATCH</code> monthly patch numbering, keep pre-transition tags compatible, and pin the June 2026 floor at <code>2026.6.5</code>.</li>
|
||||
<li>Release/process: defer the session-metadata SQLite migration from the <code>2026.6.5</code> beta train so this release keeps the existing JSON-backed session metadata path while the migration risk is worked on <code>main</code>.</li>
|
||||
<li>Release metadata: align OpenClaw, publishable plugin manifests, generated shrinkwraps, app version metadata, iOS release notes, Matrix plugin changelog, and generated release baselines with the <code>2026.6.5</code> release train.</li>
|
||||
<li>Platform maintenance: refresh Android, Swift/macOS, Docker, CodeQL, Buildx, Docker build/push, and Codex Action dependencies for this release train. (#74980, #81757, #86481, #86483, #90601)</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Channel content boundaries: QQBot now strips reasoning/thinking tags before sending, preserving final answers while hiding internal model narration from users. (#89913, #90132) Thanks @openperf.</li>
|
||||
<li>Agents/MCP/providers: coerce non-text/image MCP tool-result blocks before they reach provider converters, preserving valid images and turning richer MCP content into text instead of malformed image blocks. (#90710, #90728) Thanks @RanSHammer and @849261680.</li>
|
||||
<li>Anthropic/Codex/ACP/agent recovery: defer Anthropic stream start events until <code>message_start</code>, strip stale compaction thinking signatures before Anthropic replay, detect unsigned thinking-only stalls, refresh prompt fences after compaction writes, reject empty completion handoffs, preserve parent streaming-off overrides/shared progress commentary, forward heartbeat metadata to context-engine hooks, and cover Codex session/thread migration edge cases. (#90667, #90697, #90163, #90108, #89874, #89505, #90632, #89302, #90729, #90317, #90319) Thanks @openperf, @100yenadmin, and @ooiuuii.</li>
|
||||
<li>Agents/Codex/tools: MCP lease release no longer refreshes <code>lastUsedAt</code>, prompt-cache tool names are guarded, lean local tool catalogs stay compact, unreadable dynamic tools are quarantined, orphan tool errors still surface, native subagent completion results survive app-server monitoring, and background-session name derivation avoids regex backtracking risk. (#91124, #90612, #90022, #91235, #91233)</li>
|
||||
<li>Provider/model resolution: preserve Google Vertex ADC auth markers in generated catalogs, re-probe a single-provider primary after cooldown, share Codex model visibility, fail closed for unknown model auth, preserve Codex alias availability, keep unresolved profile refs unknown, and avoid resolving auth while listing models. (#90506, #90609, #90717, #90702) Thanks @849261680.</li>
|
||||
<li>Provider/model resolution: live provider model catalogs keep helper coverage, Ollama catalog metadata is preserved, Google provider prefixes are stripped from Gemini paths, Foundry Responses reasoning replay ids survive, MiniMax M3 thinking stays enabled, Vertex multi-region calls use the right regional host, and OpenRouter streamed generation cost is reconciled. (#91125)</li>
|
||||
<li>Gateway/macOS/mobile: avoid duplicate Gateway probe warnings by identity, rate-limit node pairing requests while preserving paired-node reconnects, keep macOS node mode on a healthy direct Gateway session, keep iOS diagnostics and gateway rows reachable, and avoid Linux ARM Gradle resource tasks during Android builds. (#85791, #90147, #90668, #90815) Thanks @giodl73-repo and @vrurg.</li>
|
||||
<li>Gateway/security/config: owner-only HTTP tools are gated, sandbox skills remain readable in writable sandboxes, legacy agent registry and Codex model metadata migrate safely, and stalled MCP response bodies time out instead of tying up Gateway workers. (#90261)</li>
|
||||
<li>Gateway/config: <code>config.patch</code> now preserves explicit array replacement semantics for arrays without merge keys, so replacement patches do not accidentally merge stale entries. (#91551)</li>
|
||||
<li>SDK: event pump failures now surface to clients instead of being swallowed behind a quiet iterator shutdown.</li>
|
||||
<li>Agents/transcripts: inline image payload redaction now catches data URLs and repaired transcript images before they can leak raw image bytes into stored or exported transcripts. (#91529)</li>
|
||||
<li>Plugins/Gateway: legacy flat Control UI descriptors from shipped JavaScript plugins now normalize <code>name</code> and missing surface fields into session descriptors, restoring Kitchen Sink RPC descriptor proof for package-backed plugin validation.</li>
|
||||
<li>TUI/chat/Workboard/auto-reply: optimistic user messages stay stable across stale history reloads, runId reassignment, and abort windows instead of disappearing, jumping, or lingering as ghost rows; Workboard stale lifecycle bulk updates no longer overwrite newer status/provenance; message-tool sends now count as delivery. (#86205, #89600, #88592, #90123) Thanks @RomneyDa.</li>
|
||||
<li>Cron/update/service env: doctor config preflight now migrates legacy cron JSON stores into SQLite before runtime reads, isolated agent turn payload messages preserve timeout context, service env planning skips unresolved placeholders that would mask state-dir <code>.env</code> values, and session transcript rewrites keep registry markers/discriminants consistent. (#90072, #90208, #91230, #90277, #90488) Thanks @MonkeyLeeT and @sallyom.</li>
|
||||
<li>State/storage: Matrix sync and crypto sidecars, memory-wiki import/source-sync state, sandbox registry state, ACPX process state, device-pair notify state, Zalo hosted media, and plugin SDK dedupe state now use SQLite-owned storage instead of ad hoc runtime files. (#91100, #91108, #91056)</li>
|
||||
<li>Security/config/tooling: guard MCP HTTP redirects, protect global agent config defaults, and keep release/test/tooling proof failures bounded and explicit. (#89732, #90145)</li>
|
||||
<li>Channels: WhatsApp restarts when per-account config changes, bounds background startup waits, closes failed sockets, and preserves reconnect behavior; Mattermost slash commands keep their state on <code>globalThis</code> and default replies stay inside existing Mattermost threads instead of starting new ones; Feishu streaming cards preserve full merged content; iMessage private-API failures and send timeouts explain themselves while split-send coalescing honors balloon metadata; voice-call tracks Twilio streams after connect; ClickClack reply tools respect <code>toolsAllow</code>; Discord runtime adapters stay resolvable; and outbound delivery retries survive budget deferrals. (#87951, #87965, #90486, #68113, #90534, #90181, #90607, #89500, #91041, #90858, #91119, #91241) Thanks @MukundaKatta, @mcaxtr, @infoanton, @mushuiyu886, @sahibzada-allahyar, and @jacobtomlinson.</li>
|
||||
<li>Feishu: retry transient send rate-limit errors (HTTP 429, per-chat code 230020, tenant-level code 11232) with linear backoff, including SDK responses that fulfill with rate-limit bodies instead of throwing, and route streaming-card sends through the retry wrapper. (#89659) Thanks @ladygege.</li>
|
||||
<li>WhatsApp: captured replies after restart now route through the successor controller instead of the stale pre-restart controller. (#85823)</li>
|
||||
<li>Release/CI/E2E: main CI guard drift, PR merge diff scoping, live Docker credential staging, base-image qualification, installer Docker classification, Playwright dependency install recovery, API-key auth for Codex live Docker lanes, Parallels option terminators, and JSON-mode progress handling are tighter so release proof fails cleaner. (#90532, #90287, #90058) Thanks @RomneyDa, @hxy91819, and @mrunalp.</li>
|
||||
<li>Release/CI/E2E: installed-package root dist verification now allows the current package's JavaScript file count while keeping dependency, per-file-size, and scan-bound checks active.</li>
|
||||
<li>Release/CI/E2E: Chutes OAuth model-discovery proof now accepts standard <code>Headers</code> requests, and QR package install smoke caps Docker CPU requests to the hosted runner capacity so beta validation fails on real package regressions.</li>
|
||||
<li>Release/CI/E2E: Docker E2E and live Docker harness runs now apply default memory, CPU, and process ceilings while preserving explicit per-lane overrides.</li>
|
||||
<li>Release/CI/E2E: Docker E2E CPU limits now cap to the runner capacity, keeping package Telegram acceptance on hosted 8-vCPU runners focused on package regressions instead of impossible Docker resource requests.</li>
|
||||
<li>Release/CI/E2E: task maintenance release checks now reset pinned config around isolated temp state dirs, keeping normal CI focused on the active session-store fixture instead of stale process snapshots.</li>
|
||||
<li>Release/CI/E2E: plugin lifecycle matrix resource sampling now fails phases that exceed RSS, wall-clock, or CPU ceilings instead of only logging the measurements.</li>
|
||||
<li>Release/CI/E2E: Codex npm plugin live assertions now cap transcript discovery and diagnostic log reads so failure proof stays bounded.</li>
|
||||
<li>Release/CI/E2E: browser snapshot, release-scenario, release-user-journey, Telegram desktop/RTT/package, web-search, Parallels update, plugin update, doctor switch, and upgrade-survivor diagnostics now stream or bound log/artifact reads so failed proof stays inspectable without unbounded output.</li>
|
||||
<li>Release/CI/E2E: Parallels smoke validation now runs without requiring <code>pnpm</code> on the host, supports already-started Windows/Linux guests without snapshots, reports empty snapshot metadata clearly, and finds portable user-local Node on Windows.</li>
|
||||
<li>Release/CI/E2E: ClawHub publish jobs prepare dependencies after checking out the target ref, and Docker store seed package discovery now targets the intended production packages. (#91547)</li>
|
||||
<li>Release/CI/E2E: QA Lab capability-flip release validation now marks intentional <code>tools.deny</code> restores as array replacements, so beta validation fails only on real capability regressions.</li>
|
||||
<li>Tests/state isolation: QA Lab valid-tool-call metrics now require runtime tool-call evidence when runtime parity data is available instead of counting tool-backed scenario pass status alone.</li>
|
||||
<li>Tests/state isolation: QA Lab runtime parity now fails planned-only tool-call rows without matching tool results instead of treating matching mock plans as real tool evidence.</li>
|
||||
<li>Tests/state isolation: QA Lab runtime parity now treats matching controlled tool errors as equivalent and falls back to transcript tool results when mock debug rows miss async image-generation starts.</li>
|
||||
<li>Tests/state isolation: QA suites now fail closed on skipped summaries, missing runtime tool proof, planned-only rows, loose release limits, missing live/provider artifacts, failed agent reply markers, and package Telegram summary failures.</li>
|
||||
<li>Tests/state isolation: provider, media, auth, cron, task, session, sandbox, Gateway, and Codex timeout fixtures now scope more home/state/env data per test, reducing cross-test leakage and making release validation failures less noisy. (#90027, #89974)</li>
|
||||
<li>Sessions: the beta SQLite downgrade rescue now skips extra pre-reads for active non-empty JSON session stores, preserving cache race detection while still restoring missing or empty beta session files.</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.6.5/OpenClaw-2026.6.5.zip" length="55725877" type="application/octet-stream" sparkle:edSignature="EKr7gCfpEVStis9HSADJk1CWYbmH2MHMqSgNfZvLbBFCBWmk3pjBJS6K2qkxkq5lIbTj4H+Lo7Iri6ip/xTGDA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.6.1</title>
|
||||
<pubDate>Wed, 03 Jun 2026 21:26:22 +0000</pubDate>
|
||||
@@ -193,52 +273,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.28/OpenClaw-2026.5.28.zip" length="54750142" type="application/octet-stream" sparkle:edSignature="U4O55uMdPU+OqSx9QR1ApUJ8wg65wxTydzD7iyCn1GHtm1MBK9noEeiA/yoUKkqb/bx0hzi1gNhn+ye19RXnCA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.5.27</title>
|
||||
<pubDate>Thu, 28 May 2026 12:12:19 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026052790</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.5.27</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.5.27</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Stronger security and content boundaries: group prompt text is kept out of the system prompt, repeated-dot hostnames are normalized, side-effecting command wrappers and unsafe Node runtime env overrides are blocked, no-auth Tailscale exposure is rejected, and node/device-role approvals now require admin authority. (#87144, #87305, #87292, #87308, #87146) Thanks @eleqtrizit and @pgondhi987.</li>
|
||||
<li>More reliable Codex app-server runs: Codex runtime models resolve first, workspace memory is routed through tools, shared app-server clients survive startup and spawned-helper failures, native hook relay generations survive restarts and rotate on fresh fallbacks, and false runtime live switches are avoided. (#87383, #87403, #87375, #72574, #87428) Thanks @yetval.</li>
|
||||
<li>Faster Gateway and reply paths: session reads, plugin metadata fingerprints, auth env snapshots, auto-enabled plugin config, tool-search catalogs, and stable metadata caches do less hot-path rediscovery while visible replies no longer inherit hidden cleanup timeouts. (#86439, #87044) Thanks @keshavbotagent.</li>
|
||||
<li>Better provider and model coverage: OpenAI-compatible embedding providers are core, DeepInfra catalog browsing loads the full credential-aware model set, Pixverse adds video generation and API region selection, VLLM thinking params are wired, Claude CLI OAuth overlays load for PI auth profiles, and bare direct Anthropic model ids work. (#85269, #84549, #87167) Thanks @dutifulbob, @ats3v, and @joshavant.</li>
|
||||
<li>Channel delivery is steadier: Telegram <code>sendMessage</code> actions use durable outbound delivery, iMessage suppresses duplicate native exec approval prompts and sends, Slack keeps delivered final replies during late cleanup, Matrix mention previews/finals are stricter, QQBot fallback approval buttons honor slash-command auth, Discord guild requester checks are tighter, recovered Discord tool-warning artifacts stay out of successful replies, and Google Chat stops thread sends in DMs. (#87261, #87154) Thanks @mbelinky and @eleqtrizit.</li>
|
||||
<li>Release, package, and CI proof paths are harder to wedge: npm/package inventory honors dist exclusions, shrinkwrap override pins merge correctly, Docker runtime workspace templates are packaged and smoked, release postpublish checks are stricter, beta smoke rejects empty runs, and E2E log/probe waits are bounded.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Memory: add a core OpenAI-compatible embedding provider for local and hosted OpenAI-style endpoints, with config, doctor, and docs support. (#85269) Thanks @dutifulbob.</li>
|
||||
<li>Plugin SDK: mark memory-specific embedding provider registration as deprecated compatibility and surface non-bundled usage in plugin compatibility diagnostics. (#85072) Thanks @mbelinky.</li>
|
||||
<li>Providers: add the Pixverse video generation provider, API region selection, docs, and external plugin packaging support.</li>
|
||||
<li>DeepInfra: load the full model catalog when users browse models during onboarding, preserve configured API-key catalogs, refresh media/video defaults, and keep pricing/default model metadata aligned. (#84549) Thanks @ats3v.</li>
|
||||
<li>Plugin SDK: expose plugin approval action metadata and stop exporting Vitest test helpers from the public SDK surface. (#87120) Thanks @RomneyDa.</li>
|
||||
<li>Channel SDK: move channel message compatibility into core, remove old channel turn runtime aliases, and preserve runtime catalog markdown metadata for plugins.</li>
|
||||
<li>ClawHub: add plugin display metadata so catalog/package listings use cleaner names. (#87354) Thanks @thewilloftheshadow.</li>
|
||||
<li>Agents: split the heartbeat runtime template out of docs assets and add compatibility repair for legacy heartbeat template content. (#85416) Thanks @hxy91819.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Security/content boundaries: route untrusted group prompt metadata outside system prompts, normalize repeated trailing hostname dots, block side-effecting command wrappers, reject unsafe Node runtime env overrides, reject no-auth Tailscale exposure, block untrusted Microsoft Teams service URLs, enforce <code>/allowlist configWrites</code> origin policy, gate QQBot fallback approval buttons, and require admin for node/device-role approvals. (#87144, #87305, #87292, #87308, #87146, #87154, #87334) Thanks @eleqtrizit and @pgondhi987.</li>
|
||||
<li>Codex: resolve Codex runtime models before generic routing, route workspace memory through tools, preserve shared app-server clients after startup and spawned-helper failures, preserve native hook relay generations across restarts and fresh fallbacks, keep raw reasoning/source-reply guards intact, report quarantined dynamic tools, keep the attempt watchdog armed for queued terminal turns, and route Codex OAuth compaction through OpenAI-Codex. (#87383, #87403, #87375, #72574, #87428) Thanks @yetval.</li>
|
||||
<li>Agents/runtime: avoid session event queue self-waits, bound compaction wake and steering retries, preserve grace for pending error diagnostics, avoid false Codex runtime live switches, avoid stale restart continuation reuse, preserve session fallback errors, suppress duplicate Claude CLI skill prompts, keep runtime context before active user turns, strip stale Anthropic thinking, quarantine unsupported tool schemas, recover completed write timeouts safely, release retained session write locks on timeout abort, and validate forced plugin harness support before pinning. (#86123, #55424, #86855, #74341, #87278) Thanks @luoyanglang, @cathrynlavery, and @openperf.</li>
|
||||
<li>Reply/session delivery: keep visible turn admission unbounded, keep visible fallback delivery on latest targets, preserve bridge hook context, classify direct fallback targets by channel grammar, report approval resolutions in bridge mode, and avoid stale source-reply artifacts. (#87044) Thanks @keshavbotagent.</li>
|
||||
<li>Channels: make Telegram <code>sendMessage</code> action replies durable and preserve SecretRef prompt config, suppress duplicate iMessage native exec approval prompts and sends, keep iMessage approval polling alive after denied reactions, keep Slack delivered final replies during late cleanup, keep Matrix mention previews/finals mention-inert and normally delivered, ignore filename-embedded Matrix IDs, suppress recovered Discord tool-warning artifacts from successful replies, suppress Google Chat thread sends in DMs, and harden Discord guild requester checks. (#87261, #87452) Thanks @mbelinky.</li>
|
||||
<li>Memory: salvage QMD search JSON after nonzero exits and keep workspace memory routing through the Codex tool path where possible. (#87225, #87383, #87403) Thanks @osolmaz.</li>
|
||||
<li>Providers/models: forward cached token usage in OpenAI-compatible chat completions, load Claude CLI OAuth overlays for PI auth profiles, send bare direct Anthropic model ids, wire configured VLLM thinking params, honor OpenAI-compatible cache retention, normalize OpenAI Responses replay tool ids, resolve OpenAI <code>gpt-5.5</code> without a cached catalog, preserve <code>retry-after</code> fallback handling, bound GitHub Copilot auth requests, and load DeepInfra custom/live catalogs consistently. (#82062, #87167, #84549) Thanks @caz0075, @joshavant, and @ats3v.</li>
|
||||
<li>Gateway/performance: borrow read-only session metadata and active session working stores, cache current/stable plugin metadata fingerprints, cache auto-enabled plugin config, slim metadata identity caches, trust current metadata lifecycle caches, stabilize isolated cron prompt-cache affinity, persist model auth profile suffixes, drain probe client closes, expire browser tokens after auth rotation, and keep default status fast paths bounded. Thanks @ferminquant.</li>
|
||||
<li>CLI/help/config: reject loose or malformed numeric options for gateway timeouts, model limits, directory limits, message options, webhooks, and partial values; respect subcommand version options; route generated/root/plugin help targets correctly; keep skills JSON output flushing naturally; and keep plugin descriptor loading quiet in root help. (#87398) Thanks @Patrick-Erichsen.</li>
|
||||
<li>Plugin state/tool search: evict the current namespace when plugin rows hit caps, reuse unchanged tool-search catalogs, align the release catalog reuse wrapper, and keep fallback tool warnings mention-inert.</li>
|
||||
<li>Install/package/release: match npm globstar exclusions, honor dist package exclusions in inventory, omit unpacked test helpers, skip Homebrew until macOS packages need it, package Docker runtime workspace templates, smoke Docker runtime templates during full validation, merge nested shrinkwrap override pins, preserve forked shrinkwrap pins, pin aged <code>lru-cache</code>, harden postpublish verification, accept main full-validation proof, and reject empty beta smoke runs.</li>
|
||||
<li>E2E/QA/Crabbox: bound Telegram, Open WebUI, ClawHub, Matrix, Tool Search, MCP, gateway network, bundled runtime, kitchen-sink, codex media, config reload, and agent-turn assertion waits; prefer Azure for Windows targets; reinitialize invalid changed-gate git dirs; full-sync sparse container runs; and fail empty explicit test requests. (#87186)</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.27/OpenClaw-2026.5.27.zip" length="54488811" type="application/octet-stream" sparkle:edSignature="c5w2T1UO6vpPs70hyYH93cIyWEOd5sl5z2NkhU53E+XQBSd+jAr+xd0qf3KzWbeX2mfXYMQmnx+VMls3L22EDg=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -41,7 +41,7 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "ai.openclaw.app"
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
// Release signing is local-only; keep the keystore path and passwords out of the repo.
|
||||
signingConfigs {
|
||||
|
||||
@@ -49,6 +49,19 @@ import java.util.concurrent.Executors
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
private fun createDnsResolver(context: Context): DnsResolver =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
|
||||
createContextDnsResolver(context)
|
||||
} else {
|
||||
createLegacyDnsResolver()
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.CINNAMON_BUN)
|
||||
private fun createContextDnsResolver(context: Context): DnsResolver = DnsResolver(context, null)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun createLegacyDnsResolver(): DnsResolver = DnsResolver.getInstance()
|
||||
|
||||
/**
|
||||
* Watches local DNS-SD and optional wide-area DNS-SD for reachable OpenClaw gateways.
|
||||
*/
|
||||
@@ -58,7 +71,7 @@ class GatewayDiscovery(
|
||||
) {
|
||||
private val nsd = context.getSystemService(NsdManager::class.java)
|
||||
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||
private val dns = DnsResolver.getInstance()
|
||||
private val dns = createDnsResolver(context)
|
||||
private val serviceType = "_openclaw-gw._tcp."
|
||||
private val wideAreaDomain = System.getenv("OPENCLAW_WIDE_AREA_DOMAIN")
|
||||
private val logTag = "OpenClaw/GatewayDiscovery"
|
||||
|
||||
@@ -5,7 +5,7 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "ai.openclaw.app.benchmark"
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 31
|
||||
|
||||
@@ -4,7 +4,7 @@ androidx-activity = "1.13.0"
|
||||
androidx-benchmark = "1.4.1"
|
||||
androidx-camera = "1.6.0"
|
||||
androidx-compose-bom = "2026.05.01"
|
||||
androidx-core = "1.18.0"
|
||||
androidx-core = "1.19.0"
|
||||
androidx-exifinterface = "1.4.2"
|
||||
androidx-lifecycle = "2.10.0"
|
||||
androidx-security = "1.1.0"
|
||||
@@ -19,7 +19,7 @@ junit = "4.13.2"
|
||||
junit-vintage = "6.1.0"
|
||||
kotest = "6.1.11"
|
||||
ktlint-gradle = "14.2.0"
|
||||
kotlin = "2.3.21"
|
||||
kotlin = "2.4.0"
|
||||
material = "1.14.0"
|
||||
okhttp = "5.3.2"
|
||||
play-services-code-scanner = "16.1.0"
|
||||
|
||||
BIN
apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/152.png
Normal file
BIN
apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/152.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
BIN
apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/167.png
Normal file
BIN
apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/167.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
BIN
apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/20.png
Normal file
BIN
apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/20.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
BIN
apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/76.png
Normal file
BIN
apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/76.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
File diff suppressed because one or more lines are too long
@@ -4,6 +4,7 @@ import SwiftUI
|
||||
|
||||
struct AgentProDreamingDestination: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let overview: AgentOverviewSnapshot?
|
||||
let gatewayConnected: Bool
|
||||
let overviewLoading: Bool
|
||||
@@ -20,6 +21,7 @@ struct AgentProDreamingDestination: View {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.header
|
||||
self.detailSummaryCard(
|
||||
icon: "moon",
|
||||
title: "Dreaming",
|
||||
@@ -57,6 +59,23 @@ struct AgentProDreamingDestination: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
if let headerLeadingAction {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: "Dreaming",
|
||||
subtitle: self.dreamingDetail,
|
||||
titleFont: .title3.weight(.semibold),
|
||||
subtitleFont: .callout)
|
||||
{
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
} accessory: {
|
||||
EmptyView()
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private enum DreamAction: String, CaseIterable, Identifiable {
|
||||
case backfill
|
||||
case repair
|
||||
|
||||
@@ -329,6 +329,13 @@ struct AgentConfigLite: Decodable {
|
||||
struct ConfigPatchParams: Encodable {
|
||||
let raw: String
|
||||
let baseHash: String
|
||||
let replacePaths: [String]?
|
||||
|
||||
init(raw: String, baseHash: String, replacePaths: [String]? = nil) {
|
||||
self.raw = raw
|
||||
self.baseHash = baseHash
|
||||
self.replacePaths = replacePaths
|
||||
}
|
||||
}
|
||||
|
||||
enum SkillMutationError: LocalizedError {
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct AgentProNodesDestination: View {
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let overview: AgentOverviewSnapshot?
|
||||
let gatewayConnected: Bool
|
||||
let agentCount: Int
|
||||
@@ -16,6 +17,7 @@ struct AgentProNodesDestination: View {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.header
|
||||
self.summaryCard
|
||||
self.totalsCard
|
||||
self.nodesList
|
||||
@@ -27,16 +29,33 @@ struct AgentProNodesDestination: View {
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Nodes")
|
||||
.navigationTitle("Instances")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
if let headerLeadingAction {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: "Instances",
|
||||
subtitle: self.instancesDetail,
|
||||
titleFont: .title3.weight(.semibold),
|
||||
subtitleFont: .callout)
|
||||
{
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
} accessory: {
|
||||
EmptyView()
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private var summaryCard: some View {
|
||||
ProCard {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: "display", color: self.instancesColor)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Nodes")
|
||||
Text("Instances")
|
||||
.font(.headline)
|
||||
Text(self.instancesDetail)
|
||||
.font(.caption)
|
||||
@@ -70,16 +89,16 @@ struct AgentProNodesDestination: View {
|
||||
|
||||
private var nodesList: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Connected Nodes")
|
||||
ProSectionHeader(title: "Connected Instances")
|
||||
ProCard(padding: 0) {
|
||||
let nodes = self.sortedPresenceEntries
|
||||
if nodes.isEmpty {
|
||||
self.emptyRow(
|
||||
icon: "display",
|
||||
title: self.gatewayConnected ? "No nodes connected" : "Nodes unavailable",
|
||||
title: self.gatewayConnected ? "No instances connected" : "Instances unavailable",
|
||||
detail: self.gatewayConnected
|
||||
? "The gateway did not report any system presence entries."
|
||||
: "Connect a gateway to inspect connected nodes.")
|
||||
: "Connect a gateway to inspect connected instances.")
|
||||
.padding(14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
@@ -114,7 +133,7 @@ struct AgentProNodesDestination: View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: Self.presenceIcon(entry), color: Self.presenceColor(entry))
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(Self.presenceLabel(entry) ?? "Node")
|
||||
Text(Self.presenceLabel(entry) ?? "Instance")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(Self.presenceDetail(entry))
|
||||
@@ -153,7 +172,7 @@ struct AgentProNodesDestination: View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: Self.presenceIcon(entry), color: Self.presenceColor(entry))
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(Self.presenceLabel(entry) ?? "Node")
|
||||
Text(Self.presenceLabel(entry) ?? "Instance")
|
||||
.font(.headline)
|
||||
Text(Self.presenceDetail(entry))
|
||||
.font(.caption)
|
||||
@@ -192,7 +211,7 @@ struct AgentProNodesDestination: View {
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle(Self.presenceLabel(entry) ?? "Node")
|
||||
.navigationTitle(Self.presenceLabel(entry) ?? "Instance")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@ extension AgentProTab {
|
||||
@ViewBuilder
|
||||
func destination(for route: AgentRoute) -> some View {
|
||||
switch route {
|
||||
case .agents:
|
||||
self.agentsDestination
|
||||
case .skills:
|
||||
self.skillsDestination
|
||||
case .nodes:
|
||||
self.nodesDestination
|
||||
case .instances:
|
||||
self.instancesDestination
|
||||
case .cron:
|
||||
self.cronDestination
|
||||
case .usage:
|
||||
@@ -19,6 +21,26 @@ extension AgentProTab {
|
||||
}
|
||||
}
|
||||
|
||||
var agentsDestination: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.rosterHeader
|
||||
self.agentFilters
|
||||
self.agentsSection
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.refreshable {
|
||||
await self.refreshOverview(force: true)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Agents")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
var skillsDestination: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
@@ -46,8 +68,9 @@ extension AgentProTab {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
var nodesDestination: some View {
|
||||
var instancesDestination: some View {
|
||||
AgentProNodesDestination(
|
||||
headerLeadingAction: self.directHeaderLeadingAction(for: .instances),
|
||||
overview: self.overview,
|
||||
gatewayConnected: self.gatewayConnected,
|
||||
agentCount: self.appModel.gatewayAgents.count,
|
||||
@@ -64,6 +87,10 @@ extension AgentProTab {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.directHeader(
|
||||
for: .cron,
|
||||
title: "Cron Jobs",
|
||||
subtitle: self.cronDetail)
|
||||
self.detailSummaryCard(
|
||||
icon: "clock.arrow.circlepath",
|
||||
title: "Cron Jobs",
|
||||
@@ -89,6 +116,10 @@ extension AgentProTab {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.directHeader(
|
||||
for: .usage,
|
||||
title: "Usage",
|
||||
subtitle: self.usageDetail)
|
||||
self.detailSummaryCard(
|
||||
icon: "chart.line.uptrend.xyaxis",
|
||||
title: "Usage",
|
||||
@@ -111,6 +142,7 @@ extension AgentProTab {
|
||||
|
||||
var dreamingDestination: some View {
|
||||
AgentProDreamingDestination(
|
||||
headerLeadingAction: self.directHeaderLeadingAction(for: .dreaming),
|
||||
overview: self.overview,
|
||||
gatewayConnected: self.gatewayConnected,
|
||||
overviewLoading: self.overviewLoading,
|
||||
@@ -122,6 +154,27 @@ extension AgentProTab {
|
||||
})
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func directHeader(for route: AgentRoute, title: String, subtitle: String) -> some View {
|
||||
if let headerLeadingAction = self.directHeaderLeadingAction(for: route) {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
titleFont: .title3.weight(.semibold),
|
||||
subtitleFont: .callout)
|
||||
{
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
} accessory: {
|
||||
EmptyView()
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
func directHeaderLeadingAction(for route: AgentRoute) -> OpenClawSidebarHeaderAction? {
|
||||
self.directRoute == route ? self.headerLeadingAction : nil
|
||||
}
|
||||
|
||||
func detailSummaryCard(
|
||||
icon: String,
|
||||
title: String,
|
||||
|
||||
@@ -5,18 +5,19 @@ import SwiftUI
|
||||
extension AgentProTab {
|
||||
var rosterHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Agents")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
Text("\(self.sortedAgents.count) total")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: self.headerTitle,
|
||||
subtitle: "\(self.sortedAgents.count) total",
|
||||
titleFont: .system(size: 28, weight: .bold),
|
||||
subtitleFont: .subheadline,
|
||||
subtitleLineLimit: 1)
|
||||
{
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
} accessory: {
|
||||
HStack(spacing: 10) {
|
||||
self.gatewayPillButton
|
||||
self.headerIconButton(
|
||||
systemName: "magnifyingglass",
|
||||
label: "Search agents",
|
||||
@@ -56,6 +57,19 @@ extension AgentProTab {
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var gatewayPillButton: some View {
|
||||
if let openSettings {
|
||||
Button(action: openSettings) {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
}
|
||||
|
||||
var agentFilters: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
@@ -140,7 +154,7 @@ extension AgentProTab {
|
||||
value: self.instancesValue,
|
||||
detail: self.instancesDetail,
|
||||
color: self.instancesColor,
|
||||
route: .nodes)
|
||||
route: .instances)
|
||||
self.metricTile(
|
||||
icon: "clock.arrow.circlepath",
|
||||
title: "Cron",
|
||||
|
||||
@@ -621,7 +621,10 @@ extension AgentProTab {
|
||||
}
|
||||
|
||||
let raw = try Self.agentSkillsPatchRaw(agentId: self.activeAgentID, skills: skills)
|
||||
let params = ConfigPatchParams(raw: raw, baseHash: baseHash)
|
||||
let params = ConfigPatchParams(
|
||||
raw: raw,
|
||||
baseHash: baseHash,
|
||||
replacePaths: ["agents.list[].skills"])
|
||||
let data = try JSONEncoder().encode(params)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
throw SkillMutationError.invalidPatchPayload
|
||||
|
||||
@@ -6,6 +6,12 @@ struct AgentProTab: View {
|
||||
@Environment(NodeAppModel.self) var appModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
let initialRoute: AgentRoute?
|
||||
let directRoute: AgentRoute?
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let headerTitle: String
|
||||
let openSettings: (() -> Void)?
|
||||
@State var navigationPath: [AgentRoute] = []
|
||||
@State var overview: AgentOverviewSnapshot?
|
||||
@State var overviewErrorText: String?
|
||||
@State var overviewLoading: Bool = false
|
||||
@@ -31,8 +37,9 @@ struct AgentProTab: View {
|
||||
@State var cronActionStatusText: String?
|
||||
|
||||
enum AgentRoute: Hashable {
|
||||
case agents
|
||||
case skills
|
||||
case nodes
|
||||
case instances
|
||||
case cron
|
||||
case usage
|
||||
case dreaming
|
||||
@@ -119,8 +126,42 @@ struct AgentProTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
initialRoute: AgentRoute? = nil,
|
||||
directRoute: AgentRoute? = nil,
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
headerTitle: String = "Agents",
|
||||
openSettings: (() -> Void)? = nil)
|
||||
{
|
||||
self.initialRoute = initialRoute
|
||||
self.directRoute = directRoute
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.headerTitle = headerTitle
|
||||
self.openSettings = openSettings
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if let directRoute {
|
||||
self.directDestination(for: directRoute)
|
||||
} else {
|
||||
self.overviewNavigation
|
||||
}
|
||||
}
|
||||
.task(id: self.overviewTaskID) {
|
||||
await self.refreshOverview(force: false)
|
||||
}
|
||||
.sheet(item: self.$skillEditorSelection) { selection in
|
||||
if let skill = self.skillByKey(selection.id) {
|
||||
self.skillEditorSheet(skill)
|
||||
} else {
|
||||
self.missingSkillEditorSheet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var overviewNavigation: some View {
|
||||
NavigationStack(path: self.$navigationPath) {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
@@ -143,15 +184,22 @@ struct AgentProTab: View {
|
||||
self.destination(for: route)
|
||||
}
|
||||
}
|
||||
.task(id: self.overviewTaskID) {
|
||||
await self.refreshOverview(force: false)
|
||||
}
|
||||
.sheet(item: self.$skillEditorSelection) { selection in
|
||||
if let skill = self.skillByKey(selection.id) {
|
||||
self.skillEditorSheet(skill)
|
||||
} else {
|
||||
self.missingSkillEditorSheet
|
||||
}
|
||||
.onAppear {
|
||||
self.applyInitialRouteIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func directDestination(for route: AgentRoute) -> some View {
|
||||
self.destination(for: route)
|
||||
.toolbar(
|
||||
self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden,
|
||||
for: .navigationBar)
|
||||
}
|
||||
|
||||
private func applyInitialRouteIfNeeded() {
|
||||
guard self.directRoute == nil else { return }
|
||||
guard let initialRoute else { return }
|
||||
guard self.navigationPath != [initialRoute] else { return }
|
||||
self.navigationPath = [initialRoute]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,25 @@ struct ChatProTab: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var viewModel: OpenClawChatViewModel?
|
||||
@State private var viewModelUsesAppleReviewDemoTransport = false
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let headerTitle: String?
|
||||
let headerSubtitle: String?
|
||||
let showsAgentBadge: Bool
|
||||
let openSettings: (() -> Void)?
|
||||
|
||||
init(
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
headerTitle: String? = nil,
|
||||
headerSubtitle: String? = nil,
|
||||
showsAgentBadge: Bool = true,
|
||||
openSettings: (() -> Void)? = nil)
|
||||
{
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.headerTitle = headerTitle
|
||||
self.headerSubtitle = headerSubtitle
|
||||
self.showsAgentBadge = showsAgentBadge
|
||||
self.openSettings = openSettings
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -67,7 +86,30 @@ struct ChatProTab: View {
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 11) {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: self.headerDisplayTitle,
|
||||
subtitle: self.headerDisplaySubtitle,
|
||||
titleFont: .headline.weight(.semibold),
|
||||
subtitleFont: .caption,
|
||||
subtitleLineLimit: 1)
|
||||
{
|
||||
HStack(spacing: 11) {
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
self.headerIdentityBadge
|
||||
}
|
||||
} accessory: {
|
||||
self.connectionPillButton
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var headerIdentityBadge: some View {
|
||||
if self.showsAgentBadge {
|
||||
Text(self.agentBadge)
|
||||
.font(.system(size: self.agentBadge.count > 2 ? 13 : 16, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
@@ -86,24 +128,9 @@ struct ChatProTab: View {
|
||||
endPoint: .bottomTrailing)))
|
||||
.overlay(Circle().strokeBorder(.white.opacity(0.18), lineWidth: 1))
|
||||
.shadow(color: OpenClawBrand.accent.opacity(0.18), radius: 10, y: 5)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(self.agentDisplayName)
|
||||
.font(.headline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text("AI Assistant")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
self.connectionPill
|
||||
} else {
|
||||
ProIconBadge(systemName: "bubble.left", color: OpenClawBrand.accent)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
private func syncChatViewModel() {
|
||||
@@ -162,37 +189,93 @@ struct ChatProTab: View {
|
||||
?? "main"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var connectionPillButton: some View {
|
||||
if let openSettings {
|
||||
Button(action: openSettings) {
|
||||
self.connectionPill
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
self.connectionPill
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionPill: some View {
|
||||
HStack(spacing: 6) {
|
||||
ProStatusDot(color: self.gatewayConnected ? OpenClawBrand.ok : .orange)
|
||||
Text(self.gatewayConnected ? "Connected" : "Connecting")
|
||||
ProStatusDot(color: self.gatewayPillColor)
|
||||
Text(Self.gatewayPillTitle(state: self.gatewayDisplayState, isGatewayUsable: self.gatewayConnected))
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(self.gatewayConnected ? OpenClawBrand.ok : .orange)
|
||||
.foregroundStyle(self.gatewayPillColor)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 30)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill((self.gatewayConnected ? OpenClawBrand.ok : Color.orange).opacity(0.11))
|
||||
.fill(self.gatewayPillColor.opacity(0.11))
|
||||
}
|
||||
.overlay {
|
||||
Capsule()
|
||||
.strokeBorder((self.gatewayConnected ? OpenClawBrand.ok : Color.orange).opacity(0.16), lineWidth: 1)
|
||||
.strokeBorder(self.gatewayPillColor.opacity(0.16), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayConnected: Bool {
|
||||
guard GatewayStatusBuilder.build(appModel: self.appModel) == .connected else {
|
||||
guard self.gatewayDisplayState == .connected else {
|
||||
return false
|
||||
}
|
||||
return self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var gatewayDisplayState: GatewayDisplayState {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel)
|
||||
}
|
||||
|
||||
private var gatewayPillColor: Color {
|
||||
switch self.gatewayDisplayState {
|
||||
case .connected:
|
||||
self.gatewayConnected ? OpenClawBrand.ok : .secondary
|
||||
case .connecting:
|
||||
OpenClawBrand.accent
|
||||
case .error:
|
||||
OpenClawBrand.warn
|
||||
case .disconnected:
|
||||
.secondary
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func gatewayPillTitle(state: GatewayDisplayState, isGatewayUsable: Bool) -> String {
|
||||
switch state {
|
||||
case .connected:
|
||||
isGatewayUsable ? "Connected" : "Unavailable"
|
||||
case .connecting:
|
||||
"Connecting"
|
||||
case .error:
|
||||
"Attention"
|
||||
case .disconnected:
|
||||
"Offline"
|
||||
}
|
||||
}
|
||||
|
||||
private var messagePlaceholder: String {
|
||||
self.gatewayConnected ? "Message \(self.agentDisplayName)..." : "Connect to a gateway"
|
||||
}
|
||||
|
||||
private var headerDisplayTitle: String {
|
||||
self.normalized(self.headerTitle)
|
||||
?? Self.defaultHeaderTitle(showsAgentBadge: self.showsAgentBadge, agentDisplayName: self.agentDisplayName)
|
||||
}
|
||||
|
||||
private var headerDisplaySubtitle: String {
|
||||
self.normalized(self.headerSubtitle) ?? "AI Assistant"
|
||||
}
|
||||
|
||||
nonisolated static func defaultHeaderTitle(showsAgentBadge: Bool, agentDisplayName: String) -> String {
|
||||
showsAgentBadge ? agentDisplayName : "Chat"
|
||||
}
|
||||
|
||||
private var chatUserAccent: Color {
|
||||
self.colorScheme == .light ? Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0) : OpenClawBrand.accent
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ struct CommandPanel<Content: View>: View {
|
||||
tint: self.tint,
|
||||
isProminent: self.isProminent,
|
||||
padding: self.padding,
|
||||
radius: 12)
|
||||
radius: OpenClawProMetric.cardRadius)
|
||||
{
|
||||
self.content
|
||||
}
|
||||
@@ -34,40 +34,15 @@ struct CommandControlBackground: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
LinearGradient(
|
||||
colors: self.colorScheme == .dark ? self.darkColors : self.lightColors,
|
||||
startPoint: .top,
|
||||
endPoint: .bottom)
|
||||
Color(uiColor: self.colorScheme == .dark ? .systemBackground : .systemGroupedBackground)
|
||||
.overlay(alignment: .top) {
|
||||
if self.colorScheme == .light {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.34),
|
||||
Color.clear,
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
.frame(height: 260)
|
||||
Color.white.opacity(0.20)
|
||||
.frame(height: 140)
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
private var darkColors: [Color] {
|
||||
[
|
||||
Color(red: 12 / 255, green: 13 / 255, blue: 15 / 255),
|
||||
Color(red: 7 / 255, green: 8 / 255, blue: 10 / 255),
|
||||
Color(red: 4 / 255, green: 5 / 255, blue: 6 / 255),
|
||||
]
|
||||
}
|
||||
|
||||
private var lightColors: [Color] {
|
||||
[
|
||||
Color(red: 247 / 255, green: 248 / 255, blue: 249 / 255),
|
||||
Color(red: 251 / 255, green: 252 / 255, blue: 253 / 255),
|
||||
.white,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandSessionRow: View {
|
||||
@@ -114,12 +89,12 @@ struct CommandSessionRow: View {
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 9)
|
||||
.padding(.vertical, 8)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
|
||||
.fill(self.rowFill)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
|
||||
.strokeBorder(self.rowBorder, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
@@ -136,11 +111,11 @@ struct CommandSessionRow: View {
|
||||
}
|
||||
|
||||
private var rowFill: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color.black.opacity(0.025)
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color(uiColor: .systemBackground)
|
||||
}
|
||||
|
||||
private var rowBorder: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.black.opacity(0.045)
|
||||
Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.24 : 0.22)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,21 +129,21 @@ struct CommandViewMoreRow: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
|
||||
.fill(self.rowFill)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
|
||||
.strokeBorder(self.rowBorder, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var rowFill: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color.black.opacity(0.025)
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color(uiColor: .systemBackground)
|
||||
}
|
||||
|
||||
private var rowBorder: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.black.opacity(0.045)
|
||||
Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.24 : 0.22)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,13 +174,13 @@ struct CommandEmptyStateRow: View {
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 9)
|
||||
.padding(.vertical, 8)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color.black.opacity(0.06))
|
||||
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
|
||||
.fill(Color(uiColor: .systemBackground))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(Color.primary.opacity(0.055), lineWidth: 1)
|
||||
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
|
||||
.strokeBorder(Color(uiColor: .separator).opacity(0.22), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,17 @@ import OpenClawChatUI
|
||||
import SwiftUI
|
||||
|
||||
struct CommandCenterTab: View {
|
||||
fileprivate static let recentSessionsFetchLimit = 200
|
||||
static let recentSessionsFetchLimit = 200
|
||||
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var defaultChatSessionEntry: OpenClawChatSessionEntry?
|
||||
@State private var recentChatSessions: [OpenClawChatSessionEntry] = []
|
||||
var headerTitle: String = "OpenClaw"
|
||||
var headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
var showsHeaderMark: Bool = true
|
||||
var openChat: () -> Void
|
||||
var openSettings: () -> Void
|
||||
|
||||
@@ -31,20 +35,37 @@ struct CommandCenterTab: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
CommandControlBackground()
|
||||
self.commandAmbientOverlay
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.header
|
||||
self.gatewayCard
|
||||
self.defaultChatSessionSection
|
||||
self.recentSessions
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
CommandControlBackground()
|
||||
self.commandAmbientOverlay
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
self.gatewayCard
|
||||
if Self.usesSplitSectionsLayout(
|
||||
horizontalSizeClass: self.horizontalSizeClass,
|
||||
containerWidth: geometry.size.width)
|
||||
{
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
self.defaultChatSessionSection
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
self.recentSessions
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
} else {
|
||||
self.defaultChatSessionSection
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
self.recentSessions
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 18)
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
@@ -53,12 +74,47 @@ struct CommandCenterTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
static func usesSplitSectionsLayout(
|
||||
horizontalSizeClass: UserInterfaceSizeClass?,
|
||||
containerWidth: CGFloat) -> Bool
|
||||
{
|
||||
guard horizontalSizeClass == .regular else { return false }
|
||||
return containerWidth >= 1000
|
||||
}
|
||||
|
||||
static func shouldShowHeaderMark(
|
||||
hasLeadingAction: Bool,
|
||||
showsHeaderMark: Bool) -> Bool
|
||||
{
|
||||
!hasLeadingAction && showsHeaderMark
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .center, spacing: 11) {
|
||||
OpenClawProMark(size: 31, shadowRadius: 9)
|
||||
Text("OpenClaw")
|
||||
.font(.system(size: 27, weight: .bold, design: .rounded))
|
||||
Spacer()
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: self.headerTitle,
|
||||
subtitle: self.gatewaySubtitle,
|
||||
titleFont: .title3.weight(.semibold),
|
||||
subtitleFont: .caption,
|
||||
subtitleLineLimit: 1)
|
||||
{
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
} else if Self.shouldShowHeaderMark(
|
||||
hasLeadingAction: headerLeadingAction != nil,
|
||||
showsHeaderMark: self.showsHeaderMark)
|
||||
{
|
||||
OpenClawProMark(size: 28, shadowRadius: 5)
|
||||
}
|
||||
} accessory: {
|
||||
Button(action: self.openSettings) {
|
||||
ProCapsule(
|
||||
title: self.gatewayStateText,
|
||||
color: self.gatewayStatusColor,
|
||||
icon: self.gatewayConnected ? "checkmark.circle.fill" : "wifi.slash")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Gateway \(self.gatewayStateText)")
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
@@ -86,7 +142,7 @@ struct CommandCenterTab: View {
|
||||
title: "Gateway",
|
||||
value: self.gatewayStateText,
|
||||
color: self.gatewayStatusColor,
|
||||
icon: self.gatewayConnected ? "hourglass" : "wifi.slash")
|
||||
icon: self.gatewayConnected ? "checkmark.circle.fill" : "wifi.slash")
|
||||
|
||||
HStack(spacing: 0) {
|
||||
self.gatewayFact(
|
||||
@@ -160,7 +216,6 @@ struct CommandCenterTab: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var recentSessions: some View {
|
||||
@@ -200,7 +255,6 @@ struct CommandCenterTab: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private func cardHeader(
|
||||
@@ -213,7 +267,8 @@ struct CommandCenterTab: View {
|
||||
{
|
||||
HStack(spacing: 8) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.bold))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
if let badgeValue {
|
||||
Text(badgeValue)
|
||||
.font(.caption2.weight(.bold))
|
||||
@@ -403,7 +458,7 @@ struct CommandCenterTab: View {
|
||||
return result
|
||||
}
|
||||
|
||||
fileprivate static func sessionWorkItem(
|
||||
static func sessionWorkItem(
|
||||
for session: OpenClawChatSessionEntry,
|
||||
currentSessionKey: String) -> WorkItem
|
||||
{
|
||||
@@ -558,14 +613,20 @@ struct CommandCenterTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct CommandSessionsScreen: View {
|
||||
struct CommandSessionsScreen: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var sessions: [OpenClawChatSessionEntry] = []
|
||||
@State private var isLoading = false
|
||||
@State private var loadErrorText: String?
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let openChat: () -> Void
|
||||
|
||||
init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, openChat: @escaping () -> Void) {
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.openChat = openChat
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
CommandControlBackground()
|
||||
@@ -587,12 +648,18 @@ private struct CommandSessionsScreen: View {
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Sessions")
|
||||
.font(.system(size: 27, weight: .bold, design: .rounded))
|
||||
Text(self.headerDetail)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Sessions")
|
||||
.font(.system(size: 27, weight: .bold, design: .rounded))
|
||||
Text(self.headerDetail)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
244
apps/ios/Sources/Design/IPadActivityScreen.swift
Normal file
244
apps/ios/Sources/Design/IPadActivityScreen.swift
Normal file
@@ -0,0 +1,244 @@
|
||||
import OpenClawChatUI
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
struct IPadActivityScreen: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var sessions: [OpenClawChatSessionEntry] = []
|
||||
@State private var isLoading = false
|
||||
@State private var loadErrorText: String?
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let openChat: () -> Void
|
||||
let openSettings: () -> Void
|
||||
|
||||
init(
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
openChat: @escaping () -> Void,
|
||||
openSettings: @escaping () -> Void)
|
||||
{
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.openChat = openChat
|
||||
self.openSettings = openSettings
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
IPadSidebarScreenChrome(
|
||||
title: "Activity",
|
||||
subtitle: "Live device and gateway activity.",
|
||||
headerLeadingAction: self.headerLeadingAction,
|
||||
gatewayAction: self.openSettings)
|
||||
{
|
||||
ProMetricGrid(metrics: self.metrics)
|
||||
self.activityFeed
|
||||
}
|
||||
.task(id: self.refreshID) {
|
||||
await self.refreshSessions()
|
||||
}
|
||||
.refreshable {
|
||||
await self.refreshSessions()
|
||||
}
|
||||
}
|
||||
|
||||
private var metrics: [ProMetric] {
|
||||
[
|
||||
ProMetric(
|
||||
icon: self.gatewayConnected ? "checkmark.circle.fill" : "wifi.slash",
|
||||
title: "Gateway",
|
||||
value: self.gatewayStateText,
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary),
|
||||
ProMetric(
|
||||
icon: "person.2.fill",
|
||||
title: "Agents",
|
||||
value: self.gatewayConnected ? "\(self.appModel.gatewayAgents.count)" : "offline",
|
||||
color: OpenClawBrand.accent),
|
||||
ProMetric(
|
||||
icon: "bubble.left.and.text.bubble.right",
|
||||
title: "Sessions",
|
||||
value: self.isLoading ? "..." : "\(self.sessionRows.count)",
|
||||
color: OpenClawBrand.accentHot),
|
||||
]
|
||||
}
|
||||
|
||||
private var activityFeed: some View {
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ProPanelHeader(
|
||||
title: "Recent activity",
|
||||
value: self.isLoading ? "Loading" : nil,
|
||||
actionTitle: "Refresh",
|
||||
action: {
|
||||
Task { await self.refreshSessions() }
|
||||
})
|
||||
|
||||
if let pendingExecApprovalPrompt = self.appModel.pendingExecApprovalPrompt {
|
||||
ProStatusRow(
|
||||
icon: "hand.raised.fill",
|
||||
title: "Approval needed",
|
||||
detail: pendingExecApprovalPrompt.commandPreview ?? pendingExecApprovalPrompt.commandText,
|
||||
value: "pending",
|
||||
color: OpenClawBrand.warn,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
|
||||
ProStatusRow(
|
||||
icon: self.gatewayConnected ? "network" : "wifi.slash",
|
||||
title: "Gateway",
|
||||
detail: self.gatewayDetailText,
|
||||
value: self.gatewayStateText.lowercased(),
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary,
|
||||
actionTitle: self.gatewayConnected ? nil : "Settings",
|
||||
action: self.gatewayConnected ? nil : self.openSettings)
|
||||
|
||||
Divider().padding(.leading, 58)
|
||||
|
||||
ProStatusRow(
|
||||
icon: "square.and.arrow.down",
|
||||
title: "Share intake",
|
||||
detail: self.appModel.lastShareEventText,
|
||||
value: "iPad",
|
||||
color: OpenClawBrand.accent,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
|
||||
if self.isLoading, self.sessions.isEmpty {
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: "hourglass",
|
||||
title: "Loading sessions",
|
||||
detail: "Fetching recent activity from the gateway.",
|
||||
value: "loading",
|
||||
color: OpenClawBrand.accent,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
} else if let loadErrorText {
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: "exclamationmark.triangle.fill",
|
||||
title: "Sessions unavailable",
|
||||
detail: loadErrorText,
|
||||
value: "error",
|
||||
color: OpenClawBrand.warn,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
} else if self.sessionRows.isEmpty {
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: "bubble.left.and.text.bubble.right",
|
||||
title: self.sessionsAvailable ? "No recent sessions" : "Session activity offline",
|
||||
detail: self.sessionsAvailable
|
||||
? "Start a chat and it will appear here."
|
||||
: "Connect to the gateway to load recent chat activity.",
|
||||
value: self.sessionsAvailable ? "empty" : "offline",
|
||||
color: .secondary,
|
||||
actionTitle: self.sessionsAvailable ? "Chat" : nil,
|
||||
action: self.sessionsAvailable ? self.openChat : nil)
|
||||
} else {
|
||||
ForEach(self.sessionRows) { row in
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: row.icon,
|
||||
title: row.title,
|
||||
detail: row.detail,
|
||||
value: row.state,
|
||||
color: row.color,
|
||||
actionTitle: "Open",
|
||||
action: {
|
||||
self.open(row)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var refreshID: String {
|
||||
[
|
||||
self.sessionsMode,
|
||||
self.appModel.chatSessionKey,
|
||||
self.scenePhase == .active ? "active" : "inactive",
|
||||
].joined(separator: ":")
|
||||
}
|
||||
|
||||
private var gatewayConnected: Bool {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
}
|
||||
|
||||
private var gatewayStateText: String {
|
||||
guard !self.gatewayConnected else { return "Online" }
|
||||
let status = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return status.isEmpty ? "Offline" : status
|
||||
}
|
||||
|
||||
private var gatewayDetailText: String {
|
||||
self.normalized(self.appModel.gatewayRemoteAddress)
|
||||
?? self.normalized(self.appModel.gatewayServerName)
|
||||
?? "No gateway connection"
|
||||
}
|
||||
|
||||
private var sessionsAvailable: Bool {
|
||||
self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var sessionsMode: String {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
|
||||
return self.appModel.isOperatorGatewayConnected ? "operator" : "offline"
|
||||
}
|
||||
|
||||
private var sessionRows: [CommandCenterTab.WorkItem] {
|
||||
self.sessions
|
||||
.filter { CommandCenterTab.isRecentChatSession(
|
||||
$0.key,
|
||||
defaultSessionKey: self.appModel.defaultChatSessionKey) }
|
||||
.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
|
||||
.prefix(8)
|
||||
.map {
|
||||
CommandCenterTab.sessionWorkItem(
|
||||
for: $0,
|
||||
currentSessionKey: self.appModel.chatSessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshSessions() async {
|
||||
guard self.scenePhase == .active else { return }
|
||||
guard self.sessionsAvailable else {
|
||||
self.sessions = []
|
||||
self.loadErrorText = nil
|
||||
return
|
||||
}
|
||||
|
||||
self.isLoading = true
|
||||
self.loadErrorText = nil
|
||||
defer { self.isLoading = false }
|
||||
|
||||
do {
|
||||
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
|
||||
let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit)
|
||||
self.sessions = response.sessions
|
||||
} catch {
|
||||
self.sessions = []
|
||||
self.loadErrorText = "Try again after the gateway reconnects."
|
||||
}
|
||||
}
|
||||
|
||||
private func open(_ item: CommandCenterTab.WorkItem) {
|
||||
switch item.route {
|
||||
case let .chat(sessionKey):
|
||||
self.appModel.openChat(sessionKey: sessionKey)
|
||||
self.openChat()
|
||||
case .settings:
|
||||
self.openSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
672
apps/ios/Sources/Design/IPadSidebarFeaturePreviews.swift
Normal file
672
apps/ios/Sources/Design/IPadSidebarFeaturePreviews.swift
Normal file
@@ -0,0 +1,672 @@
|
||||
import SwiftUI
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Activity states") {
|
||||
IPadActivityStatesPreview()
|
||||
}
|
||||
|
||||
#Preview("Workboard states") {
|
||||
IPadWorkboardStatesPreview()
|
||||
}
|
||||
|
||||
#Preview("Skill Workshop states") {
|
||||
IPadSkillWorkshopStatesPreview()
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Skill Workshop iPad kanban lanes",
|
||||
traits: .fixedLayout(width: 1180, height: 820))
|
||||
{
|
||||
IPadSkillWorkshopKanbanPreview()
|
||||
}
|
||||
|
||||
#Preview("Workboard phone queue rows") {
|
||||
IPadWorkboardCompactRowsPreview()
|
||||
}
|
||||
|
||||
#Preview("Skill Workshop phone queue rows") {
|
||||
IPadSkillWorkshopCompactRowsPreview()
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Workboard phone landscape",
|
||||
traits: .fixedLayout(width: 852, height: 393),
|
||||
.landscapeLeft)
|
||||
{
|
||||
IPadSidebarTaskScreenPreviewHost {
|
||||
IPadWorkboardScreen(openChat: {}, openSettings: {})
|
||||
}
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Skill Workshop phone landscape",
|
||||
traits: .fixedLayout(width: 852, height: 393),
|
||||
.landscapeLeft)
|
||||
{
|
||||
IPadSidebarTaskScreenPreviewHost {
|
||||
IPadSkillWorkshopScreen(openSettings: {})
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadWorkboardCompactRowsPreview: View {
|
||||
private let statuses = ["todo", "ready", "running", "review", "blocked", "done"]
|
||||
private let cards = IPadWorkboardPreviewFixtures.cards
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
self.previewHeader
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ProPanelHeader(
|
||||
title: "Queue",
|
||||
value: "\(self.cards.count)",
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
ForEach(Array(self.cards.enumerated()), id: \.element.id) { index, card in
|
||||
if index > 0 {
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
IPadWorkboardQueueRow(
|
||||
card: card,
|
||||
statuses: self.statuses,
|
||||
isBusy: card.id == "preview-running",
|
||||
inspect: {},
|
||||
openSession: {},
|
||||
move: { _ in },
|
||||
archive: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: "tray",
|
||||
title: "No cards",
|
||||
detail: "Create a card or change the filter.",
|
||||
value: "empty",
|
||||
color: .secondary,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
.environment(\.horizontalSizeClass, .compact)
|
||||
.environment(\.verticalSizeClass, .regular)
|
||||
}
|
||||
|
||||
private var previewHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Phone queue")
|
||||
.font(.headline)
|
||||
Text("Tap for detail, swipe or long-press for card actions.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadSkillWorkshopCompactRowsPreview: View {
|
||||
private let proposals = IPadSkillWorkshopPreviewFixtures.proposals
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
self.previewHeader
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ProPanelHeader(
|
||||
title: "Queue",
|
||||
value: "\(self.proposals.count)",
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
ForEach(Array(self.proposals.enumerated()), id: \.element.id) { index, proposal in
|
||||
if index > 0 {
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
IPadSkillProposalRow(
|
||||
proposal: proposal,
|
||||
isSelected: proposal.id == "preview-pending",
|
||||
isBusy: proposal.id == "preview-held")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: "hammer",
|
||||
title: "No proposals",
|
||||
detail: "New proposals will appear here when agents draft skills.",
|
||||
value: "empty",
|
||||
color: .secondary,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
.environment(\.horizontalSizeClass, .compact)
|
||||
.environment(\.verticalSizeClass, .regular)
|
||||
}
|
||||
|
||||
private var previewHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Phone proposals")
|
||||
.font(.headline)
|
||||
Text("Tap for detail, swipe or long-press for proposal actions.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadSidebarTaskScreenPreviewHost<Content: View>: View {
|
||||
@State private var appModel = NodeAppModel()
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
self.content
|
||||
}
|
||||
.environment(self.appModel)
|
||||
.environment(\.horizontalSizeClass, .regular)
|
||||
.environment(\.verticalSizeClass, .compact)
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadActivityStatesPreview: View {
|
||||
private let connectedSessions = [
|
||||
CommandCenterTab.WorkItem(
|
||||
id: "preview-main",
|
||||
icon: "bubble.left.and.text.bubble.right",
|
||||
title: "Main",
|
||||
detail: "Updated just now",
|
||||
state: "active",
|
||||
trailing: "open",
|
||||
color: OpenClawBrand.ok,
|
||||
progress: nil,
|
||||
route: .chat("main")),
|
||||
CommandCenterTab.WorkItem(
|
||||
id: "preview-ipad-audit",
|
||||
icon: "bubble.left.and.text.bubble.right",
|
||||
title: "iPad audit",
|
||||
detail: "Updated 8m ago",
|
||||
state: "recent",
|
||||
trailing: "open",
|
||||
color: OpenClawBrand.accent,
|
||||
progress: nil,
|
||||
route: .chat("ipad-audit")),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
self.previewHeader("Connected")
|
||||
self.activityCard(
|
||||
gatewayTitle: "Gateway",
|
||||
gatewayDetail: "tailscale.local:18789",
|
||||
gatewayValue: "online",
|
||||
gatewayColor: OpenClawBrand.ok,
|
||||
sessionRows: self.connectedSessions,
|
||||
tailRows: [])
|
||||
|
||||
self.previewHeader("Loading")
|
||||
self.activityCard(
|
||||
gatewayTitle: "Gateway",
|
||||
gatewayDetail: "Fetching recent activity from the gateway.",
|
||||
gatewayValue: "online",
|
||||
gatewayColor: OpenClawBrand.ok,
|
||||
sessionRows: [],
|
||||
tailRows: [
|
||||
ActivityPreviewRow(
|
||||
icon: "hourglass",
|
||||
title: "Loading sessions",
|
||||
detail: "Fetching recent activity from the gateway.",
|
||||
value: "loading",
|
||||
color: OpenClawBrand.accent),
|
||||
])
|
||||
|
||||
self.previewHeader("Empty")
|
||||
self.activityCard(
|
||||
gatewayTitle: "Gateway",
|
||||
gatewayDetail: "tailscale.local:18789",
|
||||
gatewayValue: "online",
|
||||
gatewayColor: OpenClawBrand.ok,
|
||||
sessionRows: [],
|
||||
tailRows: [
|
||||
ActivityPreviewRow(
|
||||
icon: "bubble.left.and.text.bubble.right",
|
||||
title: "No recent sessions",
|
||||
detail: "Start a chat and it will appear here.",
|
||||
value: "empty",
|
||||
color: .secondary),
|
||||
])
|
||||
|
||||
self.previewHeader("Error")
|
||||
self.activityCard(
|
||||
gatewayTitle: "Gateway",
|
||||
gatewayDetail: "No gateway connection",
|
||||
gatewayValue: "offline",
|
||||
gatewayColor: .secondary,
|
||||
sessionRows: [],
|
||||
tailRows: [
|
||||
ActivityPreviewRow(
|
||||
icon: "exclamationmark.triangle.fill",
|
||||
title: "Sessions unavailable",
|
||||
detail: "Try again after the gateway reconnects.",
|
||||
value: "error",
|
||||
color: OpenClawBrand.warn),
|
||||
])
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func previewHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
|
||||
private func activityCard(
|
||||
gatewayTitle: String,
|
||||
gatewayDetail: String,
|
||||
gatewayValue: String,
|
||||
gatewayColor: Color,
|
||||
sessionRows: [CommandCenterTab.WorkItem],
|
||||
tailRows: [ActivityPreviewRow]) -> some View
|
||||
{
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ProPanelHeader(
|
||||
title: "Recent activity",
|
||||
value: nil,
|
||||
actionTitle: "Refresh",
|
||||
action: {})
|
||||
ProStatusRow(
|
||||
icon: gatewayValue == "online" ? "network" : "wifi.slash",
|
||||
title: gatewayTitle,
|
||||
detail: gatewayDetail,
|
||||
value: gatewayValue,
|
||||
color: gatewayColor,
|
||||
actionTitle: gatewayValue == "online" ? nil : "Settings",
|
||||
action: {})
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: "square.and.arrow.down",
|
||||
title: "Share intake",
|
||||
detail: "No share events yet.",
|
||||
value: "iPad",
|
||||
color: OpenClawBrand.accent,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
ForEach(sessionRows) { row in
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: row.icon,
|
||||
title: row.title,
|
||||
detail: row.detail,
|
||||
value: row.state,
|
||||
color: row.color,
|
||||
actionTitle: "Open",
|
||||
action: {})
|
||||
}
|
||||
ForEach(tailRows) { row in
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: row.icon,
|
||||
title: row.title,
|
||||
detail: row.detail,
|
||||
value: row.value,
|
||||
color: row.color,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ActivityPreviewRow: Identifiable {
|
||||
let id = UUID()
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let value: String
|
||||
let color: Color
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadWorkboardStatesPreview: View {
|
||||
private let statuses = ["todo", "running", "review"]
|
||||
private let connectedCards = IPadWorkboardPreviewFixtures.cards
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
self.previewHeader("Connected")
|
||||
self.connectedBoard
|
||||
|
||||
self.previewHeader("Empty")
|
||||
IPadWorkboardKanbanColumn(
|
||||
status: "todo",
|
||||
cards: [],
|
||||
statuses: self.statuses,
|
||||
busyCardID: nil,
|
||||
openSession: { _ in },
|
||||
inspect: { _ in },
|
||||
move: { _, _ in },
|
||||
archive: { _ in })
|
||||
.frame(maxWidth: 320)
|
||||
|
||||
self.previewHeader("Loading")
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: "arrow.clockwise",
|
||||
title: "Loading cards",
|
||||
detail: "Refreshing the workboard from the gateway.",
|
||||
value: "loading",
|
||||
color: OpenClawBrand.accent,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
}
|
||||
|
||||
self.previewHeader("Error")
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: "exclamationmark.triangle",
|
||||
title: "Cards unavailable",
|
||||
detail: "Check the gateway connection, then refresh.",
|
||||
value: "error",
|
||||
color: OpenClawBrand.warn,
|
||||
actionTitle: "Retry",
|
||||
action: {})
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var connectedBoard: some View {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ForEach(self.statuses, id: \.self) { status in
|
||||
IPadWorkboardKanbanColumn(
|
||||
status: status,
|
||||
cards: self.connectedCards.filter { $0.status == status },
|
||||
statuses: self.statuses,
|
||||
busyCardID: nil,
|
||||
openSession: { _ in },
|
||||
inspect: { _ in },
|
||||
move: { _, _ in },
|
||||
archive: { _ in })
|
||||
.frame(width: 282)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func previewHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
}
|
||||
|
||||
private enum IPadWorkboardPreviewFixtures {
|
||||
static let cards = [
|
||||
IPadWorkboardCard(
|
||||
id: "preview-todo",
|
||||
title: "Prep iPad sidebar audit",
|
||||
notes: "Confirm portrait drawer behavior before device install.",
|
||||
status: "todo",
|
||||
priority: "normal",
|
||||
labels: ["iPad", "UI"],
|
||||
agentId: "main",
|
||||
sessionKey: nil,
|
||||
position: 0,
|
||||
updatedAt: nil,
|
||||
metadata: nil),
|
||||
IPadWorkboardCard(
|
||||
id: "preview-running",
|
||||
title: "Verify phone workboard queue",
|
||||
notes: "Single-list compact flow with detail sheet actions.",
|
||||
status: "running",
|
||||
priority: "high",
|
||||
labels: ["phone"],
|
||||
agentId: "main",
|
||||
sessionKey: "session-preview",
|
||||
position: 1,
|
||||
updatedAt: nil,
|
||||
metadata: nil),
|
||||
IPadWorkboardCard(
|
||||
id: "preview-review",
|
||||
title: "Review adaptive shell",
|
||||
notes: "Make sure shared destinations stay device-specific.",
|
||||
status: "review",
|
||||
priority: "normal",
|
||||
labels: ["shell"],
|
||||
agentId: "main",
|
||||
sessionKey: nil,
|
||||
position: 2,
|
||||
updatedAt: nil,
|
||||
metadata: nil),
|
||||
]
|
||||
}
|
||||
|
||||
private struct IPadSkillWorkshopStatesPreview: View {
|
||||
private let proposals = IPadSkillWorkshopPreviewFixtures.proposals
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
self.previewHeader("Connected")
|
||||
self.queueCard(self.proposals, selectedID: "preview-pending", busyID: nil)
|
||||
|
||||
self.previewHeader("Loading")
|
||||
self.queueCard(self.proposals, selectedID: "preview-pending", busyID: "preview-pending")
|
||||
|
||||
self.previewHeader("Empty")
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: "hammer",
|
||||
title: "No proposals",
|
||||
detail: "New proposals will appear here when agents draft skills.",
|
||||
value: "empty",
|
||||
color: .secondary,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
}
|
||||
|
||||
self.previewHeader("Offline / Error")
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: "wifi.slash",
|
||||
title: "Workshop offline",
|
||||
detail: "Connect to the gateway to load Skill Workshop proposals.",
|
||||
value: "offline",
|
||||
color: .secondary,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: "exclamationmark.triangle",
|
||||
title: "Proposal unavailable",
|
||||
detail: "Try again after the gateway reconnects.",
|
||||
value: "error",
|
||||
color: OpenClawBrand.warn,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func previewHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private func queueCard(
|
||||
_ proposals: [IPadSkillProposal],
|
||||
selectedID: String?,
|
||||
busyID: String?) -> some View
|
||||
{
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ProPanelHeader(
|
||||
title: "Queue",
|
||||
value: "\(proposals.count)",
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
ForEach(Array(proposals.enumerated()), id: \.element.id) { index, proposal in
|
||||
if index > 0 {
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
IPadSkillProposalRow(
|
||||
proposal: proposal,
|
||||
isSelected: proposal.id == selectedID,
|
||||
isBusy: proposal.id == busyID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadSkillWorkshopKanbanPreview: View {
|
||||
private let lanes = IPadSkillWorkshopPreviewFixtures.kanbanStatuses
|
||||
private let proposals = IPadSkillWorkshopPreviewFixtures.proposals
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
self.previewHeader
|
||||
ScrollView(.horizontal) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ForEach(self.lanes, id: \.self) { status in
|
||||
IPadSkillProposalKanbanColumn(
|
||||
status: status,
|
||||
proposals: self.proposals.filter { $0.status == status },
|
||||
selectedProposalID: "preview-pending",
|
||||
inspectingProposalID: "preview-needs-review",
|
||||
canApplyProposalMutations: true,
|
||||
busyAction: nil,
|
||||
select: { _ in },
|
||||
inspect: { _ in },
|
||||
apply: { _ in },
|
||||
reject: { _ in })
|
||||
.frame(width: 282)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 22)
|
||||
}
|
||||
.environment(\.horizontalSizeClass, .regular)
|
||||
.environment(\.verticalSizeClass, .regular)
|
||||
}
|
||||
|
||||
private var previewHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("iPad kanban")
|
||||
.font(.headline)
|
||||
Text("Wide layout with populated, empty, held, and custom proposal lanes.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private enum IPadSkillWorkshopPreviewFixtures {
|
||||
static let kanbanStatuses = [
|
||||
"pending",
|
||||
"quarantined",
|
||||
"stale",
|
||||
"applied",
|
||||
"rejected",
|
||||
"needs-review",
|
||||
"manual_QA",
|
||||
]
|
||||
|
||||
static let proposals = [
|
||||
Self.proposal(
|
||||
id: "preview-pending",
|
||||
status: "pending",
|
||||
title: "Add Tailscale gateway helper",
|
||||
description: "Drafts a helper skill for checking local Tailscale reachability before pairing.",
|
||||
minutesAgo: 9),
|
||||
Self.proposal(
|
||||
id: "preview-applied",
|
||||
status: "applied",
|
||||
title: "Summarize channel health",
|
||||
description: "Adds a lightweight status summary for channel clients and recent routing failures.",
|
||||
minutesAgo: 47),
|
||||
Self.proposal(
|
||||
id: "preview-held",
|
||||
status: "quarantined",
|
||||
title: "Desktop automation bridge",
|
||||
description: "Held for review because it requests broader file access than mobile should expose.",
|
||||
minutesAgo: 128),
|
||||
Self.proposal(
|
||||
id: "preview-needs-review",
|
||||
status: "needs-review",
|
||||
title: "Review pairing diagnostics",
|
||||
description: "Adds a diagnostic checklist before trusting a new gateway certificate.",
|
||||
minutesAgo: 32),
|
||||
Self.proposal(
|
||||
id: "preview-manual-qa",
|
||||
status: "manual_QA",
|
||||
title: "Manual QA runbook",
|
||||
description: "Generates a device checklist for iPhone portrait and iPad split layouts.",
|
||||
minutesAgo: 15),
|
||||
]
|
||||
|
||||
private static func proposal(
|
||||
id: String,
|
||||
status: String,
|
||||
title: String,
|
||||
description: String,
|
||||
minutesAgo: Int) -> IPadSkillProposal
|
||||
{
|
||||
let updatedAt = ISO8601DateFormatter().string(from: Date().addingTimeInterval(Double(-minutesAgo * 60)))
|
||||
return IPadSkillProposal(
|
||||
entry: IPadSkillProposalManifestEntry(
|
||||
id: id,
|
||||
kind: "skill",
|
||||
status: status,
|
||||
title: title,
|
||||
description: description,
|
||||
skillName: title,
|
||||
skillKey: id,
|
||||
createdAt: updatedAt,
|
||||
updatedAt: updatedAt,
|
||||
scanState: "complete"),
|
||||
previous: nil)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
17
apps/ios/Sources/Design/IPadSidebarFeatureScreens.swift
Normal file
17
apps/ios/Sources/Design/IPadSidebarFeatureScreens.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
struct EmptyParams: Encodable {}
|
||||
|
||||
enum IPadSidebarGatewayError: Error {
|
||||
case offline
|
||||
case invalidPayload
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .offline:
|
||||
"Gateway offline."
|
||||
case .invalidPayload:
|
||||
"Could not encode request."
|
||||
}
|
||||
}
|
||||
}
|
||||
71
apps/ios/Sources/Design/IPadSidebarScreenChrome.swift
Normal file
71
apps/ios/Sources/Design/IPadSidebarScreenChrome.swift
Normal file
@@ -0,0 +1,71 @@
|
||||
import SwiftUI
|
||||
|
||||
struct IPadSidebarScreenChrome<Content: View>: View {
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let gatewayAction: (() -> Void)?
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
init(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
gatewayAction: (() -> Void)? = nil,
|
||||
@ViewBuilder content: () -> Content)
|
||||
{
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.gatewayAction = gatewayAction
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: self.isCompactHeight ? 10 : 16) {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: self.title,
|
||||
subtitle: self.subtitle,
|
||||
titleFont: self.isCompactHeight ? .headline.weight(.semibold) : .title2.weight(.semibold),
|
||||
subtitleLineLimit: self.isCompactHeight ? 1 : 2)
|
||||
{
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
} accessory: {
|
||||
self.gatewayPill
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
self.content
|
||||
}
|
||||
.padding(.vertical, self.isCompactHeight ? 10 : 18)
|
||||
}
|
||||
.safeAreaPadding(.bottom, self.bottomScrollInset)
|
||||
}
|
||||
}
|
||||
|
||||
private var isCompactHeight: Bool {
|
||||
self.verticalSizeClass == .compact
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var gatewayPill: some View {
|
||||
if let gatewayAction {
|
||||
Button(action: gatewayAction) {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomScrollInset: CGFloat {
|
||||
self.isCompactHeight ? 150 : OpenClawProMetric.bottomScrollInset
|
||||
}
|
||||
}
|
||||
1218
apps/ios/Sources/Design/IPadSkillWorkshopScreen.swift
Normal file
1218
apps/ios/Sources/Design/IPadSkillWorkshopScreen.swift
Normal file
File diff suppressed because it is too large
Load Diff
1395
apps/ios/Sources/Design/IPadWorkboardScreen.swift
Normal file
1395
apps/ios/Sources/Design/IPadWorkboardScreen.swift
Normal file
File diff suppressed because it is too large
Load Diff
129
apps/ios/Sources/Design/OpenClawDocsScreen.swift
Normal file
129
apps/ios/Sources/Design/OpenClawDocsScreen.swift
Normal file
@@ -0,0 +1,129 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OpenClawDocsScreen: View {
|
||||
private let docsURL = URL(string: "https://docs.openclaw.ai")!
|
||||
private let gatewayURL = URL(string: "https://docs.openclaw.ai/gateway")!
|
||||
private let pairingURL = URL(string: "https://docs.openclaw.ai/channels/pairing")!
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let gatewayAction: (() -> Void)?
|
||||
|
||||
init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, gatewayAction: (() -> Void)? = nil) {
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.gatewayAction = gatewayAction
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.headerCard
|
||||
self.linkCard
|
||||
self.versionCard
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Docs")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var headerCard: some View {
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: "Docs",
|
||||
subtitle: "Gateway setup, pairing, channels, and mobile node reference.",
|
||||
titleFont: .headline,
|
||||
subtitleFont: .caption)
|
||||
{
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
ProIconBadge(systemName: "book", color: OpenClawBrand.accent)
|
||||
}
|
||||
} accessory: {
|
||||
self.gatewayPill
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var gatewayPill: some View {
|
||||
if let gatewayAction {
|
||||
Button(action: gatewayAction) {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
}
|
||||
|
||||
private var linkCard: some View {
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
self.docsLinkRow(
|
||||
title: "Docs Home",
|
||||
detail: "Browse the current OpenClaw reference.",
|
||||
icon: "book",
|
||||
url: self.docsURL)
|
||||
Divider().padding(.leading, 58)
|
||||
self.docsLinkRow(
|
||||
title: "Gateway",
|
||||
detail: "Connection, auth, and diagnostics.",
|
||||
icon: "network",
|
||||
url: self.gatewayURL)
|
||||
Divider().padding(.leading, 58)
|
||||
self.docsLinkRow(
|
||||
title: "Pairing",
|
||||
detail: "Mobile setup codes, QR, and node approval.",
|
||||
icon: "qrcode",
|
||||
url: self.pairingURL)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var versionCard: some View {
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
HStack(spacing: 10) {
|
||||
Text("Version")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 8)
|
||||
Text("v\(DeviceInfoHelper.openClawVersionString())")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.primary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private func docsLinkRow(title: String, detail: String, icon: String, url: URL) -> some View {
|
||||
Link(destination: url) {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: OpenClawBrand.accent)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,23 @@
|
||||
import SwiftUI
|
||||
|
||||
enum OpenClawProMetric {
|
||||
static let pagePadding: CGFloat = 20
|
||||
static let cardRadius: CGFloat = 14
|
||||
static let controlRadius: CGFloat = 12
|
||||
static let pagePadding: CGFloat = 18
|
||||
static let cardRadius: CGFloat = 10
|
||||
static let controlRadius: CGFloat = 8
|
||||
static let bottomScrollInset: CGFloat = 96
|
||||
static let heroRadius: CGFloat = 22
|
||||
static let heroRadius: CGFloat = 12
|
||||
}
|
||||
|
||||
struct OpenClawProBackground: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
LinearGradient(
|
||||
colors: OpenClawBrand.canvasColors(for: self.colorScheme),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom)
|
||||
Color(uiColor: self.colorScheme == .dark ? .systemBackground : .systemGroupedBackground)
|
||||
.ignoresSafeArea()
|
||||
.overlay(alignment: .top) {
|
||||
if self.colorScheme == .light {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
OpenClawBrand.accent.opacity(0.05),
|
||||
OpenClawBrand.accent.opacity(0.02),
|
||||
.clear,
|
||||
],
|
||||
startPoint: .topTrailing,
|
||||
endPoint: .bottomLeading)
|
||||
.frame(height: 620)
|
||||
Color.white.opacity(0.22)
|
||||
.frame(height: 140)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
@@ -66,7 +56,7 @@ struct ProSectionHeader: View {
|
||||
struct ProCard<Content: View>: View {
|
||||
var tint: Color?
|
||||
var isProminent: Bool = false
|
||||
var padding: CGFloat = 14
|
||||
var padding: CGFloat = 12
|
||||
var radius: CGFloat = OpenClawProMetric.cardRadius
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
@@ -91,78 +81,39 @@ private struct ProPanelBackground: View {
|
||||
let shape = RoundedRectangle(cornerRadius: self.radius, style: .continuous)
|
||||
shape
|
||||
.fill(self.fill)
|
||||
.overlay {
|
||||
ProPanelTexture()
|
||||
.opacity(self.colorScheme == .dark ? 0.22 : 0.08)
|
||||
.clipShape(shape)
|
||||
}
|
||||
.overlay {
|
||||
shape.strokeBorder(self.borderStyle, lineWidth: 1)
|
||||
}
|
||||
.overlay {
|
||||
shape
|
||||
.strokeBorder(Color.black.opacity(self.colorScheme == .dark ? 0.40 : 0.055), lineWidth: 0.7)
|
||||
.padding(1)
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
shape
|
||||
.strokeBorder(Color.white.opacity(self.colorScheme == .dark ? 0.07 : 0.36), lineWidth: 0.7)
|
||||
.mask(alignment: .top) {
|
||||
Rectangle().frame(height: 28)
|
||||
}
|
||||
if self.isProminent {
|
||||
shape.strokeBorder(
|
||||
OpenClawBrand.accent.opacity(self.colorScheme == .dark ? 0.12 : 0.07),
|
||||
lineWidth: 1)
|
||||
.padding(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var fill: AnyShapeStyle {
|
||||
if self.colorScheme == .dark {
|
||||
let base = self.isProminent
|
||||
? Color(red: 15 / 255, green: 17 / 255, blue: 19 / 255)
|
||||
: Color(red: 10 / 255, green: 12 / 255, blue: 14 / 255)
|
||||
return AnyShapeStyle(base)
|
||||
let base = self.isProminent
|
||||
? Color(uiColor: .systemBackground)
|
||||
: Color(uiColor: .secondarySystemGroupedBackground)
|
||||
if let tint {
|
||||
let gradient = LinearGradient(
|
||||
colors: [
|
||||
base,
|
||||
tint.opacity(self.colorScheme == .dark ? 0.08 : 0.045),
|
||||
base,
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
return AnyShapeStyle(gradient)
|
||||
}
|
||||
|
||||
let gradient = LinearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.98),
|
||||
(self.tint ?? Color.white).opacity(self.tint == nil ? 0.92 : 0.12),
|
||||
Color(red: 246 / 255, green: 247 / 255, blue: 249 / 255),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
return AnyShapeStyle(gradient)
|
||||
return AnyShapeStyle(base)
|
||||
}
|
||||
|
||||
private var borderStyle: AnyShapeStyle {
|
||||
if self.colorScheme == .dark {
|
||||
return AnyShapeStyle(Color.white.opacity(self.isProminent ? 0.15 : 0.11))
|
||||
}
|
||||
|
||||
let gradient = LinearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.72),
|
||||
(self.tint ?? OpenClawBrand.accent).opacity(0.10),
|
||||
Color.black.opacity(0.08),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
return AnyShapeStyle(gradient)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProPanelTexture: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
let color = self.colorScheme == .dark ? Color.white.opacity(0.11) : Color.black.opacity(0.08)
|
||||
for y in stride(from: 2.0, through: size.height, by: 6.5) {
|
||||
let offset = Int(y / 6.5).isMultiple(of: 2) ? 0.0 : 3.25
|
||||
for x in stride(from: 2.0 + offset, through: size.width, by: 6.5) {
|
||||
let dot = CGRect(x: x, y: y, width: 0.7, height: 0.7)
|
||||
context.fill(Path(ellipseIn: dot), with: .color(color))
|
||||
}
|
||||
}
|
||||
}
|
||||
AnyShapeStyle(Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.26 : 0.30))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,9 +202,9 @@ private struct ProPanelSurfaceModifier: ViewModifier {
|
||||
}
|
||||
.modifier(ProLightGlassModifier(radius: self.radius))
|
||||
.shadow(
|
||||
color: self.colorScheme == .dark ? .black.opacity(0.60) : .black.opacity(0.045),
|
||||
radius: self.isProminent ? 20 : 12,
|
||||
y: self.isProminent ? 10 : 6)
|
||||
color: self.colorScheme == .dark ? .black.opacity(0.22) : .black.opacity(0.028),
|
||||
radius: self.isProminent ? 9 : 4,
|
||||
y: self.isProminent ? 4 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,16 +214,160 @@ struct ProIconBadge: View {
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: self.systemName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.color)
|
||||
.frame(width: 34, height: 34)
|
||||
.frame(width: 30, height: 30)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(self.color.opacity(0.12))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawSidebarHeaderAction {
|
||||
let systemName: String
|
||||
let accessibilityLabel: String
|
||||
let accessibilityIdentifier: String?
|
||||
let action: () -> Void
|
||||
|
||||
init(
|
||||
systemName: String,
|
||||
accessibilityLabel: String,
|
||||
accessibilityIdentifier: String? = nil,
|
||||
action: @escaping () -> Void)
|
||||
{
|
||||
self.systemName = systemName
|
||||
self.accessibilityLabel = accessibilityLabel
|
||||
self.accessibilityIdentifier = accessibilityIdentifier
|
||||
self.action = action
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawSidebarRevealButton: View {
|
||||
let headerAction: OpenClawSidebarHeaderAction
|
||||
|
||||
init(action: OpenClawSidebarHeaderAction) {
|
||||
self.headerAction = action
|
||||
}
|
||||
|
||||
init(action: @escaping () -> Void) {
|
||||
self.headerAction = OpenClawSidebarHeaderAction(
|
||||
systemName: "sidebar.left",
|
||||
accessibilityLabel: "Show Sidebar",
|
||||
action: action)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let button = Button(action: self.headerAction.action) {
|
||||
Image(systemName: self.headerAction.systemName)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.frame(width: 38, height: 38)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
.accessibilityLabel(self.headerAction.accessibilityLabel)
|
||||
|
||||
if let accessibilityIdentifier = self.headerAction.accessibilityIdentifier {
|
||||
button.accessibilityIdentifier(accessibilityIdentifier)
|
||||
} else {
|
||||
button
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawSidebarHeaderLeadingSlot: View {
|
||||
let action: OpenClawSidebarHeaderAction
|
||||
|
||||
var body: some View {
|
||||
OpenClawSidebarRevealButton(action: self.action)
|
||||
.frame(width: 44, height: 44, alignment: .center)
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawAdaptiveHeaderRow<Leading: View, Accessory: View>: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
var titleFont: Font = .title3.weight(.semibold)
|
||||
var subtitleFont: Font = .subheadline
|
||||
var subtitleLineLimit: Int? = 2
|
||||
@ViewBuilder let leading: Leading
|
||||
@ViewBuilder let accessory: Accessory
|
||||
|
||||
init(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
titleFont: Font = .title3.weight(.semibold),
|
||||
subtitleFont: Font = .subheadline,
|
||||
subtitleLineLimit: Int? = 2,
|
||||
@ViewBuilder leading: () -> Leading,
|
||||
@ViewBuilder accessory: () -> Accessory)
|
||||
{
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.titleFont = titleFont
|
||||
self.subtitleFont = subtitleFont
|
||||
self.subtitleLineLimit = subtitleLineLimit
|
||||
self.leading = leading()
|
||||
self.accessory = accessory()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ViewThatFits(in: .horizontal) {
|
||||
self.horizontalLayout
|
||||
self.stackedLayout
|
||||
}
|
||||
}
|
||||
|
||||
private var horizontalLayout: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
self.leading
|
||||
|
||||
self.titleBlock
|
||||
.layoutPriority(1)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
self.accessory
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
}
|
||||
|
||||
private var stackedLayout: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
self.leading
|
||||
|
||||
self.titleBlock
|
||||
.layoutPriority(1)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
self.accessory
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var titleBlock: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.title)
|
||||
.font(self.titleFont)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.86)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(self.subtitle)
|
||||
.font(self.subtitleFont)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(self.subtitleLineLimit)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProStatusDot: View {
|
||||
var color: Color
|
||||
|
||||
@@ -280,7 +375,6 @@ struct ProStatusDot: View {
|
||||
Circle()
|
||||
.fill(self.color)
|
||||
.frame(width: 8, height: 8)
|
||||
.shadow(color: self.color.opacity(0.35), radius: 4)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,7 +406,7 @@ struct OpenClawProMark: View {
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: self.size, height: self.size)
|
||||
.shadow(color: OpenClawBrand.accent.opacity(0.28), radius: self.shadowRadius, y: self.shadowRadius / 2)
|
||||
.shadow(color: OpenClawBrand.accent.opacity(0.18), radius: self.shadowRadius, y: self.shadowRadius / 3)
|
||||
.accessibilityLabel("OpenClaw")
|
||||
}
|
||||
}
|
||||
@@ -390,7 +484,10 @@ struct ProCapsule: View {
|
||||
}
|
||||
Text(self.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
}
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.foregroundStyle(self.color)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
@@ -405,6 +502,57 @@ struct ProCapsule: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawGatewayCompactPill: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
|
||||
var body: some View {
|
||||
ProCapsule(
|
||||
title: self.title,
|
||||
color: self.color,
|
||||
icon: self.icon)
|
||||
.accessibilityLabel("Gateway \(self.title)")
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
switch GatewayStatusBuilder.build(appModel: self.appModel) {
|
||||
case .connected:
|
||||
"Online"
|
||||
case .connecting:
|
||||
"Connecting"
|
||||
case .error:
|
||||
"Attention"
|
||||
case .disconnected:
|
||||
"Offline"
|
||||
}
|
||||
}
|
||||
|
||||
private var color: Color {
|
||||
switch GatewayStatusBuilder.build(appModel: self.appModel) {
|
||||
case .connected:
|
||||
OpenClawBrand.ok
|
||||
case .connecting:
|
||||
OpenClawBrand.accent
|
||||
case .error:
|
||||
OpenClawBrand.warn
|
||||
case .disconnected:
|
||||
.secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var icon: String {
|
||||
switch GatewayStatusBuilder.build(appModel: self.appModel) {
|
||||
case .connected:
|
||||
"checkmark.circle.fill"
|
||||
case .connecting:
|
||||
"arrow.triangle.2.circlepath"
|
||||
case .error:
|
||||
"exclamationmark.triangle.fill"
|
||||
case .disconnected:
|
||||
"wifi.slash"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProSegmentedControl: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let labels: [String]
|
||||
@@ -531,28 +679,120 @@ struct ProMetricTile: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct ProMetric: Identifiable {
|
||||
let id = UUID()
|
||||
let icon: String
|
||||
let title: String
|
||||
let value: String
|
||||
let color: Color
|
||||
}
|
||||
|
||||
struct ProMetricGrid: View {
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
let metrics: [ProMetric]
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(
|
||||
columns: Array(repeating: GridItem(.flexible()), count: self.columnCount),
|
||||
spacing: 10)
|
||||
{
|
||||
ForEach(self.metrics) { metric in
|
||||
ProMetricTile(
|
||||
title: metric.title,
|
||||
value: metric.value,
|
||||
icon: metric.icon,
|
||||
color: metric.color)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var columnCount: Int {
|
||||
guard self.horizontalSizeClass != .compact else { return 1 }
|
||||
return min(max(self.metrics.count, 1), 3)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProPanelHeader: View {
|
||||
let title: String
|
||||
var value: String?
|
||||
var actionTitle: String?
|
||||
var actionIcon: String?
|
||||
var actionAccessibilityLabel: String?
|
||||
var isActionDisabled = false
|
||||
var action: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
if let value {
|
||||
Text(value)
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
self.actionControl
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var actionControl: some View {
|
||||
if let action {
|
||||
if let actionIcon {
|
||||
Button(action: action) {
|
||||
Image(systemName: actionIcon)
|
||||
}
|
||||
.accessibilityLabel(self.actionAccessibilityLabel ?? self.actionTitle ?? self.title)
|
||||
.disabled(self.isActionDisabled)
|
||||
} else if let actionTitle {
|
||||
Button(actionTitle, action: action)
|
||||
.font(.caption.weight(.semibold))
|
||||
.disabled(self.isActionDisabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProStatusRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let value: String
|
||||
let value: String?
|
||||
let color: Color
|
||||
var actionTitle: String?
|
||||
var action: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: self.icon, color: self.color)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(self.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: self.value, color: self.color)
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
if let value {
|
||||
ProValuePill(value: value, color: self.color)
|
||||
}
|
||||
if let actionTitle, let action {
|
||||
Button(actionTitle, action: action)
|
||||
.font(.caption.weight(.semibold))
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 11)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
417
apps/ios/Sources/Design/RootTabsPhoneControlHub.swift
Normal file
417
apps/ios/Sources/Design/RootTabsPhoneControlHub.swift
Normal file
@@ -0,0 +1,417 @@
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
struct RootTabsPhoneControlHub: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
@State private var navigationPath: [RootTabs.SidebarDestination] = []
|
||||
@State private var didApplyInitialDestination = false
|
||||
|
||||
let groups: [RootTabs.SidebarGroup]
|
||||
let initialDestination: RootTabs.SidebarDestination?
|
||||
let openRootDestination: (RootTabs.SidebarDestination) -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: self.$navigationPath) {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: self.isCompactHeight ? 10 : 16) {
|
||||
self.headerCard
|
||||
ForEach(self.groups) { group in
|
||||
self.groupSection(group)
|
||||
}
|
||||
self.versionFooter
|
||||
}
|
||||
.padding(.vertical, self.isCompactHeight ? 10 : 16)
|
||||
}
|
||||
.safeAreaPadding(.bottom, self.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Control")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationDestination(for: RootTabs.SidebarDestination.self) { destination in
|
||||
self.detail(for: destination)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
}
|
||||
.onAppear {
|
||||
self.applyInitialDestinationIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var headerCard: some View {
|
||||
if self.isCompactHeight {
|
||||
ProCard(padding: 8, radius: OpenClawProMetric.cardRadius) {
|
||||
HStack(spacing: 12) {
|
||||
OpenClawProMark(size: 24, shadowRadius: 3)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.sidebarActiveAgentTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(self.gatewayDisplayLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: self.gatewayStateText, color: self.gatewayStateColor)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
} else {
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
OpenClawProMark(size: 32, shadowRadius: 4)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.sidebarActiveAgentTitle)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
Text(self.gatewayDisplayLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: self.gatewayStateText, color: self.gatewayStateColor)
|
||||
}
|
||||
|
||||
self.gatewayActionRow
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayActionRow: some View {
|
||||
Button {
|
||||
self.openRootDestination(.gateway)
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
ProStatusDot(color: self.gatewayStateColor)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.gatewayStateText)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text(self.gatewayDisplayLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(self.gatewayActionTitle)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Gateway \(self.gatewayStateText)")
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
}
|
||||
|
||||
private func groupSection(_ group: RootTabs.SidebarGroup) -> some View {
|
||||
VStack(alignment: .leading, spacing: self.isCompactHeight ? 6 : 8) {
|
||||
ProSectionHeader(title: group.title.capitalized)
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(group.destinations.enumerated()), id: \.element.id) { index, destination in
|
||||
if index > 0 {
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
self.destinationRow(destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func destinationRow(_ destination: RootTabs.SidebarDestination) -> some View {
|
||||
if self.opensRootTab(destination) {
|
||||
Button {
|
||||
self.openRootDestination(destination)
|
||||
} label: {
|
||||
self.rowLabel(destination)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
Button {
|
||||
self.navigationPath.append(destination)
|
||||
} label: {
|
||||
self.rowLabel(destination)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private func rowLabel(_ destination: RootTabs.SidebarDestination) -> some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
ProIconBadge(systemName: destination.systemImage, color: self.color(for: destination))
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(destination.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text(destination.subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, self.isCompactHeight ? 8 : 10)
|
||||
.padding(.horizontal, 14)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private var versionFooter: some View {
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("v\(DeviceInfoHelper.openClawVersionString())")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func detail(for destination: RootTabs.SidebarDestination) -> some View {
|
||||
switch destination {
|
||||
case .chat, .talk, .agents, .gateway:
|
||||
EmptyView()
|
||||
case .overview:
|
||||
CommandCenterTab(
|
||||
headerTitle: "Overview",
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
showsHeaderMark: false,
|
||||
openChat: { self.openRootDestination(.chat) },
|
||||
openSettings: { self.openRootDestination(.gateway) })
|
||||
case .activity:
|
||||
IPadActivityScreen(
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
openChat: { self.openRootDestination(.chat) },
|
||||
openSettings: { self.openRootDestination(.gateway) })
|
||||
case .workboard:
|
||||
IPadWorkboardScreen(
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
openChat: { self.openRootDestination(.chat) },
|
||||
openSettings: { self.openRootDestination(.gateway) })
|
||||
case .skillWorkshop:
|
||||
IPadSkillWorkshopScreen(
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
openSettings: { self.openRootDestination(.gateway) })
|
||||
case .instances:
|
||||
AgentProTab(
|
||||
directRoute: .instances,
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
headerTitle: "Instances",
|
||||
openSettings: { self.openRootDestination(.gateway) })
|
||||
case .sessions:
|
||||
CommandSessionsScreen(
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
openChat: { self.openRootDestination(.chat) })
|
||||
case .dreaming:
|
||||
AgentProTab(
|
||||
directRoute: .dreaming,
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
headerTitle: "Dreaming",
|
||||
openSettings: { self.openRootDestination(.gateway) })
|
||||
case .usage:
|
||||
AgentProTab(
|
||||
directRoute: .usage,
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
headerTitle: "Usage",
|
||||
openSettings: { self.openRootDestination(.gateway) })
|
||||
case .cron:
|
||||
AgentProTab(
|
||||
directRoute: .cron,
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
headerTitle: "Cron Jobs",
|
||||
openSettings: { self.openRootDestination(.gateway) })
|
||||
case .docs:
|
||||
OpenClawDocsScreen(
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
gatewayAction: { self.openRootDestination(.gateway) })
|
||||
case .settings:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private var phoneDetailBackAction: OpenClawSidebarHeaderAction {
|
||||
OpenClawSidebarHeaderAction(
|
||||
systemName: "chevron.left",
|
||||
accessibilityLabel: "Back to Control",
|
||||
accessibilityIdentifier: "OpenClawPhoneDetailBackButton",
|
||||
action: { self.popPhoneDetail() })
|
||||
}
|
||||
|
||||
private func popPhoneDetail() {
|
||||
guard !self.navigationPath.isEmpty else { return }
|
||||
self.navigationPath.removeLast()
|
||||
}
|
||||
|
||||
private func opensRootTab(_ destination: RootTabs.SidebarDestination) -> Bool {
|
||||
RootTabs.shouldOpenRootTabFromPhoneHub(destination)
|
||||
}
|
||||
|
||||
private func applyInitialDestinationIfNeeded() {
|
||||
guard !self.didApplyInitialDestination else { return }
|
||||
self.didApplyInitialDestination = true
|
||||
guard let initialDestination, initialDestination != .overview else { return }
|
||||
if self.opensRootTab(initialDestination) {
|
||||
self.openRootDestination(initialDestination)
|
||||
} else {
|
||||
self.navigationPath = [initialDestination]
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebarActiveAgentTitle: String {
|
||||
let selectedID = self.normalized(self.appModel.selectedAgentId) ?? self.resolveDefaultAgentID()
|
||||
if let agent = self.appModel.gatewayAgents.first(where: { $0.id == selectedID }) {
|
||||
return self.agentTitle(for: agent)
|
||||
}
|
||||
return self.normalized(self.appModel.activeAgentName) ?? "Default Agent"
|
||||
}
|
||||
|
||||
private var gatewayDisplayLabel: String {
|
||||
self.normalized(self.appModel.gatewayServerName)
|
||||
?? self.normalized(self.appModel.gatewayRemoteAddress)
|
||||
?? self.appModel.gatewayDisplayStatusText
|
||||
}
|
||||
|
||||
private var gatewayStateText: String {
|
||||
switch GatewayStatusBuilder.build(appModel: self.appModel) {
|
||||
case .connected: "Online"
|
||||
case .connecting: "Connecting"
|
||||
case .error: "Attention"
|
||||
case .disconnected: "Offline"
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayStateColor: Color {
|
||||
switch GatewayStatusBuilder.build(appModel: self.appModel) {
|
||||
case .connected:
|
||||
OpenClawBrand.ok
|
||||
case .connecting:
|
||||
OpenClawBrand.accent
|
||||
case .error:
|
||||
OpenClawBrand.warn
|
||||
case .disconnected:
|
||||
.secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayActionTitle: String {
|
||||
switch GatewayStatusBuilder.build(appModel: self.appModel) {
|
||||
case .connected:
|
||||
"Manage"
|
||||
case .connecting:
|
||||
"Details"
|
||||
case .error:
|
||||
"Fix"
|
||||
case .disconnected:
|
||||
"Connect"
|
||||
}
|
||||
}
|
||||
|
||||
private var isCompactHeight: Bool {
|
||||
self.verticalSizeClass == .compact
|
||||
}
|
||||
|
||||
private var bottomScrollInset: CGFloat {
|
||||
Self.bottomScrollInset(verticalSizeClass: self.verticalSizeClass)
|
||||
}
|
||||
|
||||
static func bottomScrollInset(verticalSizeClass: UserInterfaceSizeClass?) -> CGFloat {
|
||||
verticalSizeClass == .compact ? 72 : 112
|
||||
}
|
||||
|
||||
private func color(for destination: RootTabs.SidebarDestination) -> Color {
|
||||
switch destination {
|
||||
case .chat, .talk, .overview, .gateway:
|
||||
OpenClawBrand.accent
|
||||
case .instances:
|
||||
Color.secondary
|
||||
case .activity, .usage, .docs:
|
||||
OpenClawBrand.accentHot
|
||||
case .agents, .workboard, .skillWorkshop, .sessions, .dreaming, .cron, .settings:
|
||||
OpenClawBrand.ok
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveDefaultAgentID() -> String {
|
||||
self.normalized(self.appModel.gatewayDefaultAgentId) ?? ""
|
||||
}
|
||||
|
||||
private func agentTitle(for agent: AgentSummary) -> String {
|
||||
let name = self.normalized(agent.name) ?? agent.id
|
||||
return name == agent.id ? name : "\(name) (\(agent.id))"
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Phone control hub offline") {
|
||||
RootTabsPhoneControlHub.preview(appModel: NodeAppModel())
|
||||
}
|
||||
|
||||
#Preview("Phone control hub connected") {
|
||||
let appModel = NodeAppModel()
|
||||
appModel.enterAppleReviewDemoMode()
|
||||
return RootTabsPhoneControlHub.preview(appModel: appModel)
|
||||
}
|
||||
|
||||
#Preview("Phone control hub connecting") {
|
||||
let appModel = NodeAppModel()
|
||||
appModel.gatewayStatusText = "Connecting..."
|
||||
return RootTabsPhoneControlHub.preview(appModel: appModel)
|
||||
}
|
||||
|
||||
#Preview("Phone control hub gateway error") {
|
||||
let appModel = NodeAppModel()
|
||||
appModel.gatewayStatusText = "Gateway error: connection refused"
|
||||
return RootTabsPhoneControlHub.preview(appModel: appModel)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Phone control hub landscape",
|
||||
traits: .fixedLayout(width: 852, height: 393),
|
||||
.landscapeLeft)
|
||||
{
|
||||
RootTabsPhoneControlHub.preview(appModel: NodeAppModel())
|
||||
.environment(\.horizontalSizeClass, .regular)
|
||||
.environment(\.verticalSizeClass, .compact)
|
||||
}
|
||||
|
||||
extension RootTabsPhoneControlHub {
|
||||
fileprivate static func preview(appModel: NodeAppModel) -> some View {
|
||||
RootTabsPhoneControlHub(
|
||||
groups: RootTabs.phoneControlGroups,
|
||||
initialDestination: nil,
|
||||
openRootDestination: { _ in })
|
||||
.environment(appModel)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
726
apps/ios/Sources/Design/SettingsChannelsDestination.swift
Normal file
726
apps/ios/Sources/Design/SettingsChannelsDestination.swift
Normal file
@@ -0,0 +1,726 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsChannelsDestination: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
let showsSummaryCard: Bool
|
||||
@State private var snapshot: ChannelsStatusResult?
|
||||
@State private var isLoading = false
|
||||
@State private var errorText: String?
|
||||
@State private var busyOperation: SettingsChannelOperation?
|
||||
|
||||
init(showsSummaryCard: Bool = true) {
|
||||
self.showsSummaryCard = showsSummaryCard
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if self.showsSummaryCard {
|
||||
self.summaryCard
|
||||
}
|
||||
self.channelsCard
|
||||
}
|
||||
.task(id: self.refreshID) {
|
||||
await self.loadChannels(force: false)
|
||||
}
|
||||
.refreshable {
|
||||
await self.loadChannels(force: true)
|
||||
}
|
||||
}
|
||||
|
||||
private var summaryCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: "point.3.connected.trianglepath.dotted", color: self.summaryColor)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Channels / Integrations")
|
||||
.font(.headline)
|
||||
Text(self.summaryDetail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: self.summaryValue, color: self.summaryColor)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var channelsCard: some View {
|
||||
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ProPanelHeader(
|
||||
title: "Message Routing",
|
||||
value: self.headerValue,
|
||||
actionIcon: self.isLoading ? "hourglass" : "arrow.clockwise",
|
||||
actionAccessibilityLabel: "Refresh Channels",
|
||||
isActionDisabled: self.isLoading,
|
||||
action: {
|
||||
Task { await self.loadChannels(force: true) }
|
||||
})
|
||||
|
||||
if let errorText {
|
||||
ProStatusRow(
|
||||
icon: "exclamationmark.triangle",
|
||||
title: "Channel status unavailable",
|
||||
detail: errorText,
|
||||
value: "error",
|
||||
color: OpenClawBrand.warn)
|
||||
} else if !self.canRead {
|
||||
ProStatusRow(
|
||||
icon: "wifi.slash",
|
||||
title: "Gateway offline",
|
||||
detail: "Connect to the gateway to load installed channels, accounts, and routing status.",
|
||||
value: "offline",
|
||||
color: .secondary)
|
||||
} else if self.isLoading, self.snapshot == nil {
|
||||
ProStatusRow(
|
||||
icon: "hourglass",
|
||||
title: "Loading channels",
|
||||
detail: "Fetching installed channels, accounts, and routing status from the gateway.",
|
||||
value: "loading",
|
||||
color: OpenClawBrand.accent)
|
||||
} else if self.channelEntries.isEmpty {
|
||||
ProStatusRow(
|
||||
icon: "tray",
|
||||
title: "No channel plugins reported",
|
||||
detail: "Install or enable channel plugins on the gateway, then refresh.",
|
||||
value: "empty",
|
||||
color: .secondary)
|
||||
} else {
|
||||
ForEach(Array(self.channelEntries.enumerated()), id: \.element.id) { index, entry in
|
||||
if index > 0 {
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
SettingsChannelRow(
|
||||
entry: entry,
|
||||
canAdmin: self.canAdmin,
|
||||
busyOperation: self.busyOperation,
|
||||
start: { accountID in
|
||||
Task { await self.run(.start, channelID: entry.id, accountID: accountID) }
|
||||
},
|
||||
stop: { accountID in
|
||||
Task { await self.run(.stop, channelID: entry.id, accountID: accountID) }
|
||||
},
|
||||
logout: { accountID in
|
||||
Task { await self.run(.logout, channelID: entry.id, accountID: accountID) }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var refreshID: String {
|
||||
[
|
||||
self.canRead ? "connected" : "offline",
|
||||
self.scenePhase == .active ? "active" : "inactive",
|
||||
].joined(separator: ":")
|
||||
}
|
||||
|
||||
private var canRead: Bool {
|
||||
self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var canAdmin: Bool {
|
||||
self.appModel.hasOperatorAdminScope
|
||||
}
|
||||
|
||||
static func shouldEnableChannelOperation(canRead: Bool, hasOperatorAdminScope: Bool) -> Bool {
|
||||
canRead && hasOperatorAdminScope
|
||||
}
|
||||
|
||||
private var headerValue: String? {
|
||||
if self.isLoading { return "Loading" }
|
||||
guard self.canRead else { return "Offline" }
|
||||
return "\(self.channelEntries.count)"
|
||||
}
|
||||
|
||||
private var summaryDetail: String {
|
||||
guard self.canRead else {
|
||||
return "Connect to load channel integrations."
|
||||
}
|
||||
if let errorText {
|
||||
return errorText
|
||||
}
|
||||
return "Installed channel clients, account state, and message-routing readiness."
|
||||
}
|
||||
|
||||
private var summaryValue: String {
|
||||
guard self.canRead else { return "offline" }
|
||||
if self.isLoading { return "loading" }
|
||||
if self.errorText != nil { return "error" }
|
||||
let configured = self.channelEntries.count(where: { $0.configured })
|
||||
return "\(configured)/\(self.channelEntries.count)"
|
||||
}
|
||||
|
||||
private var summaryColor: Color {
|
||||
guard self.canRead else { return .secondary }
|
||||
if self.errorText != nil { return OpenClawBrand.warn }
|
||||
return self.channelEntries.contains(where: { $0.running || $0.connected }) ? OpenClawBrand.ok : OpenClawBrand
|
||||
.accent
|
||||
}
|
||||
|
||||
private var channelEntries: [SettingsChannelEntry] {
|
||||
guard let snapshot else { return [] }
|
||||
let ids = snapshot.channelorder.isEmpty ? Array(snapshot.channels.keys).sorted() : snapshot.channelorder
|
||||
return ids.map { self.entry(channelID: $0, snapshot: snapshot) }
|
||||
}
|
||||
|
||||
private func entry(channelID: String, snapshot: ChannelsStatusResult) -> SettingsChannelEntry {
|
||||
let summary = snapshot.channels[channelID]?.dictionaryValue ?? [:]
|
||||
let accounts = self.accounts(channelID: channelID, snapshot: snapshot)
|
||||
let configured = accounts.contains(where: \.configured) || summary["configured"]?.boolValue == true
|
||||
let running = accounts.contains(where: \.running)
|
||||
let connected = accounts.contains(where: \.connected)
|
||||
let linked = accounts.contains(where: \.linked)
|
||||
let label = snapshot.channellabels[channelID]?.stringValue ?? Self.fallbackLabel(channelID)
|
||||
let detail = snapshot.channeldetaillabels?[channelID]?.stringValue ?? Self.fallbackDetail(channelID)
|
||||
let systemImage = snapshot.channelsystemimages?[channelID]?.stringValue ?? Self.fallbackSystemImage(channelID)
|
||||
let lastActivity = accounts.compactMap(\.lastActivityMs).max()
|
||||
let lastError = accounts.compactMap(\.lastError).first ?? summary["lastError"]?.stringValue
|
||||
return SettingsChannelEntry(
|
||||
id: channelID,
|
||||
label: label,
|
||||
detail: detail,
|
||||
systemImage: systemImage,
|
||||
configured: configured,
|
||||
running: running,
|
||||
connected: connected,
|
||||
linked: linked,
|
||||
lastActivityText: lastActivity.map(Self.relativeTime),
|
||||
lastError: lastError,
|
||||
unavailableReason: configured ? nil : "Configure this channel on the gateway.",
|
||||
accounts: accounts)
|
||||
}
|
||||
|
||||
private func accounts(channelID: String, snapshot: ChannelsStatusResult) -> [SettingsChannelAccount] {
|
||||
let rawAccounts = snapshot.channelaccounts[channelID]?.arrayValue ?? []
|
||||
return rawAccounts.compactMap { raw in
|
||||
guard let dict = raw.dictionaryValue else { return nil }
|
||||
let accountID = dict["accountId"]?.stringValue ?? "default"
|
||||
let name = dict["name"]?.stringValue
|
||||
let lastActivity = [
|
||||
dict["lastInboundAt"]?.intValue,
|
||||
dict["lastOutboundAt"]?.intValue,
|
||||
dict["lastTransportActivityAt"]?.intValue,
|
||||
]
|
||||
.compactMap(\.self)
|
||||
.max()
|
||||
return SettingsChannelAccount(
|
||||
id: accountID,
|
||||
name: name,
|
||||
configured: dict["configured"]?.boolValue == true,
|
||||
enabled: dict["enabled"]?.boolValue != false,
|
||||
running: dict["running"]?.boolValue == true,
|
||||
connected: dict["connected"]?.boolValue == true,
|
||||
linked: dict["linked"]?.boolValue == true,
|
||||
healthState: dict["healthState"]?.stringValue,
|
||||
lastError: dict["lastError"]?.stringValue,
|
||||
lastActivityMs: lastActivity)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadChannels(force: Bool) async {
|
||||
guard self.scenePhase == .active else { return }
|
||||
guard self.canRead else {
|
||||
self.snapshot = nil
|
||||
self.errorText = nil
|
||||
return
|
||||
}
|
||||
if self.isLoading { return }
|
||||
|
||||
self.isLoading = true
|
||||
self.errorText = nil
|
||||
defer { self.isLoading = false }
|
||||
|
||||
do {
|
||||
let params = ChannelsStatusParams(probe: false, timeoutms: 10000, channel: nil)
|
||||
let data = try await self.request(method: "channels.status", params: params, timeoutSeconds: 12)
|
||||
self.snapshot = try JSONDecoder().decode(ChannelsStatusResult.self, from: data)
|
||||
} catch {
|
||||
if force || self.snapshot == nil {
|
||||
self.errorText = Self.message(for: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func run(_ kind: SettingsChannelOperation.Kind, channelID: String, accountID: String?) async {
|
||||
guard Self.shouldEnableChannelOperation(canRead: self.canRead, hasOperatorAdminScope: self.canAdmin),
|
||||
self.busyOperation == nil
|
||||
else {
|
||||
return
|
||||
}
|
||||
self.busyOperation = SettingsChannelOperation(kind: kind, channelID: channelID, accountID: accountID)
|
||||
self.errorText = nil
|
||||
defer { self.busyOperation = nil }
|
||||
|
||||
do {
|
||||
switch kind {
|
||||
case .start:
|
||||
let params = ChannelsStartParams(channel: channelID, accountid: accountID)
|
||||
_ = try await self.request(method: "channels.start", params: params, timeoutSeconds: 20)
|
||||
case .stop:
|
||||
let params = ChannelsStopParams(channel: channelID, accountid: accountID)
|
||||
_ = try await self.request(method: "channels.stop", params: params, timeoutSeconds: 20)
|
||||
case .logout:
|
||||
let params = ChannelsLogoutParams(channel: channelID, accountid: accountID)
|
||||
_ = try await self.request(method: "channels.logout", params: params, timeoutSeconds: 20)
|
||||
}
|
||||
await self.loadChannels(force: true)
|
||||
} catch {
|
||||
self.errorText = Self.message(for: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func request(method: String, params: some Encodable, timeoutSeconds: Int) async throws -> Data {
|
||||
let data = try JSONEncoder().encode(params)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
throw SettingsChannelError.invalidPayload
|
||||
}
|
||||
return try await self.appModel.operatorSession.request(
|
||||
method: method,
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
|
||||
static func fallbackLabel(_ id: String) -> String {
|
||||
if let metadata = self.fallbackMetadata[id.lowercased()] {
|
||||
return metadata.label
|
||||
}
|
||||
return id.replacingOccurrences(of: "-", with: " ")
|
||||
.replacingOccurrences(of: "_", with: " ")
|
||||
.split(separator: " ")
|
||||
.map { $0.prefix(1).uppercased() + $0.dropFirst() }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
|
||||
static func fallbackDetail(_ id: String) -> String {
|
||||
self.fallbackMetadata[id.lowercased()]?.detail ?? "Channel integration"
|
||||
}
|
||||
|
||||
static func fallbackSystemImage(_ id: String) -> String {
|
||||
self.fallbackMetadata[id.lowercased()]?.systemImage ?? "bubble.left.and.text.bubble.right"
|
||||
}
|
||||
|
||||
private static let fallbackMetadata: [String: SettingsChannelFallbackMetadata] = [
|
||||
"clickclack": SettingsChannelFallbackMetadata(
|
||||
label: "ClickClack",
|
||||
detail: "Self-hosted chat bot routing.",
|
||||
systemImage: "bubble.left.and.bubble.right"),
|
||||
]
|
||||
|
||||
private static func relativeTime(_ milliseconds: Int) -> String {
|
||||
let age = max(0, Int(Date().timeIntervalSince1970 * 1000) - milliseconds)
|
||||
let minutes = age / 60000
|
||||
if minutes < 1 { return "now" }
|
||||
if minutes < 60 { return "\(minutes)m ago" }
|
||||
let hours = minutes / 60
|
||||
if hours < 24 { return "\(hours)h ago" }
|
||||
return "\(hours / 24)d ago"
|
||||
}
|
||||
|
||||
private static func message(for error: Error) -> String {
|
||||
if let channelError = error as? SettingsChannelError {
|
||||
return channelError.message
|
||||
}
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsChannelsScreen: View {
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let gatewayAction: (() -> Void)?
|
||||
|
||||
init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, gatewayAction: (() -> Void)? = nil) {
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.gatewayAction = gatewayAction
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
SettingsChannelsDestination(showsSummaryCard: false)
|
||||
}
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Channels")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("Channels / Integrations")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Message routing and external channel clients.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
self.gatewayPill
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var gatewayPill: some View {
|
||||
if let gatewayAction {
|
||||
Button(action: gatewayAction) {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsChannelRow: View {
|
||||
let entry: SettingsChannelEntry
|
||||
let canAdmin: Bool
|
||||
let busyOperation: SettingsChannelOperation?
|
||||
let start: (String?) -> Void
|
||||
let stop: (String?) -> Void
|
||||
let logout: (String?) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: self.entry.systemImage, color: self.entry.color)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.entry.label)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(self.entry.detailText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if let lastError = self.entry.lastError {
|
||||
Text(lastError)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: self.entry.statusValue, color: self.entry.color)
|
||||
}
|
||||
|
||||
if !self.entry.accounts.isEmpty {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(self.entry.accounts.enumerated()), id: \.element.id) { index, account in
|
||||
if index > 0 {
|
||||
Divider().padding(.leading, 38)
|
||||
}
|
||||
self.accountRow(account)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private func accountRow(_ account: SettingsChannelAccount) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: account.running || account.connected ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(account.color)
|
||||
.frame(width: 28, height: 28)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(account.displayName)
|
||||
.font(.caption.weight(.semibold))
|
||||
Text(account.detailText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Menu {
|
||||
if account.running {
|
||||
Button("Stop") {
|
||||
self.stop(account.id)
|
||||
}
|
||||
} else {
|
||||
Button("Start") {
|
||||
self.start(account.id)
|
||||
}
|
||||
.disabled(!account.configured || !account.enabled)
|
||||
}
|
||||
if account.linked {
|
||||
Button("Logout", role: .destructive) {
|
||||
self.logout(account.id)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: self.actionMenuIcon(account))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.mini)
|
||||
.disabled(!self.canAdmin || self.isBusy(account))
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func actionMenuIcon(_ account: SettingsChannelAccount) -> String {
|
||||
if self.isBusy(account) {
|
||||
return "hourglass"
|
||||
}
|
||||
if !self.canAdmin {
|
||||
return "lock.shield"
|
||||
}
|
||||
return "ellipsis.circle"
|
||||
}
|
||||
|
||||
private func isBusy(_ account: SettingsChannelAccount) -> Bool {
|
||||
self.busyOperation?.channelID == self.entry.id && self.busyOperation?.accountID == account.id
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsChannelEntry: Identifiable {
|
||||
let id: String
|
||||
let label: String
|
||||
let detail: String
|
||||
let systemImage: String
|
||||
let configured: Bool
|
||||
let running: Bool
|
||||
let connected: Bool
|
||||
let linked: Bool
|
||||
let lastActivityText: String?
|
||||
let lastError: String?
|
||||
let unavailableReason: String?
|
||||
let accounts: [SettingsChannelAccount]
|
||||
|
||||
var color: Color {
|
||||
if self.connected || self.running { return OpenClawBrand.ok }
|
||||
if self.lastError != nil { return OpenClawBrand.warn }
|
||||
return self.configured ? OpenClawBrand.accent : .secondary
|
||||
}
|
||||
|
||||
var statusValue: String {
|
||||
if self.connected { return "connected" }
|
||||
if self.running { return "running" }
|
||||
if self.linked { return "linked" }
|
||||
if self.configured { return "configured" }
|
||||
return "not set"
|
||||
}
|
||||
|
||||
var detailText: String {
|
||||
if let lastActivityText {
|
||||
return "\(self.detail) • active \(lastActivityText)"
|
||||
}
|
||||
if let unavailableReason {
|
||||
return unavailableReason
|
||||
}
|
||||
return self.detail
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsChannelFallbackMetadata {
|
||||
let label: String
|
||||
let detail: String
|
||||
let systemImage: String
|
||||
}
|
||||
|
||||
private struct SettingsChannelAccount: Identifiable {
|
||||
let id: String
|
||||
let name: String?
|
||||
let configured: Bool
|
||||
let enabled: Bool
|
||||
let running: Bool
|
||||
let connected: Bool
|
||||
let linked: Bool
|
||||
let healthState: String?
|
||||
let lastError: String?
|
||||
let lastActivityMs: Int?
|
||||
|
||||
var displayName: String {
|
||||
let trimmedName = self.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmedName.isEmpty ? self.id : "\(trimmedName) (\(self.id))"
|
||||
}
|
||||
|
||||
var detailText: String {
|
||||
let state = if self.connected {
|
||||
"connected"
|
||||
} else if self.running {
|
||||
"running"
|
||||
} else if self.linked {
|
||||
"linked"
|
||||
} else if self.configured {
|
||||
"configured"
|
||||
} else {
|
||||
"not configured"
|
||||
}
|
||||
let enabledText = self.enabled ? "enabled" : "disabled"
|
||||
if let healthState, !healthState.isEmpty {
|
||||
return "\(state), \(enabledText), \(healthState)"
|
||||
}
|
||||
if let lastError, !lastError.isEmpty {
|
||||
return "\(state), \(enabledText), error"
|
||||
}
|
||||
return "\(state), \(enabledText)"
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
if self.connected || self.running { return OpenClawBrand.ok }
|
||||
if self.lastError != nil { return OpenClawBrand.warn }
|
||||
return self.configured ? OpenClawBrand.accent : .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsChannelOperation: Equatable {
|
||||
enum Kind {
|
||||
case start
|
||||
case stop
|
||||
case logout
|
||||
}
|
||||
|
||||
let kind: Kind
|
||||
let channelID: String
|
||||
let accountID: String?
|
||||
}
|
||||
|
||||
private enum SettingsChannelError: Error {
|
||||
case invalidPayload
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .invalidPayload:
|
||||
"Could not encode channel request."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Channels states") {
|
||||
SettingsChannelsStatesPreview()
|
||||
}
|
||||
|
||||
private struct SettingsChannelsStatesPreview: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.stateSection("Connected") {
|
||||
SettingsChannelRow(
|
||||
entry: Self.telegramEntry,
|
||||
canAdmin: true,
|
||||
busyOperation: nil,
|
||||
start: { _ in },
|
||||
stop: { _ in },
|
||||
logout: { _ in })
|
||||
}
|
||||
|
||||
self.stateSection("Loading") {
|
||||
ProPanelHeader(
|
||||
title: "Message Routing",
|
||||
value: "Loading",
|
||||
actionIcon: "hourglass",
|
||||
actionAccessibilityLabel: "Refresh Channels",
|
||||
isActionDisabled: true,
|
||||
action: {})
|
||||
ProStatusRow(
|
||||
icon: "hourglass",
|
||||
title: "Loading channel status",
|
||||
detail: "Checking installed channel clients and account state.",
|
||||
value: "loading",
|
||||
color: OpenClawBrand.accent)
|
||||
}
|
||||
|
||||
self.stateSection("Empty") {
|
||||
ProPanelHeader(
|
||||
title: "Message Routing",
|
||||
value: "0",
|
||||
actionIcon: "arrow.clockwise",
|
||||
actionAccessibilityLabel: "Refresh Channels",
|
||||
action: {})
|
||||
ProStatusRow(
|
||||
icon: "tray",
|
||||
title: "No channel plugins reported",
|
||||
detail: "Install or enable channel plugins on the gateway, then refresh.",
|
||||
value: "empty",
|
||||
color: .secondary)
|
||||
}
|
||||
|
||||
self.stateSection("Error") {
|
||||
ProStatusRow(
|
||||
icon: "exclamationmark.triangle",
|
||||
title: "Channel status unavailable",
|
||||
detail: "Gateway returned an unexpected channel status response.",
|
||||
value: "error",
|
||||
color: OpenClawBrand.warn)
|
||||
}
|
||||
|
||||
self.stateSection("Offline") {
|
||||
ProStatusRow(
|
||||
icon: "wifi.slash",
|
||||
title: "Gateway offline",
|
||||
detail: "Connect to the gateway to load installed channels, accounts, and routing status.",
|
||||
value: "offline",
|
||||
color: .secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stateSection(
|
||||
_ title: String,
|
||||
@ViewBuilder content: () -> some View) -> some View
|
||||
{
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let telegramEntry = SettingsChannelEntry(
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
detail: "Message routing client",
|
||||
systemImage: "paperplane",
|
||||
configured: true,
|
||||
running: true,
|
||||
connected: true,
|
||||
linked: true,
|
||||
lastActivityText: "4m ago",
|
||||
lastError: nil,
|
||||
unavailableReason: nil,
|
||||
accounts: [
|
||||
SettingsChannelAccount(
|
||||
id: "main",
|
||||
name: "OpenClaw Ops",
|
||||
configured: true,
|
||||
enabled: true,
|
||||
running: true,
|
||||
connected: true,
|
||||
linked: true,
|
||||
healthState: "healthy",
|
||||
lastError: nil,
|
||||
lastActivityMs: nil),
|
||||
])
|
||||
}
|
||||
#endif
|
||||
@@ -58,9 +58,38 @@ struct SettingsProTab: View {
|
||||
@State var diagnosticsLastRunText = "Not run"
|
||||
@State var diagnosticsIssueCount: Int?
|
||||
@State var showTalkIssueDetails = false
|
||||
@State private var navigationPath: [SettingsRoute] = []
|
||||
let initialRoute: SettingsRoute?
|
||||
let directRoute: SettingsRoute?
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
|
||||
init(
|
||||
initialRoute: SettingsRoute? = nil,
|
||||
directRoute: SettingsRoute? = nil,
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil)
|
||||
{
|
||||
self.initialRoute = initialRoute
|
||||
self.directRoute = directRoute
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
self.settingsModalPresentation(
|
||||
self.settingsLifecycle(
|
||||
self.settingsContent))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var settingsContent: some View {
|
||||
if let directRoute {
|
||||
self.destination(for: directRoute)
|
||||
} else {
|
||||
self.settingsNavigationStack
|
||||
}
|
||||
}
|
||||
|
||||
private var settingsNavigationStack: some View {
|
||||
NavigationStack(path: self.$navigationPath) {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
@@ -78,11 +107,17 @@ struct SettingsProTab: View {
|
||||
.navigationDestination(for: SettingsRoute.self) { route in
|
||||
self.destination(for: route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func settingsLifecycle(_ content: some View) -> some View {
|
||||
content
|
||||
.task {
|
||||
self.previousLocationModeRaw = self.locationModeRaw
|
||||
self.syncSettingsState()
|
||||
self.refreshNotificationSettings()
|
||||
self.applyPendingGatewaySetupLinkIfNeeded()
|
||||
self.applyInitialRouteIfNeeded()
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, phase in
|
||||
if phase == .active {
|
||||
@@ -119,66 +154,76 @@ struct SettingsProTab: View {
|
||||
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
|
||||
self.applyPendingGatewaySetupLinkIfNeeded()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
|
||||
onPrimaryAction: {
|
||||
Task { await self.handleGatewayProblemPrimaryAction(gatewayProblem) }
|
||||
})
|
||||
}
|
||||
|
||||
private func settingsModalPresentation(_ content: some View) -> some View {
|
||||
content
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
|
||||
onPrimaryAction: {
|
||||
Task { await self.handleGatewayProblemPrimaryAction(gatewayProblem) }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showTalkIssueDetails) {
|
||||
if let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue {
|
||||
TalkRuntimeIssueDetailsSheet(issue: issue)
|
||||
.sheet(isPresented: self.$showTalkIssueDetails) {
|
||||
if let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue {
|
||||
TalkRuntimeIssueDetailsSheet(issue: issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showQRScanner) {
|
||||
NavigationStack {
|
||||
QRScannerView(
|
||||
onGatewayLink: { link in
|
||||
self.handleScannedGatewayLink(link)
|
||||
},
|
||||
onSetupCode: { code in
|
||||
self.handleScannedSetupCode(code)
|
||||
},
|
||||
onError: { error in
|
||||
self.showQRScanner = false
|
||||
self.setupStatusText = "Scanner error: \(error)"
|
||||
self.scannerError = error
|
||||
},
|
||||
onDismiss: {
|
||||
self.showQRScanner = false
|
||||
})
|
||||
.ignoresSafeArea()
|
||||
.navigationTitle("Scan QR Code")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { self.showQRScanner = false }
|
||||
.sheet(isPresented: self.$showQRScanner) {
|
||||
NavigationStack {
|
||||
QRScannerView(
|
||||
onGatewayLink: { link in
|
||||
self.handleScannedGatewayLink(link)
|
||||
},
|
||||
onSetupCode: { code in
|
||||
self.handleScannedSetupCode(code)
|
||||
},
|
||||
onError: { error in
|
||||
self.showQRScanner = false
|
||||
self.setupStatusText = "Scanner error: \(error)"
|
||||
self.scannerError = error
|
||||
},
|
||||
onDismiss: {
|
||||
self.showQRScanner = false
|
||||
})
|
||||
.ignoresSafeArea()
|
||||
.navigationTitle("Scan QR Code")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { self.showQRScanner = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
|
||||
Button("Reset", role: .destructive) {
|
||||
self.resetOnboarding()
|
||||
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
|
||||
Button("Reset", role: .destructive) {
|
||||
self.resetOnboarding()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("This disconnects, clears saved gateway credentials, and reopens onboarding.")
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("This disconnects, clears saved gateway credentials, and reopens onboarding.")
|
||||
}
|
||||
.alert(
|
||||
"QR Scanner Unavailable",
|
||||
isPresented: Binding(
|
||||
get: { self.scannerError != nil },
|
||||
set: { if !$0 { self.scannerError = nil } }))
|
||||
{
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(self.scannerError ?? "")
|
||||
}
|
||||
.alert(
|
||||
"QR Scanner Unavailable",
|
||||
isPresented: Binding(
|
||||
get: { self.scannerError != nil },
|
||||
set: { if !$0 { self.scannerError = nil } }))
|
||||
{
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(self.scannerError ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private func applyInitialRouteIfNeeded() {
|
||||
guard self.directRoute == nil else { return }
|
||||
guard let initialRoute else { return }
|
||||
guard self.navigationPath != [initialRoute] else { return }
|
||||
self.navigationPath = [initialRoute]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,6 +495,7 @@ extension SettingsProTab {
|
||||
case .gateway: "Gateway"
|
||||
case .approvals: "Approvals"
|
||||
case .permissions: "Permissions"
|
||||
case .channels: "Channels"
|
||||
case .voice: "Voice & Talk"
|
||||
case .diagnostics: "Diagnostics"
|
||||
case .privacy: "Privacy"
|
||||
@@ -503,6 +504,20 @@ extension SettingsProTab {
|
||||
}
|
||||
}
|
||||
|
||||
func subtitle(for route: SettingsRoute) -> String {
|
||||
switch route {
|
||||
case .gateway: "Pairing, diagnostics, and Tailscale checks."
|
||||
case .approvals: "Review pending agent actions."
|
||||
case .permissions: "Control device capabilities."
|
||||
case .channels: "Message routing and external clients."
|
||||
case .voice: "Talk mode and wake phrase settings."
|
||||
case .diagnostics: "Run local health checks."
|
||||
case .privacy: "Data and device privacy controls."
|
||||
case .notifications: "Alert permissions and delivery."
|
||||
case .about: "Version and support details."
|
||||
}
|
||||
}
|
||||
|
||||
var manualPortBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.manualGatewayPortText },
|
||||
|
||||
@@ -3,10 +3,20 @@ import SwiftUI
|
||||
|
||||
extension SettingsProTab {
|
||||
var settingsHeader: some View {
|
||||
Text("Settings")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 6)
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: "Settings",
|
||||
subtitle: "Gateway, permissions, voice, and device controls.",
|
||||
titleFont: .title3.weight(.semibold),
|
||||
subtitleFont: .callout)
|
||||
{
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
} accessory: {
|
||||
EmptyView()
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
var appearanceSection: some View {
|
||||
@@ -131,6 +141,11 @@ extension SettingsProTab {
|
||||
title: "Permissions",
|
||||
detail: self.permissionsDetail,
|
||||
route: .permissions)
|
||||
self.settingsListRow(
|
||||
icon: "point.3.connected.trianglepath.dotted",
|
||||
title: "Channels / Integrations",
|
||||
detail: "Message routing and external channel clients.",
|
||||
route: .channels)
|
||||
self.settingsListRow(
|
||||
icon: "waveform",
|
||||
title: "Voice & Talk",
|
||||
@@ -199,6 +214,9 @@ extension SettingsProTab {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if self.headerLeadingAction != nil {
|
||||
self.routeHeader(for: route)
|
||||
}
|
||||
switch route {
|
||||
case .gateway:
|
||||
self.gatewayDestination
|
||||
@@ -206,6 +224,8 @@ extension SettingsProTab {
|
||||
self.approvalsDestination
|
||||
case .permissions:
|
||||
self.permissionsDestination
|
||||
case .channels:
|
||||
SettingsChannelsDestination()
|
||||
case .voice:
|
||||
self.voiceDestination
|
||||
case .diagnostics:
|
||||
@@ -224,6 +244,24 @@ extension SettingsProTab {
|
||||
}
|
||||
.navigationTitle(self.title(for: route))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar(self.headerLeadingAction == nil ? .visible : .hidden, for: .navigationBar)
|
||||
}
|
||||
|
||||
func routeHeader(for route: SettingsRoute) -> some View {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: self.title(for: route),
|
||||
subtitle: self.subtitle(for: route),
|
||||
titleFont: .title3.weight(.semibold),
|
||||
subtitleFont: .callout)
|
||||
{
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
} accessory: {
|
||||
EmptyView()
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
var gatewayDestination: some View {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import Darwin
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
enum SettingsRoute: Hashable {
|
||||
case gateway
|
||||
case approvals
|
||||
case permissions
|
||||
case channels
|
||||
case voice
|
||||
case diagnostics
|
||||
case privacy
|
||||
@@ -150,3 +152,176 @@ extension SettingsProTab {
|
||||
return a == 100 && b >= 64 && b <= 127
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Gateway settings states") {
|
||||
SettingsGatewayStatesPreview()
|
||||
}
|
||||
|
||||
private struct SettingsGatewayStatesPreview: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.stateSection("Connected") {
|
||||
self.gatewayStatusCard(
|
||||
title: "Gateway online",
|
||||
detail: "Connected to openclaw-gateway.tailnet.ts.net.",
|
||||
value: "online",
|
||||
color: OpenClawBrand.ok)
|
||||
self.gatewayFactsCard(
|
||||
address: "100.88.41.20:18789",
|
||||
server: "openclaw-gateway",
|
||||
discovered: "3",
|
||||
agent: "Aiden")
|
||||
}
|
||||
|
||||
self.stateSection("Loading") {
|
||||
self.gatewayStatusCard(
|
||||
title: "Checking gateway",
|
||||
detail: "Refreshing connection, discovery, and device trust state.",
|
||||
value: "loading",
|
||||
color: OpenClawBrand.accent)
|
||||
self.gatewayActionsCard(isBusy: true)
|
||||
}
|
||||
|
||||
self.stateSection("Empty") {
|
||||
self.gatewayStatusCard(
|
||||
title: "No gateway configured",
|
||||
detail: "Scan a setup QR code, paste a setup code, or choose a discovered gateway.",
|
||||
value: "setup",
|
||||
color: .secondary)
|
||||
self.setupActionsCard
|
||||
}
|
||||
|
||||
self.stateSection("Error") {
|
||||
GatewayProblemBanner(
|
||||
problem: Self.pairingProblem,
|
||||
primaryActionTitle: "Retry",
|
||||
onPrimaryAction: {},
|
||||
onShowDetails: {})
|
||||
self.gatewayStatusCard(
|
||||
title: "Tailscale warning",
|
||||
detail: "Tailscale is off on this device. Turn it on, then try again.",
|
||||
value: "network",
|
||||
color: OpenClawBrand.warn)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stateSection(
|
||||
_ title: String,
|
||||
@ViewBuilder content: () -> some View) -> some View
|
||||
{
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
private func gatewayStatusCard(
|
||||
title: String,
|
||||
detail: String,
|
||||
value: String,
|
||||
color: Color) -> some View
|
||||
{
|
||||
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: value == "online" ? "antenna.radiowaves.left.and.right" : "wifi.slash",
|
||||
title: title,
|
||||
detail: detail,
|
||||
value: value,
|
||||
color: color,
|
||||
actionTitle: value == "setup" ? "Scan QR" : nil,
|
||||
action: value == "setup" ? {} : nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func gatewayFactsCard(
|
||||
address: String,
|
||||
server: String,
|
||||
discovered: String,
|
||||
agent: String) -> some View
|
||||
{
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
self.factRow("Address", value: address)
|
||||
Divider()
|
||||
self.factRow("Server", value: server)
|
||||
Divider()
|
||||
self.factRow("Discovered", value: discovered)
|
||||
Divider()
|
||||
self.factRow("Default Agent", value: agent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func factRow(_ label: String, value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 8)
|
||||
Text(value)
|
||||
.font(.caption.weight(.medium))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
.frame(height: SettingsLayout.rowHeight)
|
||||
}
|
||||
|
||||
private func gatewayActionsCard(isBusy: Bool) -> some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
HStack(spacing: 10) {
|
||||
self.previewButton("Reconnect", systemImage: "arrow.triangle.2.circlepath", isBusy: isBusy)
|
||||
self.previewButton("Diagnose", systemImage: "cross.case", isBusy: isBusy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var setupActionsCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 10) {
|
||||
self.previewButton("Scan QR", systemImage: "qrcode.viewfinder", isBusy: false)
|
||||
self.previewButton("Connect", systemImage: "link", isBusy: false)
|
||||
}
|
||||
Text("Discovered gateways and manual setup live here when the gateway has not connected yet.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func previewButton(
|
||||
_ title: String,
|
||||
systemImage: String,
|
||||
isBusy: Bool) -> some View
|
||||
{
|
||||
Button {} label: {
|
||||
Label(title, systemImage: systemImage)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(isBusy)
|
||||
}
|
||||
|
||||
private static let pairingProblem = GatewayConnectionProblem(
|
||||
kind: .pairingRequired,
|
||||
owner: .gateway,
|
||||
title: "Pairing required",
|
||||
message: "Run /pair approve in your OpenClaw chat before this iPad can connect.",
|
||||
actionCommand: "/pair approve req-ipad-preview",
|
||||
requestId: "req-ipad-preview",
|
||||
retryable: false,
|
||||
pauseReconnect: true)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -9,8 +9,17 @@ struct TalkProTab: View {
|
||||
@AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false
|
||||
@State private var showPermissionPrompt = false
|
||||
@State private var showTalkIssueDetails = false
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
var openSettings: () -> Void
|
||||
|
||||
init(
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
openSettings: @escaping () -> Void)
|
||||
{
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.openSettings = openSettings
|
||||
}
|
||||
|
||||
private var state: TalkProState {
|
||||
TalkProState(
|
||||
gatewayConnected: self.gatewayConnected,
|
||||
@@ -85,6 +94,9 @@ struct TalkProTab: View {
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .center, spacing: 11) {
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
OpenClawProMark(size: 31, shadowRadius: 9)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Talk")
|
||||
|
||||
@@ -116,6 +116,8 @@ final class NodeAppModel {
|
||||
self.operatorConnected
|
||||
}
|
||||
|
||||
private(set) var hasOperatorAdminScope: Bool = false
|
||||
|
||||
var gatewayServerName: String?
|
||||
var gatewayRemoteAddress: String?
|
||||
var connectedGatewayID: String?
|
||||
@@ -297,6 +299,7 @@ final class NodeAppModel {
|
||||
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
|
||||
self.voiceWake.setEnabled(enabled)
|
||||
self.talkMode.attachGateway(self.operatorGateway)
|
||||
self.refreshOperatorAdminScopeFromStore()
|
||||
self.refreshLastShareEventFromRelay()
|
||||
let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled")
|
||||
self.setTalkEnabled(talkEnabled)
|
||||
@@ -2757,6 +2760,15 @@ extension NodeAppModel {
|
||||
private func setOperatorConnected(_ connected: Bool) {
|
||||
self.operatorConnected = connected
|
||||
self.operatorStatusText = connected ? "Connected" : "Offline"
|
||||
self.refreshOperatorAdminScopeFromStore()
|
||||
}
|
||||
|
||||
private func refreshOperatorAdminScopeFromStore() {
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
self.hasOperatorAdminScope = DeviceAuthStore
|
||||
.loadToken(deviceId: identity.deviceId, role: "operator")?
|
||||
.scopes
|
||||
.contains("operator.admin") == true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4636,6 +4648,10 @@ extension NodeAppModel {
|
||||
self.gatewayConnected
|
||||
}
|
||||
|
||||
func _test_refreshOperatorAdminScopeFromStore() {
|
||||
self.refreshOperatorAdminScopeFromStore()
|
||||
}
|
||||
|
||||
func _test_applyPendingForegroundNodeActions(
|
||||
_ actions: [(id: String, command: String, paramsJSON: String?)]) async
|
||||
{
|
||||
|
||||
@@ -8,6 +8,8 @@ struct RootTabs: View {
|
||||
@Environment(VoiceWakeManager.self) private var voiceWake
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.rootTabsUserInterfaceIdiomOverride) private var userInterfaceIdiomOverride
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
||||
@AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0
|
||||
@@ -21,10 +23,14 @@ struct RootTabs: View {
|
||||
@AppStorage(AppAppearancePreference.storageKey) private var appearancePreferenceRaw: String =
|
||||
AppAppearancePreference.system.rawValue
|
||||
@State private var selectedTab: AppTab = Self.initialTab
|
||||
@State private var selectedSidebarDestination: SidebarDestination = Self.initialSidebarDestination
|
||||
@State private var isSidebarVisible: Bool = Self.initialSidebarVisibility ?? false
|
||||
@State private var sidebarVisibilityUserOverridden: Bool = Self.initialSidebarVisibility != nil
|
||||
@State private var isSidebarDrawerLayout: Bool = false
|
||||
@State private var didResolveSidebarLayout: Bool = false
|
||||
@State private var voiceWakeToastText: String?
|
||||
@State private var toastDismissTask: Task<Void, Never>?
|
||||
@State private var presentedSheet: PresentedSheet?
|
||||
@State private var showGatewayActions: Bool = false
|
||||
@State private var showGatewayProblemDetails: Bool = false
|
||||
@State private var showOnboarding: Bool = false
|
||||
@State private var onboardingAllowSkip: Bool = true
|
||||
@@ -34,14 +40,6 @@ struct RootTabs: View {
|
||||
@State private var didApplyInitialChatSession: Bool = false
|
||||
@State private var handledGatewaySetupRequestID: Int = 0
|
||||
|
||||
private enum AppTab: Hashable {
|
||||
case control
|
||||
case chat
|
||||
case talk
|
||||
case agent
|
||||
case settings
|
||||
}
|
||||
|
||||
private static var initialTab: AppTab {
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
guard let flagIndex = arguments.firstIndex(of: "--openclaw-initial-tab") else {
|
||||
@@ -66,6 +64,28 @@ struct RootTabs: View {
|
||||
}
|
||||
}
|
||||
|
||||
private static var initialSidebarDestination: SidebarDestination {
|
||||
if let requested = requestedInitialSidebarDestination {
|
||||
return requested
|
||||
}
|
||||
return Self.defaultSidebarDestination(for: initialTab)
|
||||
}
|
||||
|
||||
private static var requestedInitialSidebarDestination: SidebarDestination? {
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
guard let flagIndex = arguments.firstIndex(of: "--openclaw-initial-destination") else {
|
||||
return nil
|
||||
}
|
||||
let valueIndex = arguments.index(after: flagIndex)
|
||||
guard arguments.indices.contains(valueIndex) else { return nil }
|
||||
let requested = arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return SidebarDestination.allCases.first { $0.rawValue.lowercased() == requested }
|
||||
}
|
||||
|
||||
private static var initialSidebarVisibility: Bool? {
|
||||
requestedInitialSidebarVisibility(arguments: ProcessInfo.processInfo.arguments)
|
||||
}
|
||||
|
||||
private static var initialChatSessionKey: String? {
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
guard let flagIndex = arguments.firstIndex(of: "--openclaw-chat-session") else {
|
||||
@@ -87,45 +107,11 @@ struct RootTabs: View {
|
||||
}
|
||||
}
|
||||
|
||||
enum StartupPresentationRoute: Equatable {
|
||||
case none
|
||||
case onboarding
|
||||
case settings
|
||||
}
|
||||
|
||||
static func startupPresentationRoute(
|
||||
gatewayConnected: Bool,
|
||||
hasConnectedOnce: Bool,
|
||||
onboardingComplete: Bool,
|
||||
hasExistingGatewayConfig: Bool,
|
||||
shouldPresentOnLaunch: Bool) -> StartupPresentationRoute
|
||||
static func shouldUseSidebarTabs(
|
||||
idiom: UIUserInterfaceIdiom,
|
||||
horizontalSizeClass _: UserInterfaceSizeClass?) -> Bool
|
||||
{
|
||||
if gatewayConnected {
|
||||
return .none
|
||||
}
|
||||
if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete {
|
||||
return .onboarding
|
||||
}
|
||||
if !hasExistingGatewayConfig {
|
||||
return .settings
|
||||
}
|
||||
return .none
|
||||
}
|
||||
|
||||
static func shouldPresentQuickSetup(
|
||||
quickSetupDismissed: Bool,
|
||||
showOnboarding: Bool,
|
||||
hasPresentedSheet: Bool,
|
||||
gatewayConnected: Bool,
|
||||
hasExistingGatewayConfig: Bool,
|
||||
discoveredGatewayCount: Int) -> Bool
|
||||
{
|
||||
guard !quickSetupDismissed else { return false }
|
||||
guard !showOnboarding else { return false }
|
||||
guard !hasPresentedSheet else { return false }
|
||||
guard !gatewayConnected else { return false }
|
||||
guard !hasExistingGatewayConfig else { return false }
|
||||
return discoveredGatewayCount > 0
|
||||
idiom == .pad
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -136,20 +122,30 @@ struct RootTabs: View {
|
||||
.tint(OpenClawBrand.accent))))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var tabContent: some View {
|
||||
if self.usesSidebarTabs {
|
||||
self.sidebarSplitContent
|
||||
} else {
|
||||
self.phoneTabContent
|
||||
}
|
||||
}
|
||||
|
||||
private var phoneTabContent: some View {
|
||||
TabView(selection: self.$selectedTab) {
|
||||
CommandCenterTab(
|
||||
openChat: { self.selectedTab = .chat },
|
||||
openSettings: { self.selectedTab = .settings })
|
||||
.tabItem { Label("Command", systemImage: "target") }
|
||||
RootTabsPhoneControlHub(
|
||||
groups: Self.phoneControlGroups,
|
||||
initialDestination: Self.requestedInitialSidebarDestination,
|
||||
openRootDestination: { self.selectSidebarDestination($0) })
|
||||
.tabItem { Label("Control", systemImage: "square.grid.2x2") }
|
||||
.badge(self.appModel.pendingExecApprovalPrompt == nil ? 0 : 1)
|
||||
.tag(AppTab.control)
|
||||
|
||||
ChatProTab()
|
||||
ChatProTab(openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
.tabItem { Label("Chat", systemImage: "bubble.left.fill") }
|
||||
.tag(AppTab.chat)
|
||||
|
||||
TalkProTab(openSettings: { self.selectedTab = .settings })
|
||||
TalkProTab(openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
.tabItem {
|
||||
Label(
|
||||
"Talk",
|
||||
@@ -157,16 +153,394 @@ struct RootTabs: View {
|
||||
}
|
||||
.tag(AppTab.talk)
|
||||
|
||||
AgentProTab()
|
||||
.tabItem { Label("Agent", systemImage: "person.2.fill") }
|
||||
.tag(AppTab.agent)
|
||||
NavigationStack {
|
||||
AgentProTab(
|
||||
directRoute: .agents,
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
}
|
||||
.tabItem { Label("Agent", systemImage: "person.2.fill") }
|
||||
.tag(AppTab.agent)
|
||||
|
||||
SettingsProTab()
|
||||
SettingsProTab(initialRoute: self.selectedSidebarDestination.settingsRoute)
|
||||
.id(self.selectedSidebarDestination.settingsRoute.map { "\($0)" } ?? "settings")
|
||||
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
|
||||
.tag(AppTab.settings)
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebarSplitContent: some View {
|
||||
GeometryReader { proxy in
|
||||
let isDrawerLayout = self.shouldUseSidebarDrawer(containerSize: proxy.size)
|
||||
let sidebarWidth = self.sidebarWidth(containerWidth: proxy.size.width, isDrawerLayout: isDrawerLayout)
|
||||
Group {
|
||||
if isDrawerLayout {
|
||||
self.sidebarDrawerContent(sidebarWidth: sidebarWidth)
|
||||
} else {
|
||||
self.sidebarNavigationSplitContent(sidebarWidth: sidebarWidth)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.22), value: self.isSidebarVisible)
|
||||
.onAppear {
|
||||
self.updateSidebarLayout(containerSize: proxy.size, force: false)
|
||||
}
|
||||
.onChange(of: proxy.size) { _, size in
|
||||
self.updateSidebarLayout(containerSize: size, force: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sidebarNavigationSplitContent(sidebarWidth: CGFloat) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
if self.isSidebarVisible {
|
||||
self.sidebarColumn
|
||||
.frame(width: sidebarWidth, alignment: .topLeading)
|
||||
.frame(maxHeight: .infinity, alignment: .topLeading)
|
||||
.overlay(alignment: .trailing) {
|
||||
self.sidebarVerticalSeparator
|
||||
}
|
||||
.transition(.move(edge: .leading).combined(with: .opacity))
|
||||
}
|
||||
|
||||
self.sidebarDetailNavigationShell
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.background(OpenClawProBackground())
|
||||
}
|
||||
|
||||
private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
self.sidebarDetailNavigationShell
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
if self.isSidebarVisible {
|
||||
Color.black.opacity(0.28)
|
||||
.ignoresSafeArea()
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
self.hideSidebar()
|
||||
}
|
||||
.transition(.opacity)
|
||||
|
||||
self.sidebarColumn
|
||||
.frame(width: sidebarWidth, alignment: .topLeading)
|
||||
.frame(maxHeight: .infinity, alignment: .topLeading)
|
||||
.overlay(alignment: .trailing) {
|
||||
self.sidebarVerticalSeparator
|
||||
}
|
||||
.shadow(color: .black.opacity(0.26), radius: 18, x: 8, y: 0)
|
||||
.transition(.move(edge: .leading).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebarDetailShell: some View {
|
||||
self.sidebarDetail
|
||||
.id(self.selectedSidebarDestination.id)
|
||||
}
|
||||
|
||||
private var sidebarColumn: some View {
|
||||
VStack(spacing: 0) {
|
||||
self.sidebarIdentityHeader
|
||||
self.sidebarList
|
||||
self.sidebarFooter
|
||||
}
|
||||
.safeAreaPadding(.top, 8)
|
||||
.safeAreaPadding(.bottom, 8)
|
||||
.background(Color(uiColor: .systemBackground))
|
||||
}
|
||||
|
||||
private var sidebarIdentityHeader: some View {
|
||||
HStack(spacing: 10) {
|
||||
OpenClawProMark(size: 30, shadowRadius: 3)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("OpenClaw")
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "circle.fill")
|
||||
.font(.system(size: 7, weight: .bold))
|
||||
.foregroundStyle(self.sidebarGatewayStatusColor)
|
||||
Text(self.sidebarGatewayStatusTitle)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
if self.isSidebarDrawerLayout {
|
||||
self.sidebarHideButton
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color(uiColor: .systemBackground))
|
||||
.overlay(alignment: .bottom) {
|
||||
self.sidebarHorizontalSeparator
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("OpenClaw \(self.sidebarGatewayStatusTitle)")
|
||||
}
|
||||
|
||||
private var sidebarGatewayStatusTitle: String {
|
||||
switch self.gatewayStatus {
|
||||
case .connected:
|
||||
"Online"
|
||||
case .connecting:
|
||||
"Connecting"
|
||||
case .error:
|
||||
"Needs attention"
|
||||
case .disconnected:
|
||||
"Offline"
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebarList: some View {
|
||||
List {
|
||||
ForEach(Self.sidebarGroups) { group in
|
||||
Section(group.title.capitalized) {
|
||||
ForEach(group.destinations) { destination in
|
||||
self.sidebarDestinationButton(destination)
|
||||
}
|
||||
}
|
||||
.listSectionSeparator(.hidden, edges: .all)
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.tint(OpenClawBrand.accent)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color(uiColor: .systemBackground))
|
||||
}
|
||||
|
||||
private var sidebarFooter: some View {
|
||||
VStack(spacing: 0) {
|
||||
self.sidebarHorizontalSeparator
|
||||
HStack(spacing: 10) {
|
||||
Text("VERSION")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 8)
|
||||
Text("v\(DeviceInfoHelper.openClawVersionString())")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
ProStatusDot(color: self.sidebarGatewayStatusColor)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebarHorizontalSeparator: some View {
|
||||
Rectangle()
|
||||
.fill(Color(uiColor: .separator))
|
||||
.frame(height: 1 / UIScreen.main.scale)
|
||||
}
|
||||
|
||||
private var sidebarVerticalSeparator: some View {
|
||||
Rectangle()
|
||||
.fill(Color(uiColor: .separator))
|
||||
.frame(width: 1 / UIScreen.main.scale)
|
||||
}
|
||||
|
||||
private var sidebarGatewayStatusColor: Color {
|
||||
switch self.gatewayStatus {
|
||||
case .connected:
|
||||
OpenClawBrand.ok
|
||||
case .connecting:
|
||||
OpenClawBrand.accent
|
||||
case .error:
|
||||
OpenClawBrand.warn
|
||||
case .disconnected:
|
||||
.secondary
|
||||
}
|
||||
}
|
||||
|
||||
private func sidebarDestinationButton(
|
||||
_ destination: SidebarDestination,
|
||||
title: String? = nil) -> some View
|
||||
{
|
||||
Button {
|
||||
self.selectSidebarDestination(destination)
|
||||
} label: {
|
||||
Label(title ?? destination.sidebarTitle, systemImage: destination.systemImage)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.82)
|
||||
.truncationMode(.tail)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(destination == self.selectedSidebarDestination ? OpenClawBrand.accent : .primary)
|
||||
.listRowBackground(
|
||||
destination == self.selectedSidebarDestination
|
||||
? OpenClawBrand.accent.opacity(0.12)
|
||||
: Color.clear)
|
||||
.listRowSeparator(.hidden, edges: .all)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var sidebarDetail: some View {
|
||||
switch self.selectedSidebarDestination {
|
||||
case .chat:
|
||||
ChatProTab(
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
headerTitle: "Chat",
|
||||
headerSubtitle: "Agent conversation",
|
||||
showsAgentBadge: false,
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
case .talk:
|
||||
TalkProTab(
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
case .overview:
|
||||
CommandCenterTab(
|
||||
headerTitle: "Overview",
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
showsHeaderMark: false,
|
||||
openChat: { self.selectSidebarDestination(.chat) },
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
case .activity:
|
||||
IPadActivityScreen(
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
openChat: { self.selectSidebarDestination(.chat) },
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
case .workboard:
|
||||
IPadWorkboardScreen(
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
openChat: { self.selectSidebarDestination(.chat) },
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
case .skillWorkshop:
|
||||
IPadSkillWorkshopScreen(
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
case .agents:
|
||||
AgentProTab(
|
||||
directRoute: .agents,
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
headerTitle: "Agents",
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
.id(self.selectedSidebarDestination.id)
|
||||
case .instances:
|
||||
AgentProTab(
|
||||
directRoute: .instances,
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
headerTitle: "Instances",
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
.id(self.selectedSidebarDestination.id)
|
||||
case .sessions:
|
||||
CommandSessionsScreen(
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
openChat: { self.selectSidebarDestination(.chat) })
|
||||
case .dreaming:
|
||||
AgentProTab(
|
||||
directRoute: .dreaming,
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
headerTitle: "Dreaming",
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
.id(self.selectedSidebarDestination.id)
|
||||
case .usage:
|
||||
AgentProTab(
|
||||
directRoute: .usage,
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
headerTitle: "Usage",
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
.id(self.selectedSidebarDestination.id)
|
||||
case .cron:
|
||||
AgentProTab(
|
||||
directRoute: .cron,
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
headerTitle: "Cron Jobs",
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
.id(self.selectedSidebarDestination.id)
|
||||
case .docs:
|
||||
OpenClawDocsScreen(
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
gatewayAction: { self.selectSidebarDestination(.gateway) })
|
||||
case .settings:
|
||||
SettingsProTab(headerLeadingAction: self.sidebarHeaderLeadingAction)
|
||||
case .gateway:
|
||||
SettingsProTab(
|
||||
directRoute: self.selectedSidebarDestination.settingsRoute ?? .gateway,
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction)
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebarDetailNavigationShell: some View {
|
||||
NavigationStack {
|
||||
self.sidebarDetailShell
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
private var usesSidebarTabs: Bool {
|
||||
Self.shouldUseSidebarTabs(
|
||||
idiom: self.userInterfaceIdiom,
|
||||
horizontalSizeClass: self.horizontalSizeClass)
|
||||
}
|
||||
|
||||
private var userInterfaceIdiom: UIUserInterfaceIdiom {
|
||||
if let userInterfaceIdiomOverride {
|
||||
return userInterfaceIdiomOverride
|
||||
}
|
||||
return UIDevice.current.userInterfaceIdiom
|
||||
}
|
||||
|
||||
private var shouldCollapseSidebarAfterSelection: Bool {
|
||||
Self.shouldCollapseSidebarAfterSelection(
|
||||
layoutMode: self.isSidebarDrawerLayout ? .drawer : .split)
|
||||
}
|
||||
|
||||
private var sidebarHeaderLeadingAction: OpenClawSidebarHeaderAction? {
|
||||
guard Self.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: self.isSidebarVisible,
|
||||
layoutMode: self.isSidebarDrawerLayout ? .drawer : .split)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
if self.isSidebarVisible {
|
||||
return OpenClawSidebarHeaderAction(
|
||||
systemName: "sidebar.left",
|
||||
accessibilityLabel: "Hide Sidebar",
|
||||
accessibilityIdentifier: Self.sidebarHideButtonAccessibilityIdentifier,
|
||||
action: { self.hideSidebar() })
|
||||
}
|
||||
return OpenClawSidebarHeaderAction(
|
||||
systemName: "sidebar.left",
|
||||
accessibilityLabel: "Show Sidebar",
|
||||
accessibilityIdentifier: Self.sidebarShowButtonAccessibilityIdentifier,
|
||||
action: { self.showSidebar() })
|
||||
}
|
||||
|
||||
private var sidebarHideButton: some View {
|
||||
Button {
|
||||
self.hideSidebar()
|
||||
} label: {
|
||||
Image(systemName: self.isSidebarDrawerLayout ? "xmark" : "sidebar.left")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
.accessibilityLabel("Hide Sidebar")
|
||||
.accessibilityIdentifier(Self.sidebarHideButtonAccessibilityIdentifier)
|
||||
}
|
||||
|
||||
private func shouldUseSidebarDrawer(containerSize: CGSize) -> Bool {
|
||||
Self.sidebarLayoutMode(containerSize: containerSize) == .drawer
|
||||
}
|
||||
|
||||
private func sidebarWidth(containerWidth: CGFloat, isDrawerLayout: Bool) -> CGFloat {
|
||||
Self.sidebarWidth(containerWidth: containerWidth, isDrawerLayout: isDrawerLayout)
|
||||
}
|
||||
|
||||
private func rootOverlays(_ content: some View) -> some View {
|
||||
content
|
||||
.overlay(alignment: .top) {
|
||||
@@ -195,6 +569,7 @@ struct RootTabs: View {
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
|
||||
.overlay {
|
||||
if self.appModel.cameraFlashNonce != 0 {
|
||||
RootCameraFlashOverlay(nonce: self.appModel.cameraFlashNonce)
|
||||
@@ -325,7 +700,7 @@ struct RootTabs: View {
|
||||
self.evaluateOnboardingPresentation(force: true)
|
||||
}
|
||||
.onChange(of: self.appModel.openChatRequestID) { _, _ in
|
||||
self.selectedTab = .chat
|
||||
self.selectSidebarDestination(.chat)
|
||||
}
|
||||
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
|
||||
self.maybeOpenSettingsForGatewaySetup()
|
||||
@@ -334,10 +709,6 @@ struct RootTabs: View {
|
||||
|
||||
private func rootPresentation(_ content: some View) -> some View {
|
||||
content
|
||||
.gatewayActionsDialog(
|
||||
isPresented: self.$showGatewayActions,
|
||||
onDisconnect: { self.appModel.disconnectGateway() },
|
||||
onOpenSettings: { self.selectedTab = .settings })
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
@@ -504,6 +875,50 @@ struct RootTabs: View {
|
||||
self.normalized(agent.name) ?? agent.id
|
||||
}
|
||||
|
||||
private func selectSidebarDestination(_ destination: SidebarDestination) {
|
||||
self.selectedSidebarDestination = destination
|
||||
self.selectedTab = destination.appTab
|
||||
guard self.usesSidebarTabs, self.shouldCollapseSidebarAfterSelection else { return }
|
||||
withAnimation(.easeInOut(duration: 0.22)) {
|
||||
self.setSidebarVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
private func showSidebar() {
|
||||
self.sidebarVisibilityUserOverridden = true
|
||||
withAnimation(.easeInOut(duration: 0.22)) {
|
||||
self.setSidebarVisible(true)
|
||||
}
|
||||
}
|
||||
|
||||
private func hideSidebar() {
|
||||
self.sidebarVisibilityUserOverridden = true
|
||||
withAnimation(.easeInOut(duration: 0.22)) {
|
||||
self.setSidebarVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSidebarLayout(containerSize: CGSize, force: Bool) {
|
||||
let layoutMode = Self.sidebarLayoutMode(containerSize: containerSize)
|
||||
let previousLayoutMode: SidebarLayoutMode = self.isSidebarDrawerLayout ? .drawer : .split
|
||||
let didResolvePreviousLayout = self.didResolveSidebarLayout
|
||||
let layoutModeDidChange = layoutMode != previousLayoutMode
|
||||
self.didResolveSidebarLayout = true
|
||||
self.isSidebarDrawerLayout = layoutMode == .drawer
|
||||
if layoutModeDidChange && didResolvePreviousLayout {
|
||||
self.sidebarVisibilityUserOverridden = false
|
||||
}
|
||||
guard force || !self.sidebarVisibilityUserOverridden else { return }
|
||||
|
||||
let preferredVisibility = Self.preferredSidebarVisibility(layoutMode: layoutMode)
|
||||
guard self.isSidebarVisible != preferredVisibility else { return }
|
||||
self.setSidebarVisible(preferredVisibility)
|
||||
}
|
||||
|
||||
private func setSidebarVisible(_ isVisible: Bool) {
|
||||
self.isSidebarVisible = isVisible
|
||||
}
|
||||
|
||||
private func homeCanvasBadge(for agent: AgentSummary) -> String {
|
||||
if let identity = agent.identity,
|
||||
let emoji = identity["emoji"]?.value as? String,
|
||||
@@ -538,7 +953,7 @@ struct RootTabs: View {
|
||||
} else if problem.retryable {
|
||||
Task { await self.gatewayController.connectLastKnown() }
|
||||
} else {
|
||||
self.selectedTab = .settings
|
||||
self.selectSidebarDestination(.gateway)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,7 +980,7 @@ struct RootTabs: View {
|
||||
self.showOnboarding = true
|
||||
case .settings:
|
||||
self.didAutoOpenSettings = true
|
||||
self.selectedTab = .settings
|
||||
self.selectSidebarDestination(.gateway)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,7 +1006,7 @@ struct RootTabs: View {
|
||||
shouldPresentOnLaunch: false)
|
||||
guard route == .settings else { return }
|
||||
self.didAutoOpenSettings = true
|
||||
self.selectedTab = .settings
|
||||
self.selectSidebarDestination(.gateway)
|
||||
}
|
||||
|
||||
private func maybeOpenSettingsForGatewaySetup() {
|
||||
@@ -601,7 +1016,7 @@ struct RootTabs: View {
|
||||
self.showOnboarding = false
|
||||
self.presentedSheet = nil
|
||||
self.didAutoOpenSettings = true
|
||||
self.selectedTab = .settings
|
||||
self.selectSidebarDestination(.gateway)
|
||||
}
|
||||
|
||||
private func applyInitialChatSessionIfNeeded() {
|
||||
@@ -681,3 +1096,120 @@ private struct RootCameraFlashOverlay: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
@Entry var rootTabsUserInterfaceIdiomOverride: UIUserInterfaceIdiom?
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview(
|
||||
"Shell iPhone portrait",
|
||||
traits: .fixedLayout(width: 393, height: 852),
|
||||
.portrait)
|
||||
{
|
||||
RootTabsPreviewHost(idiom: .phone)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Shell iPhone connected",
|
||||
traits: .fixedLayout(width: 393, height: 852),
|
||||
.portrait)
|
||||
{
|
||||
RootTabsPreviewHost(idiom: .phone, gatewayState: .connected)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Shell iPhone gateway error",
|
||||
traits: .fixedLayout(width: 393, height: 852),
|
||||
.portrait)
|
||||
{
|
||||
RootTabsPreviewHost(idiom: .phone, gatewayState: .error)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Shell iPhone landscape",
|
||||
traits: .fixedLayout(width: 852, height: 393),
|
||||
.landscapeLeft)
|
||||
{
|
||||
RootTabsPreviewHost(idiom: .phone)
|
||||
.environment(\.horizontalSizeClass, .regular)
|
||||
.environment(\.verticalSizeClass, .compact)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Shell iPad portrait drawer",
|
||||
traits: .fixedLayout(width: 1024, height: 1366),
|
||||
.portrait)
|
||||
{
|
||||
RootTabsPreviewHost(idiom: .pad)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Shell iPad landscape split",
|
||||
traits: .fixedLayout(width: 1366, height: 1024),
|
||||
.landscapeLeft)
|
||||
{
|
||||
RootTabsPreviewHost(idiom: .pad, gatewayState: .connected)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Shell iPad connecting",
|
||||
traits: .fixedLayout(width: 1366, height: 1024),
|
||||
.landscapeLeft)
|
||||
{
|
||||
RootTabsPreviewHost(idiom: .pad, gatewayState: .connecting)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Shell iPad gateway error",
|
||||
traits: .fixedLayout(width: 1366, height: 1024),
|
||||
.landscapeLeft)
|
||||
{
|
||||
RootTabsPreviewHost(idiom: .pad, gatewayState: .error)
|
||||
}
|
||||
|
||||
private struct RootTabsPreviewHost: View {
|
||||
@State private var appModel: NodeAppModel
|
||||
@State private var gatewayController: GatewayConnectionController
|
||||
private let idiom: UIUserInterfaceIdiom
|
||||
|
||||
init(idiom: UIUserInterfaceIdiom, gatewayState: RootTabsPreviewGatewayState = .offline) {
|
||||
let appModel = NodeAppModel()
|
||||
gatewayState.apply(to: appModel)
|
||||
self.idiom = idiom
|
||||
_appModel = State(initialValue: appModel)
|
||||
_gatewayController = State(
|
||||
initialValue: GatewayConnectionController(appModel: appModel, startDiscovery: false))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
RootTabs()
|
||||
.environment(self.appModel)
|
||||
.environment(self.appModel.voiceWake)
|
||||
.environment(self.gatewayController)
|
||||
.environment(\.rootTabsUserInterfaceIdiomOverride, self.idiom)
|
||||
}
|
||||
}
|
||||
|
||||
private enum RootTabsPreviewGatewayState {
|
||||
case offline
|
||||
case connecting
|
||||
case connected
|
||||
case error
|
||||
|
||||
@MainActor
|
||||
func apply(to appModel: NodeAppModel) {
|
||||
switch self {
|
||||
case .offline:
|
||||
break
|
||||
case .connecting:
|
||||
appModel.gatewayStatusText = "Connecting..."
|
||||
case .connected:
|
||||
appModel.enterAppleReviewDemoMode()
|
||||
case .error:
|
||||
appModel.gatewayStatusText = "Gateway error: connection refused"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
308
apps/ios/Sources/RootTabsNavigation.swift
Normal file
308
apps/ios/Sources/RootTabsNavigation.swift
Normal file
@@ -0,0 +1,308 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension RootTabs {
|
||||
private static var sidebarPersistentWidthThreshold: CGFloat {
|
||||
980
|
||||
}
|
||||
|
||||
static let sidebarSplitMinimumWidth: CGFloat = 292
|
||||
static let sidebarSplitIdealWidth: CGFloat = 316
|
||||
static let sidebarSplitMaximumWidth: CGFloat = 340
|
||||
static let sidebarDrawerMaximumWidth: CGFloat = 340
|
||||
static let sidebarShowButtonAccessibilityIdentifier = "RootTabs.Sidebar.Show"
|
||||
static let sidebarHideButtonAccessibilityIdentifier = "RootTabs.Sidebar.Hide"
|
||||
|
||||
enum AppTab: Hashable {
|
||||
case control
|
||||
case chat
|
||||
case talk
|
||||
case agent
|
||||
case settings
|
||||
}
|
||||
|
||||
enum SidebarDestination: String, CaseIterable, Hashable, Identifiable {
|
||||
case chat
|
||||
case talk
|
||||
case overview
|
||||
case activity
|
||||
case agents
|
||||
case workboard
|
||||
case skillWorkshop
|
||||
case instances
|
||||
case sessions
|
||||
case dreaming
|
||||
case usage
|
||||
case cron
|
||||
case docs
|
||||
case settings
|
||||
case gateway
|
||||
|
||||
var id: String {
|
||||
rawValue
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .chat: "Chat"
|
||||
case .talk: "Talk"
|
||||
case .overview: "Overview"
|
||||
case .activity: "Activity"
|
||||
case .agents: "Agents"
|
||||
case .workboard: "Workboard"
|
||||
case .skillWorkshop: "Skill Workshop"
|
||||
case .instances: "Instances"
|
||||
case .sessions: "Sessions"
|
||||
case .dreaming: "Dreaming"
|
||||
case .usage: "Usage"
|
||||
case .cron: "Cron Jobs"
|
||||
case .docs: "Docs"
|
||||
case .settings: "Settings"
|
||||
case .gateway: "Settings / Gateway"
|
||||
}
|
||||
}
|
||||
|
||||
var sidebarTitle: String {
|
||||
switch self {
|
||||
case .gateway: "Connection"
|
||||
default: self.title
|
||||
}
|
||||
}
|
||||
|
||||
var subtitle: String {
|
||||
switch self {
|
||||
case .chat: "Agent chat and recent work."
|
||||
case .talk: "Realtime voice and fallback controls."
|
||||
case .overview: "Status, entry points, health."
|
||||
case .activity: "Gateway, session, and device activity."
|
||||
case .agents: "Agent roster and readiness."
|
||||
case .workboard: "Agent work queue and session handoff."
|
||||
case .skillWorkshop: "Review and apply proposed skills."
|
||||
case .instances: "Latest presence from OpenClaw nodes."
|
||||
case .sessions: "Active sessions and defaults."
|
||||
case .dreaming: "Memory signals and background synthesis."
|
||||
case .usage: "API usage and costs."
|
||||
case .cron: "Wakeups and recurring runs."
|
||||
case .docs: "Reference docs and setup guides."
|
||||
case .settings: "Connection, permissions, channels, and app options."
|
||||
case .gateway: "Pairing, diagnostics, permissions, and device controls."
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .chat: "bubble.left"
|
||||
case .talk: "waveform.circle"
|
||||
case .overview: "chart.bar"
|
||||
case .activity: "waveform.path.ecg"
|
||||
case .agents: "person.2"
|
||||
case .workboard: "folder"
|
||||
case .skillWorkshop: "hammer"
|
||||
case .instances: "dot.radiowaves.left.and.right"
|
||||
case .sessions: "doc.text"
|
||||
case .dreaming: "moon.stars"
|
||||
case .usage: "chart.bar.xaxis"
|
||||
case .cron: "timer"
|
||||
case .docs: "book"
|
||||
case .settings: "gearshape"
|
||||
case .gateway: "gearshape"
|
||||
}
|
||||
}
|
||||
|
||||
var appTab: AppTab {
|
||||
switch self {
|
||||
case .chat:
|
||||
.chat
|
||||
case .talk:
|
||||
.talk
|
||||
case .agents:
|
||||
.agent
|
||||
case .settings, .gateway:
|
||||
.settings
|
||||
case .overview, .activity, .workboard, .skillWorkshop, .instances, .sessions, .dreaming,
|
||||
.usage,
|
||||
.cron, .docs:
|
||||
.control
|
||||
}
|
||||
}
|
||||
|
||||
var settingsRoute: SettingsRoute? {
|
||||
switch self {
|
||||
case .gateway:
|
||||
.gateway
|
||||
case .chat, .talk, .overview, .activity, .agents, .workboard, .skillWorkshop, .instances, .sessions,
|
||||
.dreaming,
|
||||
.usage, .cron, .settings, .docs:
|
||||
nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SidebarLayoutMode: Equatable {
|
||||
case drawer
|
||||
case split
|
||||
}
|
||||
|
||||
static func sidebarLayoutMode(containerSize: CGSize) -> SidebarLayoutMode {
|
||||
containerSize.width < self.sidebarPersistentWidthThreshold || containerSize.height > containerSize.width
|
||||
? .drawer
|
||||
: .split
|
||||
}
|
||||
|
||||
static func preferredSidebarVisibility(layoutMode: SidebarLayoutMode) -> Bool {
|
||||
layoutMode == .split
|
||||
}
|
||||
|
||||
static func shouldCollapseSidebarAfterSelection(layoutMode: SidebarLayoutMode) -> Bool {
|
||||
layoutMode == .drawer
|
||||
}
|
||||
|
||||
static func sidebarWidth(containerWidth: CGFloat, isDrawerLayout: Bool) -> CGFloat {
|
||||
if isDrawerLayout {
|
||||
return min(self.sidebarDrawerMaximumWidth, max(280, containerWidth * 0.86))
|
||||
}
|
||||
return min(self.sidebarSplitMaximumWidth, max(self.sidebarSplitIdealWidth, containerWidth * 0.25))
|
||||
}
|
||||
|
||||
static func shouldShowSidebarRevealControl(isSidebarVisible: Bool) -> Bool {
|
||||
!isSidebarVisible
|
||||
}
|
||||
|
||||
static func shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: Bool,
|
||||
layoutMode: SidebarLayoutMode) -> Bool
|
||||
{
|
||||
switch layoutMode {
|
||||
case .split:
|
||||
true
|
||||
case .drawer:
|
||||
self.shouldShowSidebarRevealControl(isSidebarVisible: isSidebarVisible)
|
||||
}
|
||||
}
|
||||
|
||||
static func requestedInitialSidebarVisibility(arguments: [String]) -> Bool? {
|
||||
guard let flagIndex = arguments.firstIndex(of: "--openclaw-sidebar-visibility") else {
|
||||
return nil
|
||||
}
|
||||
let valueIndex = arguments.index(after: flagIndex)
|
||||
guard arguments.indices.contains(valueIndex) else { return nil }
|
||||
|
||||
switch arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "visible", "show", "shown", "open", "true", "1":
|
||||
return true
|
||||
case "hidden", "hide", "closed", "false", "0":
|
||||
return false
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func shouldOpenRootTabFromPhoneHub(_ destination: SidebarDestination) -> Bool {
|
||||
switch destination {
|
||||
case .chat, .talk, .agents, .gateway, .settings:
|
||||
true
|
||||
case .overview, .activity, .workboard, .skillWorkshop, .instances, .sessions, .dreaming,
|
||||
.usage,
|
||||
.cron, .docs:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
static func defaultSidebarDestination(for tab: AppTab) -> SidebarDestination {
|
||||
switch tab {
|
||||
case .control:
|
||||
.overview
|
||||
case .chat:
|
||||
.chat
|
||||
case .talk:
|
||||
.talk
|
||||
case .agent:
|
||||
.agents
|
||||
case .settings:
|
||||
.settings
|
||||
}
|
||||
}
|
||||
|
||||
enum StartupPresentationRoute: Equatable {
|
||||
case none
|
||||
case onboarding
|
||||
case settings
|
||||
}
|
||||
|
||||
static func startupPresentationRoute(
|
||||
gatewayConnected: Bool,
|
||||
hasConnectedOnce: Bool,
|
||||
onboardingComplete: Bool,
|
||||
hasExistingGatewayConfig: Bool,
|
||||
shouldPresentOnLaunch: Bool) -> StartupPresentationRoute
|
||||
{
|
||||
if gatewayConnected {
|
||||
return .none
|
||||
}
|
||||
if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete {
|
||||
return .onboarding
|
||||
}
|
||||
if !hasExistingGatewayConfig {
|
||||
return .settings
|
||||
}
|
||||
return .none
|
||||
}
|
||||
|
||||
static func shouldPresentQuickSetup(
|
||||
quickSetupDismissed: Bool,
|
||||
showOnboarding: Bool,
|
||||
hasPresentedSheet: Bool,
|
||||
gatewayConnected: Bool,
|
||||
hasExistingGatewayConfig: Bool,
|
||||
discoveredGatewayCount: Int) -> Bool
|
||||
{
|
||||
guard !quickSetupDismissed else { return false }
|
||||
guard !showOnboarding else { return false }
|
||||
guard !hasPresentedSheet else { return false }
|
||||
guard !gatewayConnected else { return false }
|
||||
guard !hasExistingGatewayConfig else { return false }
|
||||
return discoveredGatewayCount > 0
|
||||
}
|
||||
|
||||
struct SidebarGroup: Identifiable {
|
||||
let title: String
|
||||
let destinations: [SidebarDestination]
|
||||
|
||||
var id: String {
|
||||
self.title
|
||||
}
|
||||
}
|
||||
|
||||
static let sidebarGroups: [SidebarGroup] = [
|
||||
SidebarGroup(title: "CHAT", destinations: [.chat, .talk]),
|
||||
SidebarGroup(
|
||||
title: "CONTROL",
|
||||
destinations: [
|
||||
.overview,
|
||||
.activity,
|
||||
.agents,
|
||||
.workboard,
|
||||
.skillWorkshop,
|
||||
.instances,
|
||||
.sessions,
|
||||
.dreaming,
|
||||
.usage,
|
||||
.cron,
|
||||
]),
|
||||
SidebarGroup(
|
||||
title: "SETTINGS",
|
||||
destinations: [.settings]),
|
||||
SidebarGroup(title: "REFERENCE", destinations: [.docs]),
|
||||
]
|
||||
|
||||
static var phoneControlGroups: [SidebarGroup] {
|
||||
self.sidebarGroups
|
||||
.map { group in
|
||||
SidebarGroup(
|
||||
title: group.title,
|
||||
destinations: group.destinations.filter { $0 != .agents })
|
||||
}
|
||||
.filter { !$0.destinations.isEmpty }
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func gatewayActionsDialog(
|
||||
isPresented: Binding<Bool>,
|
||||
onDisconnect: @escaping () -> Void,
|
||||
onOpenSettings: @escaping () -> Void) -> some View
|
||||
{
|
||||
self.confirmationDialog(
|
||||
"Gateway",
|
||||
isPresented: isPresented,
|
||||
titleVisibility: .visible)
|
||||
{
|
||||
Button("Disconnect", role: .destructive) {
|
||||
onDisconnect()
|
||||
}
|
||||
Button("Open Settings") {
|
||||
onOpenSettings()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Disconnect from the gateway?")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,15 @@ Sources/Design/AgentProTab+Usage.swift
|
||||
Sources/Design/AgentProTab+DetailComponents.swift
|
||||
Sources/Design/AgentProTab+GatewayData.swift
|
||||
Sources/Design/AgentProModels.swift
|
||||
Sources/Design/IPadActivityScreen.swift
|
||||
Sources/Design/IPadSidebarFeaturePreviews.swift
|
||||
Sources/Design/IPadSidebarFeatureScreens.swift
|
||||
Sources/Design/IPadSkillWorkshopScreen.swift
|
||||
Sources/Design/IPadSidebarScreenChrome.swift
|
||||
Sources/Design/IPadWorkboardScreen.swift
|
||||
Sources/Design/OpenClawDocsScreen.swift
|
||||
Sources/Design/RootTabsPhoneControlHub.swift
|
||||
Sources/Design/SettingsChannelsDestination.swift
|
||||
Sources/EventKit/EventKitAuthorization.swift
|
||||
Sources/Gateway/DeepLinkAgentPromptAlert.swift
|
||||
Sources/Gateway/ExecApprovalPromptDialog.swift
|
||||
@@ -73,6 +82,7 @@ Sources/Push/PushRelayClient.swift
|
||||
Sources/Push/PushRelayKeychainStore.swift
|
||||
Sources/Reminders/RemindersService.swift
|
||||
Sources/RootTabs.swift
|
||||
Sources/RootTabsNavigation.swift
|
||||
Sources/RootView.swift
|
||||
Sources/Screen/ScreenController.swift
|
||||
Sources/Screen/ScreenRecordService.swift
|
||||
@@ -86,7 +96,6 @@ Sources/SessionKey.swift
|
||||
Sources/Settings/PrivacyAccessSectionView.swift
|
||||
Sources/Settings/SettingsNetworkingHelpers.swift
|
||||
Sources/Settings/VoiceWakeWordsSettingsView.swift
|
||||
Sources/Status/GatewayActionsDialog.swift
|
||||
Sources/Status/GatewayStatusBuilder.swift
|
||||
Sources/Status/VoiceWakeToast.swift
|
||||
Sources/Voice/TalkGatewayPermissionState.swift
|
||||
|
||||
27
apps/ios/Tests/CommandCenterTabLayoutTests.swift
Normal file
27
apps/ios/Tests/CommandCenterTabLayoutTests.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@MainActor
|
||||
@Suite struct CommandCenterTabLayoutTests {
|
||||
@Test func splitLayoutDisabledForCompactWidth() {
|
||||
#expect(
|
||||
!CommandCenterTab.usesSplitSectionsLayout(
|
||||
horizontalSizeClass: .compact,
|
||||
containerWidth: 1_200))
|
||||
}
|
||||
|
||||
@Test func splitLayoutDisabledBelowWidthThreshold() {
|
||||
#expect(
|
||||
!CommandCenterTab.usesSplitSectionsLayout(
|
||||
horizontalSizeClass: .regular,
|
||||
containerWidth: 900))
|
||||
}
|
||||
|
||||
@Test func splitLayoutEnabledForRegularWideLayout() {
|
||||
#expect(
|
||||
CommandCenterTab.usesSplitSectionsLayout(
|
||||
horizontalSizeClass: .regular,
|
||||
containerWidth: 1_024))
|
||||
}
|
||||
}
|
||||
@@ -33,4 +33,12 @@ import Testing
|
||||
|
||||
#expect(state == .connecting)
|
||||
}
|
||||
|
||||
@Test func chatGatewayPillLabelsMatchDisplayState() {
|
||||
#expect(ChatProTab.gatewayPillTitle(state: .disconnected, isGatewayUsable: false) == "Offline")
|
||||
#expect(ChatProTab.gatewayPillTitle(state: .connecting, isGatewayUsable: false) == "Connecting")
|
||||
#expect(ChatProTab.gatewayPillTitle(state: .error, isGatewayUsable: false) == "Attention")
|
||||
#expect(ChatProTab.gatewayPillTitle(state: .connected, isGatewayUsable: true) == "Connected")
|
||||
#expect(ChatProTab.gatewayPillTitle(state: .connected, isGatewayUsable: false) == "Unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1025,6 +1025,38 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
#expect(appModel.openChatRequestID == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func operatorAdminScopeCacheRefreshesFromStoredToken() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
|
||||
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
|
||||
defer {
|
||||
if let previousStateDir {
|
||||
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
||||
} else {
|
||||
unsetenv("OPENCLAW_STATE_DIR")
|
||||
}
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
#expect(appModel.hasOperatorAdminScope == false)
|
||||
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: "operator",
|
||||
token: "operator-token",
|
||||
scopes: ["operator.read", "operator.admin"])
|
||||
appModel._test_refreshOperatorAdminScopeFromStore()
|
||||
#expect(appModel.hasOperatorAdminScope == true)
|
||||
|
||||
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: "operator")
|
||||
appModel._test_refreshOperatorAdminScopeFromStore()
|
||||
#expect(appModel.hasOperatorAdminScope == false)
|
||||
}
|
||||
|
||||
@Test @MainActor func sendVoiceTranscriptThrowsWhenGatewayOffline() async {
|
||||
let appModel = NodeAppModel()
|
||||
await #expect(throws: Error.self) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
import UIKit
|
||||
@testable import OpenClaw
|
||||
|
||||
@MainActor
|
||||
@@ -38,4 +40,432 @@ import Testing
|
||||
|
||||
#expect(!shouldPresent)
|
||||
}
|
||||
|
||||
@Test func sidebarTabsEnabledForIPadRegularWidth() {
|
||||
#expect(
|
||||
RootTabs.shouldUseSidebarTabs(
|
||||
idiom: .pad,
|
||||
horizontalSizeClass: .regular))
|
||||
}
|
||||
|
||||
@Test func sidebarTabsEnabledForIPadCompactWidth() {
|
||||
#expect(
|
||||
RootTabs.shouldUseSidebarTabs(
|
||||
idiom: .pad,
|
||||
horizontalSizeClass: .compact))
|
||||
}
|
||||
|
||||
@Test func sidebarTabsDisabledForIPhone() {
|
||||
#expect(
|
||||
!RootTabs.shouldUseSidebarTabs(
|
||||
idiom: .phone,
|
||||
horizontalSizeClass: .regular))
|
||||
}
|
||||
|
||||
@Test func sidebarGroupsMatchAdaptiveNavigationModel() {
|
||||
let groups = RootTabs.sidebarGroups
|
||||
let destinationIDs = RootTabs.SidebarDestination.allCases.map(\.rawValue)
|
||||
|
||||
#expect(groups.map(\.title) == ["CHAT", "CONTROL", "SETTINGS", "REFERENCE"])
|
||||
#expect(groups[0].destinations.map(\.rawValue) == ["chat", "talk"])
|
||||
#expect(groups[1].destinations == [
|
||||
.overview,
|
||||
.activity,
|
||||
.agents,
|
||||
.workboard,
|
||||
.skillWorkshop,
|
||||
.instances,
|
||||
.sessions,
|
||||
.dreaming,
|
||||
.usage,
|
||||
.cron,
|
||||
])
|
||||
#expect(groups[2].destinations == [.settings])
|
||||
#expect(groups[3].destinations == [.docs])
|
||||
#expect(destinationIDs == [
|
||||
"chat",
|
||||
"talk",
|
||||
"overview",
|
||||
"activity",
|
||||
"agents",
|
||||
"workboard",
|
||||
"skillWorkshop",
|
||||
"instances",
|
||||
"sessions",
|
||||
"dreaming",
|
||||
"usage",
|
||||
"cron",
|
||||
"docs",
|
||||
"settings",
|
||||
"gateway",
|
||||
])
|
||||
#expect(!destinationIDs.contains("agent"))
|
||||
#expect(!RootTabs.sidebarGroups.flatMap(\.destinations).contains(.gateway))
|
||||
}
|
||||
|
||||
@Test func phoneControlGroupsAvoidDuplicatingTheAgentTab() {
|
||||
let groups = RootTabs.phoneControlGroups
|
||||
let destinations = groups.flatMap(\.destinations)
|
||||
|
||||
#expect(groups.map(\.title) == ["CHAT", "CONTROL", "SETTINGS", "REFERENCE"])
|
||||
#expect(!destinations.contains(.agents))
|
||||
#expect(RootTabs.sidebarGroups.flatMap(\.destinations).contains(.agents))
|
||||
#expect(destinations.contains(.dreaming))
|
||||
#expect(destinations.contains(.instances))
|
||||
}
|
||||
|
||||
@Test func sidebarUsesCompactLabelsForLongRoutes() {
|
||||
#expect(RootTabs.SidebarDestination.settings.title == "Settings")
|
||||
#expect(RootTabs.SidebarDestination.gateway.title == "Settings / Gateway")
|
||||
#expect(RootTabs.SidebarDestination.gateway.sidebarTitle == "Connection")
|
||||
}
|
||||
|
||||
@Test func phoneHubUsesRootTabsOnlyForNativeChatAgentAndGateway() {
|
||||
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.chat))
|
||||
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.talk))
|
||||
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.agents))
|
||||
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.gateway))
|
||||
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.settings))
|
||||
|
||||
for destination in RootTabs.SidebarDestination.allCases
|
||||
where destination != .chat && destination != .talk && destination != .agents && destination != .gateway && destination != .settings
|
||||
{
|
||||
#expect(!RootTabs.shouldOpenRootTabFromPhoneHub(destination))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func legacyInitialTabsMapToMatchingSidebarDestinations() {
|
||||
#expect(RootTabs.defaultSidebarDestination(for: .control) == .overview)
|
||||
#expect(RootTabs.defaultSidebarDestination(for: .chat) == .chat)
|
||||
#expect(RootTabs.defaultSidebarDestination(for: .talk) == .talk)
|
||||
#expect(RootTabs.defaultSidebarDestination(for: .agent) == .agents)
|
||||
#expect(RootTabs.defaultSidebarDestination(for: .settings) == .settings)
|
||||
}
|
||||
|
||||
@Test func skillWorkshopMutationsRequireAdminScope() {
|
||||
#expect(IPadSkillWorkshopScreen.shouldEnableProposalMutation(canWrite: true, hasOperatorAdminScope: true))
|
||||
#expect(!IPadSkillWorkshopScreen.shouldEnableProposalMutation(canWrite: true, hasOperatorAdminScope: false))
|
||||
#expect(!IPadSkillWorkshopScreen.shouldEnableProposalMutation(canWrite: false, hasOperatorAdminScope: true))
|
||||
}
|
||||
|
||||
@Test func skillWorkshopHeldFilterIncludesQuarantinedAndStale() {
|
||||
#expect(IPadSkillWorkshopScreen.proposalStatusFilters.contains("held"))
|
||||
#expect(IPadSkillWorkshopScreen.proposalStatusMatchesFilter(status: "quarantined", filter: "held"))
|
||||
#expect(IPadSkillWorkshopScreen.proposalStatusMatchesFilter(status: "stale", filter: "held"))
|
||||
#expect(!IPadSkillWorkshopScreen.proposalStatusMatchesFilter(status: "pending", filter: "held"))
|
||||
}
|
||||
|
||||
@Test func skillWorkshopBoardLanesMatchStatusFilter() {
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.proposalStatusBoardLanes(
|
||||
filter: "pending",
|
||||
proposalStatuses: ["pending", "applied"]) == ["pending"])
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.proposalStatusBoardLanes(
|
||||
filter: "held",
|
||||
proposalStatuses: ["quarantined", "stale"]) == ["quarantined", "stale"])
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.proposalStatusBoardLanes(
|
||||
filter: "all",
|
||||
proposalStatuses: ["pending", "needs-review"]) == [
|
||||
"pending",
|
||||
"quarantined",
|
||||
"stale",
|
||||
"applied",
|
||||
"rejected",
|
||||
"needs-review",
|
||||
])
|
||||
#expect(IPadSkillWorkshopScreen.proposalLaneLabel("quarantined") == "Quarantined")
|
||||
#expect(IPadSkillWorkshopScreen.proposalLaneLabel("pending") == "Pending")
|
||||
#expect(IPadSkillWorkshopScreen.proposalLaneLabel("needs-review") == "Needs Review")
|
||||
#expect(IPadSkillWorkshopScreen.proposalLaneLabel("manual_QA") == "Manual QA")
|
||||
}
|
||||
|
||||
@Test func skillWorkshopSelectionStaysInsideActiveFilter() {
|
||||
let proposals = [
|
||||
(id: "applied-1", status: "applied"),
|
||||
(id: "pending-1", status: "pending"),
|
||||
(id: "held-1", status: "quarantined"),
|
||||
]
|
||||
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.nextSelectedProposalID(
|
||||
current: "applied-1",
|
||||
proposals: proposals,
|
||||
filter: "pending") == "pending-1")
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.nextSelectedProposalID(
|
||||
current: "held-1",
|
||||
proposals: proposals,
|
||||
filter: "held") == "held-1")
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.nextSelectedProposalID(
|
||||
current: "pending-1",
|
||||
visibleProposalIDs: ["held-1"]) == "held-1")
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.nextSelectedProposalID(
|
||||
current: "pending-1",
|
||||
visibleProposalIDs: []) == nil)
|
||||
}
|
||||
|
||||
@Test func workboardBoardScopeLabelsStayCompact() {
|
||||
#expect(IPadWorkboardScreen.normalizedScopeID(" planning ") == "planning")
|
||||
#expect(IPadWorkboardScreen.boardScopeLabel(for: "") == "All boards")
|
||||
#expect(IPadWorkboardScreen.boardScopeLabel(for: "planning") == "planning")
|
||||
#expect(IPadWorkboardScreen.boardScopeOptions(
|
||||
knownBoardIDs: ["default", " empty-board ", ""],
|
||||
cardBoardIDs: ["planning", "default"]) == ["default", "empty-board", "planning"])
|
||||
#expect(IPadWorkboardScreen
|
||||
.workboardSubtitle(boardScopeLabel: "All boards", selectedStatus: "active") == "All boards / Active")
|
||||
#expect(IPadWorkboardScreen
|
||||
.workboardSubtitle(boardScopeLabel: "planning", selectedStatus: "running") == "planning / Running")
|
||||
}
|
||||
|
||||
@Test func workboardCompactUnavailableCopyExplainsRealCapabilityState() {
|
||||
#expect(IPadWorkboardScreen
|
||||
.compactWriteUnavailableMessage(canRead: false) ==
|
||||
"Connect from Settings to create, move, and dispatch cards.")
|
||||
#expect(IPadWorkboardScreen.compactWriteUnavailableMessage(canRead: true) == "Read-only gateway.")
|
||||
}
|
||||
|
||||
@Test func skillWorkshopAgentScopeNormalizesGatewayIds() {
|
||||
#expect(IPadSkillWorkshopScreen.normalizedScopeID(" aiden ") == "aiden")
|
||||
#expect(IPadSkillWorkshopScreen.normalizedScopeID(nil) == "")
|
||||
}
|
||||
|
||||
@Test func channelLifecycleControlsRequireAdminScope() {
|
||||
#expect(SettingsChannelsDestination.shouldEnableChannelOperation(canRead: true, hasOperatorAdminScope: true))
|
||||
#expect(!SettingsChannelsDestination.shouldEnableChannelOperation(canRead: true, hasOperatorAdminScope: false))
|
||||
#expect(!SettingsChannelsDestination.shouldEnableChannelOperation(canRead: false, hasOperatorAdminScope: true))
|
||||
}
|
||||
|
||||
@Test func clickClackStaysInChannelsIntegrationMetadata() {
|
||||
#expect(SettingsChannelsDestination.fallbackLabel("clickclack") == "ClickClack")
|
||||
#expect(SettingsChannelsDestination.fallbackDetail("clickclack") == "Self-hosted chat bot routing.")
|
||||
#expect(SettingsChannelsDestination.fallbackSystemImage("clickclack") == "bubble.left.and.bubble.right")
|
||||
}
|
||||
|
||||
@Test func iPadOverviewCanSuppressStandaloneHeaderBranding() {
|
||||
#expect(CommandCenterTab.shouldShowHeaderMark(hasLeadingAction: false, showsHeaderMark: true))
|
||||
#expect(!CommandCenterTab.shouldShowHeaderMark(hasLeadingAction: true, showsHeaderMark: true))
|
||||
#expect(!CommandCenterTab.shouldShowHeaderMark(hasLeadingAction: false, showsHeaderMark: false))
|
||||
}
|
||||
|
||||
@Test func chatSidebarDestinationCanUseRouteHeaderInsteadOfAgentBranding() {
|
||||
let standalone = ChatProTab()
|
||||
let routed = ChatProTab(
|
||||
headerTitle: "Chat",
|
||||
headerSubtitle: "Agent conversation",
|
||||
showsAgentBadge: false,
|
||||
openSettings: {})
|
||||
|
||||
#expect(standalone.showsAgentBadge)
|
||||
#expect(standalone.headerTitle == nil)
|
||||
#expect(standalone.openSettings == nil)
|
||||
#expect(routed.headerTitle == "Chat")
|
||||
#expect(routed.headerSubtitle == "Agent conversation")
|
||||
#expect(!routed.showsAgentBadge)
|
||||
#expect(routed.openSettings != nil)
|
||||
#expect(ChatProTab.defaultHeaderTitle(showsAgentBadge: true, agentDisplayName: "OpenClaw") == "OpenClaw")
|
||||
#expect(ChatProTab.defaultHeaderTitle(showsAgentBadge: false, agentDisplayName: "OpenClaw") == "Chat")
|
||||
}
|
||||
|
||||
@Test func agentRoutesCanOpenGatewaySettingsFromHeaderPill() {
|
||||
let standalone = AgentProTab()
|
||||
let routed = AgentProTab(
|
||||
directRoute: .instances,
|
||||
headerTitle: "Instances",
|
||||
openSettings: {})
|
||||
|
||||
#expect(standalone.headerTitle == "Agents")
|
||||
#expect(standalone.directRoute == nil)
|
||||
#expect(standalone.openSettings == nil)
|
||||
#expect(AgentProTab(directRoute: .agents).directRoute == .agents)
|
||||
#expect(routed.directRoute == .instances)
|
||||
#expect(routed.headerTitle == "Instances")
|
||||
#expect(routed.openSettings != nil)
|
||||
}
|
||||
|
||||
@Test func workboardDispatchSummaryReportsStartedAndFailures() throws {
|
||||
let payload = Data(
|
||||
"""
|
||||
{
|
||||
"count": 2,
|
||||
"started": [{}],
|
||||
"startFailures": [{}],
|
||||
"promoted": [],
|
||||
"reclaimed": [],
|
||||
"blocked": [],
|
||||
"orchestrated": []
|
||||
}
|
||||
""".utf8)
|
||||
let summary = try JSONDecoder().decode(IPadWorkboardDispatchSummary.self, from: payload)
|
||||
|
||||
#expect(summary.summaryText == "2 dispatched: 1 started, 1 failed.")
|
||||
}
|
||||
|
||||
@Test func talkSidebarDestinationCanReceiveRevealAction() {
|
||||
let action = OpenClawSidebarHeaderAction(
|
||||
systemName: "sidebar.left",
|
||||
accessibilityLabel: "Show Sidebar",
|
||||
action: {})
|
||||
let routed = TalkProTab(headerLeadingAction: action, openSettings: {})
|
||||
|
||||
#expect(routed.headerLeadingAction?.systemName == "sidebar.left")
|
||||
#expect(routed.headerLeadingAction?.accessibilityLabel == "Show Sidebar")
|
||||
}
|
||||
|
||||
@Test func iPadPortraitUsesHiddenDrawerSidebar() {
|
||||
let mode = RootTabs.sidebarLayoutMode(containerSize: CGSize(width: 1024, height: 1366))
|
||||
|
||||
#expect(mode == .drawer)
|
||||
#expect(!RootTabs.preferredSidebarVisibility(layoutMode: mode))
|
||||
}
|
||||
|
||||
@Test func iPadWideLandscapeUsesVisibleSplitSidebar() {
|
||||
let mode = RootTabs.sidebarLayoutMode(containerSize: CGSize(width: 1366, height: 1024))
|
||||
|
||||
#expect(mode == .split)
|
||||
#expect(RootTabs.preferredSidebarVisibility(layoutMode: mode))
|
||||
}
|
||||
|
||||
@Test func iPadSplitSidebarWidthStaysUsable() {
|
||||
let width = RootTabs.sidebarWidth(containerWidth: 1366, isDrawerLayout: false)
|
||||
|
||||
#expect(width >= RootTabs.sidebarSplitIdealWidth)
|
||||
#expect(width <= RootTabs.sidebarSplitMaximumWidth)
|
||||
}
|
||||
|
||||
@Test func iPadCollapsedSplitSidebarUsesHeaderRevealWithoutReservedRail() {
|
||||
#expect(
|
||||
RootTabs.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: false,
|
||||
layoutMode: .split))
|
||||
#expect(
|
||||
RootTabs.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: true,
|
||||
layoutMode: .split))
|
||||
#expect(
|
||||
RootTabs.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: false,
|
||||
layoutMode: .drawer))
|
||||
#expect(
|
||||
!RootTabs.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: true,
|
||||
layoutMode: .drawer))
|
||||
}
|
||||
|
||||
@Test func initialSidebarVisibilityParsesLaunchArgument() {
|
||||
#expect(
|
||||
RootTabs.requestedInitialSidebarVisibility(arguments: [
|
||||
"OpenClaw",
|
||||
"--openclaw-sidebar-visibility",
|
||||
"hidden",
|
||||
]) == false)
|
||||
#expect(
|
||||
RootTabs.requestedInitialSidebarVisibility(arguments: [
|
||||
"OpenClaw",
|
||||
"--openclaw-sidebar-visibility",
|
||||
"visible",
|
||||
]) == true)
|
||||
#expect(
|
||||
RootTabs.requestedInitialSidebarVisibility(arguments: [
|
||||
"OpenClaw",
|
||||
"--openclaw-sidebar-visibility",
|
||||
"unknown",
|
||||
]) == nil)
|
||||
}
|
||||
|
||||
@Test func sidebarControlsHaveStableAccessibilityIdentifiers() {
|
||||
#expect(RootTabs.sidebarShowButtonAccessibilityIdentifier == "RootTabs.Sidebar.Show")
|
||||
#expect(RootTabs.sidebarHideButtonAccessibilityIdentifier == "RootTabs.Sidebar.Hide")
|
||||
}
|
||||
|
||||
@Test func iPadDrawerSidebarWidthStaysInsideScreen() {
|
||||
let width = RootTabs.sidebarWidth(containerWidth: 744, isDrawerLayout: true)
|
||||
|
||||
#expect(width >= 280)
|
||||
#expect(width <= RootTabs.sidebarDrawerMaximumWidth)
|
||||
}
|
||||
|
||||
@Test func narrowLandscapeKeepsDrawerSidebar() {
|
||||
let mode = RootTabs.sidebarLayoutMode(containerSize: CGSize(width: 900, height: 600))
|
||||
|
||||
#expect(mode == .drawer)
|
||||
#expect(!RootTabs.preferredSidebarVisibility(layoutMode: mode))
|
||||
}
|
||||
|
||||
@Test func drawerSelectionCollapsesSidebarButSplitSelectionDoesNot() {
|
||||
#expect(RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .drawer))
|
||||
#expect(!RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .split))
|
||||
}
|
||||
|
||||
@Test func hiddenSidebarShowsRevealControl() {
|
||||
#expect(RootTabs.shouldShowSidebarRevealControl(isSidebarVisible: false))
|
||||
}
|
||||
|
||||
@Test func sidebarRevealControlsHideWhenSidebarIsVisible() {
|
||||
#expect(!RootTabs.shouldShowSidebarRevealControl(isSidebarVisible: true))
|
||||
}
|
||||
|
||||
@Test func iPadSplitPrefersIntegratedVisibleSidebar() {
|
||||
#expect(RootTabs.preferredSidebarVisibility(layoutMode: .split))
|
||||
#expect(!RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .split))
|
||||
#expect(!RootTabs.preferredSidebarVisibility(layoutMode: .drawer))
|
||||
#expect(RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .drawer))
|
||||
}
|
||||
|
||||
@Test func destinationHeadersOwnHiddenSidebarRevealControl() {
|
||||
#expect(
|
||||
RootTabs.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: false,
|
||||
layoutMode: .drawer))
|
||||
#expect(
|
||||
RootTabs.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: false,
|
||||
layoutMode: .split))
|
||||
#expect(
|
||||
!RootTabs.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: true,
|
||||
layoutMode: .drawer))
|
||||
#expect(
|
||||
RootTabs.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: true,
|
||||
layoutMode: .split))
|
||||
}
|
||||
|
||||
@Test func workboardAndSkillWorkshopUseCompactTaskFlowOnPhoneSizes() {
|
||||
#expect(
|
||||
IPadWorkboardScreen.usesCompactTaskFlow(
|
||||
horizontalSizeClass: .compact,
|
||||
verticalSizeClass: .regular))
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.usesCompactTaskFlow(
|
||||
horizontalSizeClass: .compact,
|
||||
verticalSizeClass: .regular))
|
||||
#expect(
|
||||
IPadWorkboardScreen.usesCompactTaskFlow(
|
||||
horizontalSizeClass: .regular,
|
||||
verticalSizeClass: .compact))
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.usesCompactTaskFlow(
|
||||
horizontalSizeClass: .regular,
|
||||
verticalSizeClass: .compact))
|
||||
}
|
||||
|
||||
@Test func workboardAndSkillWorkshopKeepRegularTaskFlowOnWideIPadSizes() {
|
||||
#expect(
|
||||
!IPadWorkboardScreen.usesCompactTaskFlow(
|
||||
horizontalSizeClass: .regular,
|
||||
verticalSizeClass: .regular))
|
||||
#expect(
|
||||
!IPadSkillWorkshopScreen.usesCompactTaskFlow(
|
||||
horizontalSizeClass: .regular,
|
||||
verticalSizeClass: .regular))
|
||||
}
|
||||
|
||||
@Test func phoneHubLeavesRoomForFloatingTabBar() {
|
||||
#expect(RootTabsPhoneControlHub.bottomScrollInset(verticalSizeClass: .regular) == 112)
|
||||
#expect(RootTabsPhoneControlHub.bottomScrollInset(verticalSizeClass: .compact) == 72)
|
||||
}
|
||||
}
|
||||
|
||||
65
apps/ios/Tests/RootTabsSidebarRegressionTests.swift
Normal file
65
apps/ios/Tests/RootTabsSidebarRegressionTests.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@Suite struct RootTabsSidebarRegressionTests {
|
||||
@Test func iPadSplitHiddenSidebarUsesHeaderRevealInsteadOfReservedRail() throws {
|
||||
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let navigationSource = try String(contentsOf: Self.rootTabsNavigationSourceURL(), encoding: .utf8)
|
||||
let splitContent = try Self.extract(
|
||||
source,
|
||||
from: "private func sidebarNavigationSplitContent(sidebarWidth: CGFloat) -> some View",
|
||||
to: "private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View")
|
||||
|
||||
#expect(splitContent.contains("HStack(spacing: 0)"))
|
||||
#expect(splitContent.contains("self.sidebarColumn"))
|
||||
#expect(splitContent.contains(".frame(width: sidebarWidth, alignment: .topLeading)"))
|
||||
#expect(splitContent.contains(".overlay(alignment: .trailing)"))
|
||||
#expect(!splitContent.contains("self.syncSidebarVisibility(from: visibility)"))
|
||||
#expect(!source.contains("NavigationSplitViewVisibility"))
|
||||
#expect(!source.contains("@State private var splitColumnVisibility: NavigationSplitViewVisibility"))
|
||||
#expect(!splitContent.contains("NavigationSplitView"))
|
||||
#expect(!splitContent.contains("self.collapsedSidebarRail"))
|
||||
#expect(!source.contains("private var collapsedSidebarRail: some View"))
|
||||
#expect(!source.contains("Self.sidebarCollapsedRailWidth"))
|
||||
#expect(source.contains("shouldShowSidebarRevealInDestinationHeader"))
|
||||
#expect(!navigationSource.contains("static let sidebarCollapsedRailWidth"))
|
||||
#expect(!navigationSource.contains("static func sidebarSplitColumnVisibility(isSidebarVisible: Bool)"))
|
||||
#expect(!navigationSource
|
||||
.contains("static func sidebarIsVisible(splitColumnVisibility: NavigationSplitViewVisibility)"))
|
||||
}
|
||||
|
||||
@Test func initialSidebarVisibilitySurvivesFirstLayoutMeasurement() throws {
|
||||
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let layoutUpdate = try Self.extract(
|
||||
source,
|
||||
from: "private func updateSidebarLayout(containerSize: CGSize, force: Bool)",
|
||||
to: "private func setSidebarVisible(_ isVisible: Bool)")
|
||||
|
||||
#expect(source.contains("@State private var didResolveSidebarLayout: Bool = false"))
|
||||
#expect(layoutUpdate.contains("let didResolvePreviousLayout = self.didResolveSidebarLayout"))
|
||||
#expect(layoutUpdate.contains("self.didResolveSidebarLayout = true"))
|
||||
#expect(layoutUpdate.contains("if layoutModeDidChange && didResolvePreviousLayout"))
|
||||
#expect(layoutUpdate.contains("guard force || !self.sidebarVisibilityUserOverridden else { return }"))
|
||||
}
|
||||
|
||||
private static func rootTabsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/RootTabs.swift")
|
||||
}
|
||||
|
||||
private static func rootTabsNavigationSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/RootTabsNavigation.swift")
|
||||
}
|
||||
|
||||
private static func extract(_ source: String, from start: String, to end: String) throws -> String {
|
||||
let startRange = try #require(source.range(of: start))
|
||||
let tail = source[startRange.lowerBound...]
|
||||
let endRange = try #require(tail.range(of: end))
|
||||
return String(tail[..<endRange.lowerBound])
|
||||
}
|
||||
}
|
||||
812
apps/ios/Tests/RootTabsSourceGuardTests.swift
Normal file
812
apps/ios/Tests/RootTabsSourceGuardTests.swift
Normal file
@@ -0,0 +1,812 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@Suite struct RootTabsSourceGuardTests {
|
||||
@Test func hiddenSidebarRevealUsesDestinationHeaderWithoutReservedRail() throws {
|
||||
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let componentSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("sidebarHeaderLeadingAction"))
|
||||
#expect(source.contains("Hide Sidebar"))
|
||||
#expect(source.contains("Show Sidebar"))
|
||||
#expect(source.contains("shouldShowSidebarRevealInDestinationHeader"))
|
||||
#expect(source.contains("layoutMode: self.isSidebarDrawerLayout ? .drawer : .split"))
|
||||
#expect(componentSource.contains("OpenClawSidebarHeaderLeadingSlot"))
|
||||
#expect(componentSource.contains(".frame(width: 44, height: 44, alignment: .center)"))
|
||||
#expect(source.contains(".safeAreaPadding(.top, 8)"))
|
||||
#expect(source.contains("Self.sidebarShowButtonAccessibilityIdentifier"))
|
||||
#expect(source.contains("Self.sidebarHideButtonAccessibilityIdentifier"))
|
||||
#expect(source.contains("accessibilityLabel: \"Hide Sidebar\""))
|
||||
#expect(source.contains("accessibilityLabel: \"Show Sidebar\""))
|
||||
#expect(source.contains("action: { self.hideSidebar() }"))
|
||||
#expect(source.contains("action: { self.showSidebar() }"))
|
||||
#expect(!source.contains("private var collapsedSidebarRail: some View"))
|
||||
#expect(!source.contains("Self.sidebarCollapsedRailWidth"))
|
||||
#expect(source.contains("requestedInitialSidebarVisibility"))
|
||||
#expect(!source.contains("@State private var splitColumnVisibility: NavigationSplitViewVisibility"))
|
||||
#expect(!source.contains("NavigationSplitView(columnVisibility: self.$splitColumnVisibility)"))
|
||||
#expect(source.contains("HStack(spacing: 0)"))
|
||||
#expect(!source.contains("self.syncSidebarVisibility(from: visibility)"))
|
||||
#expect(!source.contains("shouldReserveSidebarRevealInset"))
|
||||
#expect(!source.contains("safeAreaInset(edge: .top"))
|
||||
#expect(!source.contains("thinMaterial, in: Circle"))
|
||||
#expect(!source.contains("sidebarRevealInset"))
|
||||
#expect(source.contains("Color.black.opacity(0.28)"))
|
||||
#expect(source.contains(".background(Color(uiColor: .systemBackground))"))
|
||||
#expect(!source.contains("sidebarRevealCornerButton"))
|
||||
#expect(!source.contains("shouldShowSidebarRevealOverlay"))
|
||||
#expect(!source.contains("shouldShowOverviewHeaderSidebarReveal"))
|
||||
}
|
||||
|
||||
@Test func iPadSplitUsesSlidingSidebarWhilePortraitKeepsDrawerOverlay() throws {
|
||||
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let splitContent = try Self.extract(
|
||||
source,
|
||||
from: "private func sidebarNavigationSplitContent(sidebarWidth: CGFloat) -> some View",
|
||||
to: "private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View")
|
||||
let drawerContent = try Self.extract(
|
||||
source,
|
||||
from: "private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View",
|
||||
to: "private var sidebarDetailShell: some View")
|
||||
|
||||
#expect(!source.contains("@State private var splitColumnVisibility: NavigationSplitViewVisibility"))
|
||||
#expect(!source.contains("Self.sidebarSplitColumnVisibility(isSidebarVisible:"))
|
||||
#expect(!source.contains("self.syncSidebarVisibility(from: visibility)"))
|
||||
#expect(splitContent.contains("HStack(spacing: 0)"))
|
||||
#expect(splitContent.contains("self.sidebarColumn"))
|
||||
#expect(splitContent.contains(".frame(width: sidebarWidth, alignment: .topLeading)"))
|
||||
#expect(splitContent.contains(".overlay(alignment: .trailing)"))
|
||||
#expect(splitContent.contains("self.sidebarVerticalSeparator"))
|
||||
#expect(splitContent.contains("self.sidebarDetailNavigationShell"))
|
||||
#expect(!splitContent.contains("NavigationSplitView"))
|
||||
#expect(!splitContent.contains("self.collapsedSidebarRail"))
|
||||
#expect(!source.contains("Self.sidebarCollapsedRailWidth"))
|
||||
#expect(drawerContent.contains("ZStack(alignment: .topLeading)"))
|
||||
#expect(drawerContent.contains("Color.black.opacity(0.28)"))
|
||||
#expect(drawerContent.contains(".transition(.move(edge: .leading).combined(with: .opacity))"))
|
||||
#expect(!drawerContent.contains("NavigationSplitView"))
|
||||
}
|
||||
|
||||
@Test func sidebarKeepsNavigationModelDestinationOnly() throws {
|
||||
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let navigationSource = try String(contentsOf: Self.rootTabsNavigationSourceURL(), encoding: .utf8)
|
||||
let sidebarColumn = try Self.extract(
|
||||
source,
|
||||
from: "private var sidebarColumn: some View",
|
||||
to: "private var sidebarList: some View")
|
||||
|
||||
#expect(source.contains("ForEach(Self.sidebarGroups)"))
|
||||
#expect(!source.contains("Section(\"Context\")"))
|
||||
#expect(!source.contains("sidebarAgentMenu"))
|
||||
#expect(!source.contains("sidebarDeviceMenu"))
|
||||
#expect(sidebarColumn.contains("self.sidebarIdentityHeader"))
|
||||
#expect(source.contains("private var sidebarIdentityHeader: some View"))
|
||||
#expect(source.contains("OpenClawProMark(size: 30"))
|
||||
#expect(source.contains("Text(\"OpenClaw\")"))
|
||||
#expect(source.contains("private var sidebarGatewayStatusTitle: String"))
|
||||
#expect(source.contains("private var sidebarGatewayStatusColor: Color"))
|
||||
#expect(!sidebarColumn.contains("activeAgent"))
|
||||
#expect(!source.contains("shouldShowSidebarColumnHeader"))
|
||||
#expect(!source.contains("private var sidebarColumnHeader: some View"))
|
||||
#expect(sidebarColumn.contains(".safeAreaPadding(.top, 8)"))
|
||||
#expect(source.contains(".scrollContentBackground(.hidden)"))
|
||||
#expect(source.contains(".listStyle(.sidebar)"))
|
||||
#expect(source.contains("private var sidebarHorizontalSeparator: some View"))
|
||||
#expect(source.contains("private var sidebarVerticalSeparator: some View"))
|
||||
#expect(source.contains("1 / UIScreen.main.scale"))
|
||||
#expect(!source.contains("geometry.size.height >= Self.sidebarListNonScrollingMinimumHeight"))
|
||||
#expect(!source.contains("private var sidebarListContent: some View"))
|
||||
#expect(source.contains(".listRowSeparator(.hidden, edges: .all)"))
|
||||
#expect(source.contains(".listSectionSeparator(.hidden, edges: .all)"))
|
||||
#expect(source.contains("if self.isSidebarDrawerLayout {"))
|
||||
#expect(source.contains("private var sidebarFooter: some View"))
|
||||
#expect(!source.contains("LabeledContent(\"Version\""))
|
||||
#expect(navigationSource.contains("SidebarGroup(title: \"CHAT\", destinations: [.chat, .talk])"))
|
||||
#expect(!navigationSource.contains("title: \"AGENT\""))
|
||||
#expect(navigationSource.contains("case settings"))
|
||||
#expect(!navigationSource.contains("case settingsChannels"))
|
||||
#expect(!navigationSource.contains("case settingsApprovals"))
|
||||
#expect(!navigationSource.contains("case settingsPrivacy"))
|
||||
#expect(navigationSource.contains("SidebarGroup(\n title: \"SETTINGS\""))
|
||||
#expect(navigationSource.contains("destinations: [.settings]"))
|
||||
#expect(!navigationSource.contains("destinations: [.gateway"))
|
||||
#expect(!navigationSource.contains("SidebarGroup(title: \"REFERENCE\", destinations: [.settings"))
|
||||
#expect(navigationSource.contains("SidebarGroup(title: \"REFERENCE\", destinations: [.docs])"))
|
||||
}
|
||||
|
||||
@Test func sidebarRoutesUseDestinationHeadersInsteadOfRepeatedProductBranding() throws {
|
||||
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let agentOverviewSource = try String(contentsOf: Self.agentProTabOverviewSourceURL(), encoding: .utf8)
|
||||
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
|
||||
let sidebarDetail = try Self.extract(
|
||||
rootSource,
|
||||
from: "private var sidebarDetail: some View",
|
||||
to: "private var sidebarDetailNavigationShell: some View")
|
||||
|
||||
#expect(sidebarDetail.contains("headerTitle: \"Chat\""))
|
||||
#expect(sidebarDetail.contains("headerTitle: \"Overview\""))
|
||||
#expect(sidebarDetail.contains("headerTitle: \"Agents\""))
|
||||
#expect(sidebarDetail.contains("headerTitle: \"Instances\""))
|
||||
#expect(!sidebarDetail.contains("headerTitle: \"Nodes\""))
|
||||
#expect(sidebarDetail.contains("directRoute: .agents"))
|
||||
#expect(sidebarDetail.contains("directRoute: .instances"))
|
||||
#expect(sidebarDetail.contains("directRoute: .dreaming"))
|
||||
#expect(sidebarDetail.contains("directRoute: .usage"))
|
||||
#expect(sidebarDetail.contains("directRoute: .cron"))
|
||||
#expect(!sidebarDetail.contains("initialRoute: .nodes"))
|
||||
#expect(!sidebarDetail.contains("initialRoute: .usage"))
|
||||
#expect(!sidebarDetail.contains("initialRoute: .cron"))
|
||||
#expect(sidebarDetail.contains("headerTitle: \"Dreaming\""))
|
||||
#expect(sidebarDetail.contains("headerTitle: \"Usage\""))
|
||||
#expect(sidebarDetail.contains("headerTitle: \"Cron Jobs\""))
|
||||
#expect(!sidebarDetail.contains("headerTitle: \"OpenClaw\""))
|
||||
#expect(agentOverviewSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
#expect(agentOverviewSource.contains("title: self.headerTitle"))
|
||||
#expect(!agentOverviewSource.contains("Text(\"OpenClaw\")"))
|
||||
#expect(docsSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
#expect(docsSource.contains("title: \"Docs\""))
|
||||
#expect(!docsSource.contains("Text(\"OpenClaw Docs\")"))
|
||||
}
|
||||
|
||||
@Test func agentsDirectRouteKeepsSingleSidebarControl() throws {
|
||||
let source = try String(contentsOf: Self.agentProTabSourceURL(), encoding: .utf8)
|
||||
let destinationsSource = try String(contentsOf: Self.agentProTabDestinationsSourceURL(), encoding: .utf8)
|
||||
let nodesSource = try String(contentsOf: Self.agentProNodesDestinationSourceURL(), encoding: .utf8)
|
||||
let dreamingSource = try String(contentsOf: Self.agentProDreamingDestinationSourceURL(), encoding: .utf8)
|
||||
let directDestination = try Self.extract(
|
||||
source,
|
||||
from: "private func directDestination(for route: AgentRoute) -> some View",
|
||||
to: "private func applyInitialRouteIfNeeded()")
|
||||
|
||||
#expect(!directDestination.contains("ToolbarItem"))
|
||||
#expect(directDestination.contains("self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden"))
|
||||
#expect(destinationsSource.contains("self.directHeaderLeadingAction(for: .instances)"))
|
||||
#expect(destinationsSource.contains("self.directHeaderLeadingAction(for: .dreaming)"))
|
||||
#expect(destinationsSource.contains("self.directHeader(\n for: .usage"))
|
||||
#expect(destinationsSource.contains("self.directHeader(\n for: .cron"))
|
||||
#expect(destinationsSource.contains("self.directRoute == route ? self.headerLeadingAction : nil"))
|
||||
#expect(nodesSource.contains("OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)"))
|
||||
#expect(dreamingSource.contains("OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)"))
|
||||
}
|
||||
|
||||
@Test func routedHeadersUseSharedAdaptiveLayout() throws {
|
||||
let componentsSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8)
|
||||
let featureChromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8)
|
||||
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
|
||||
let overviewSource = try String(contentsOf: Self.commandCenterSourceURL(), encoding: .utf8)
|
||||
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
|
||||
let agentOverviewSource = try String(contentsOf: Self.agentProTabOverviewSourceURL(), encoding: .utf8)
|
||||
let settingsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(componentsSource.contains("struct OpenClawAdaptiveHeaderRow<Leading: View, Accessory: View>: View"))
|
||||
#expect(componentsSource.contains("ViewThatFits(in: .horizontal)"))
|
||||
#expect(componentsSource.contains("private var stackedLayout: some View"))
|
||||
#expect(componentsSource.contains(".layoutPriority(1)"))
|
||||
#expect(componentsSource.contains(".fixedSize(horizontal: true, vertical: false)"))
|
||||
#expect(featureChromeSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
#expect(docsSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
#expect(overviewSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
#expect(chatSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
#expect(agentOverviewSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
#expect(settingsSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
}
|
||||
|
||||
@Test func phoneHubKeepsDocsAsDestinationOnly() throws {
|
||||
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("case .docs:"))
|
||||
#expect(source.contains("OpenClawDocsScreen("))
|
||||
#expect(source.contains("headerLeadingAction: self.phoneDetailBackAction"))
|
||||
#expect(source.contains("gatewayAction: { self.openRootDestination(.gateway) }"))
|
||||
#expect(!source.contains("Label(\"Docs\", systemImage: \"book\")"))
|
||||
#expect(!source.contains("https://docs.openclaw.ai"))
|
||||
}
|
||||
|
||||
@Test func rootShellPreviewMatrixCoversPhoneAndIPadStates() throws {
|
||||
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("#Preview(\n \"Shell iPhone portrait\""))
|
||||
#expect(source.contains("#Preview(\n \"Shell iPhone landscape\""))
|
||||
#expect(source.contains("#Preview(\n \"Shell iPhone connected\""))
|
||||
#expect(source.contains("#Preview(\n \"Shell iPhone gateway error\""))
|
||||
#expect(source.contains("#Preview(\n \"Shell iPad portrait drawer\""))
|
||||
#expect(source.contains("#Preview(\n \"Shell iPad landscape split\""))
|
||||
#expect(source.contains("#Preview(\n \"Shell iPad connecting\""))
|
||||
#expect(source.contains("#Preview(\n \"Shell iPad gateway error\""))
|
||||
}
|
||||
|
||||
@Test func sharedChatPreviewMatrixCoversConnectionStates() throws {
|
||||
let source = try String(contentsOf: Self.sharedChatPreviewSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("#Preview(\"Chat connected\")"))
|
||||
#expect(source.contains("#Preview(\"Chat empty\")"))
|
||||
#expect(source.contains("#Preview(\"Chat loading\")"))
|
||||
#expect(source.contains("#Preview(\"Chat gateway error\")"))
|
||||
#expect(source.contains("enum Scenario"))
|
||||
#expect(source.contains("case connected"))
|
||||
#expect(source.contains("case empty"))
|
||||
#expect(source.contains("case loading"))
|
||||
#expect(source.contains("case error"))
|
||||
#expect(source.contains("Gateway not connected. Check Tailscale and retry."))
|
||||
}
|
||||
|
||||
@Test func phoneHubKeepsContentAboveFloatingTabBar() throws {
|
||||
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains(".safeAreaPadding(.bottom, self.bottomScrollInset)"))
|
||||
#expect(!source.contains(".padding(.bottom, self.bottomScrollInset)"))
|
||||
#expect(!source.contains("bottomViewportInset"))
|
||||
#expect(!source.contains("bottomTabBarClearance"))
|
||||
}
|
||||
|
||||
@Test func phoneHubHeaderStaysTaskFirst() throws {
|
||||
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("private var gatewayActionRow: some View"))
|
||||
#expect(source.contains("self.openRootDestination(.gateway)"))
|
||||
#expect(source.contains("private var phoneDetailBackAction: OpenClawSidebarHeaderAction"))
|
||||
#expect(source.contains("accessibilityLabel: \"Back to Control\""))
|
||||
#expect(source.contains("accessibilityIdentifier: \"OpenClawPhoneDetailBackButton\""))
|
||||
#expect(source.contains(".navigationBarBackButtonHidden(true)"))
|
||||
#expect(source.contains(".toolbar(.hidden, for: .navigationBar)"))
|
||||
#expect(source.matches(of: /headerLeadingAction: self\.phoneDetailBackAction/).count == 10)
|
||||
#expect(!source.contains("directRoute: .agents"))
|
||||
#expect(!source.contains("ToolbarItem(placement: .topBarTrailing)"))
|
||||
#expect(!source.contains("Image(systemName: \"gearshape\")"))
|
||||
#expect(!source.contains("self.metric(label:"))
|
||||
#expect(!source.contains("private func metric(label:"))
|
||||
}
|
||||
|
||||
@Test func workboardUsesRealGatewayMethods() throws {
|
||||
let source = try String(contentsOf: Self.iPadWorkboardScreenSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("workboard.cards.list"))
|
||||
#expect(source.contains("workboard.cards.create"))
|
||||
#expect(source.contains("workboard.cards.move"))
|
||||
#expect(source.contains("workboard.cards.archive"))
|
||||
#expect(source.contains("workboard.cards.dispatch"))
|
||||
#expect(source.contains(".padding(.bottom, 12)"))
|
||||
#expect(!source.contains("Workboard gateway contract unavailable"))
|
||||
#expect(!source.contains("supportsGatewayContract"))
|
||||
#expect(!source.contains("Compact mobile queue control"))
|
||||
#expect(!source.contains("Multi-column queue control"))
|
||||
}
|
||||
|
||||
@Test func workboardCreateActionSurfacesUnavailableReasons() throws {
|
||||
let source = try String(contentsOf: Self.iPadWorkboardScreenSourceURL(), encoding: .utf8)
|
||||
let createFunction = try Self.extract(
|
||||
source,
|
||||
from: "private func createCard() async -> Bool",
|
||||
to: "private func move(_ card: IPadWorkboardCard, to status: String) async")
|
||||
|
||||
#expect(source.contains("private var createUnavailableMessage: String?"))
|
||||
#expect(source.contains("Enter a title to create a card."))
|
||||
#expect(source.contains("Card creation is already in progress."))
|
||||
#expect(source.contains("private func newCardButton(expands: Bool) -> some View"))
|
||||
#expect(source.contains("private func beginCreateCard()"))
|
||||
#expect(source.contains("self.newCardButton(expands: false)"))
|
||||
#expect(source.contains("self.newCardButton(expands: true)"))
|
||||
#expect(source.contains("Label(\"New Card\", systemImage: \"plus\")"))
|
||||
#expect(source.contains(".accessibilityHint(\"Opens card title and notes entry\")"))
|
||||
#expect(source.contains(".accessibilityHint(self.createUnavailableMessage ?? \"Creates a workboard card\")"))
|
||||
#expect(source.contains("if await self.createCard()"))
|
||||
#expect(source.contains(".disabled(self.isCreatingCard)"))
|
||||
#expect(!source.contains("Button(\"Create\")"))
|
||||
#expect(!source.contains("TextField(\"New card\""))
|
||||
#expect(!source.contains(".disabled(!self.canWrite || self.draftTitle"))
|
||||
#expect(createFunction.contains("self.errorText = createUnavailableMessage"))
|
||||
#expect(createFunction.contains("return false"))
|
||||
#expect(createFunction.contains("return true"))
|
||||
}
|
||||
|
||||
@Test func taskScopeControlsSendRealGatewayParams() throws {
|
||||
let source = try Self.iPadTaskFeatureScreensSource()
|
||||
|
||||
#expect(source.contains("private var boardScopeMenu: some View"))
|
||||
#expect(source.contains("method: \"workboard.boards.list\""))
|
||||
#expect(source.contains("IPadWorkboardListParams(boardId: selectedBoardParam)"))
|
||||
#expect(source.contains("boardId: selectedBoardParam"))
|
||||
#expect(source
|
||||
.matches(
|
||||
of: /method: "workboard\.cards\.dispatch"[\s\S]*?IPadWorkboardListParams\(boardId: selectedBoardParam\)/)
|
||||
.count == 1)
|
||||
#expect(source.contains("private var agentScopeMenu: some View"))
|
||||
#expect(source.contains("IPadSkillProposalListParams(agentId: selectedAgentParam)"))
|
||||
#expect(source.contains("agentId: selectedAgentParam"))
|
||||
#expect(!source
|
||||
.contains(
|
||||
"params: EmptyParams(),\n timeoutSeconds: 20)\n let response = try JSONDecoder().decode(IPadSkillProposalManifest.self"))
|
||||
}
|
||||
|
||||
@Test func compactTaskRowsKeepPhoneNativeActions() throws {
|
||||
let source = try Self.iPadTaskFeatureScreensSource()
|
||||
let compactControls = try Self.extract(
|
||||
source,
|
||||
from: "private var compactQueueControls: some View",
|
||||
to: "private var compactRefreshButton: some View")
|
||||
|
||||
#expect(source.contains("struct IPadWorkboardQueueRow"))
|
||||
#expect(source.contains("private var actionMenuItems: some View"))
|
||||
#expect(source.components(separatedBy: ".contextMenu {").count - 1 >= 2)
|
||||
#expect(source.components(separatedBy: ".swipeActions(edge: .leading").count - 1 >= 2)
|
||||
#expect(source.components(separatedBy: ".swipeActions(edge: .trailing").count - 1 >= 2)
|
||||
#expect(source.contains("@State private var presentedProposalRoute: IPadSkillProposalSheetRoute?"))
|
||||
#expect(source.contains(".sheet(item: self.$presentedProposalRoute)"))
|
||||
#expect(source.contains("private func selectProposal("))
|
||||
#expect(!source.contains("proposalSheetPresented"))
|
||||
#expect(source.contains("self.presentedSheet = .card(card)"))
|
||||
#expect(!source.contains("Label(\"Gateway\", systemImage: \"network\")"))
|
||||
#expect(!source.contains("Button(\"Gateway\")"))
|
||||
#expect(!source.contains("actionTitle: self.canRead ? nil : \"Gateway\""))
|
||||
#expect(!source.contains("Workboard offline"))
|
||||
#expect(!source.contains("Workshop offline"))
|
||||
#expect(!source.contains("Connect gateway to"))
|
||||
#expect(source.contains("private var compactRefreshButton: some View"))
|
||||
#expect(source.contains("private var compactBoardScopeMenu: some View"))
|
||||
#expect(source.contains("Color(uiColor: .secondarySystemGroupedBackground)"))
|
||||
#expect(source.contains(".allowsHitTesting(false)"))
|
||||
#expect(compactControls.contains("self.compactRefreshButton"))
|
||||
#expect(compactControls.contains("self.compactBoardScopeMenu"))
|
||||
#expect(!compactControls.contains("Self.workboardSubtitle("))
|
||||
#expect(!compactControls.contains("Label(\"Refresh\""))
|
||||
#expect(compactControls.contains("Label(\"Dispatch\""))
|
||||
}
|
||||
|
||||
@Test func skillWorkshopUsesKanbanLanesOnWideIPad() throws {
|
||||
let source = try String(contentsOf: Self.iPadSkillWorkshopScreenSourceURL(), encoding: .utf8)
|
||||
let previewSource = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
|
||||
let content = try Self.extract(
|
||||
source,
|
||||
from: "private var proposalContent: some View",
|
||||
to: "private var proposalBoard: some View")
|
||||
let board = try Self.extract(
|
||||
source,
|
||||
from: "private var proposalBoard: some View",
|
||||
to: "private var proposalList: some View")
|
||||
|
||||
#expect(content.contains("if self.isCompactWidth"))
|
||||
#expect(content.contains("self.proposalList"))
|
||||
#expect(content.contains("self.proposalBoard"))
|
||||
#expect(!content.contains("self.proposalDetail"))
|
||||
#expect(board.contains("ScrollView(.horizontal)"))
|
||||
#expect(board.contains("IPadSkillProposalKanbanColumn("))
|
||||
#expect(source.contains("private struct IPadSkillProposalKanbanCard"))
|
||||
#expect(source.contains("static let defaultProposalStatusBoardLanes"))
|
||||
#expect(source.contains("private func proposals(forLaneStatus status: String)"))
|
||||
#expect(previewSource.contains("#Preview(\n \"Skill Workshop iPad kanban lanes\""))
|
||||
#expect(previewSource.contains("private struct IPadSkillWorkshopKanbanPreview"))
|
||||
#expect(previewSource.contains("IPadSkillProposalKanbanColumn("))
|
||||
#expect(previewSource.contains("status: \"needs-review\""))
|
||||
#expect(previewSource.contains("status: \"manual_QA\""))
|
||||
}
|
||||
|
||||
@Test func compactTaskRowsHavePopulatedPhonePreviews() throws {
|
||||
let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("#Preview(\"Workboard phone queue rows\")"))
|
||||
#expect(source.contains("#Preview(\"Skill Workshop phone queue rows\")"))
|
||||
#expect(source.contains("private struct IPadWorkboardCompactRowsPreview"))
|
||||
#expect(source.contains("private struct IPadSkillWorkshopCompactRowsPreview"))
|
||||
#expect(source.contains("IPadWorkboardPreviewFixtures.cards"))
|
||||
#expect(source.contains("IPadSkillWorkshopPreviewFixtures.proposals"))
|
||||
}
|
||||
|
||||
@Test func taskScreenPreviewMatricesCoverPrimaryStates() throws {
|
||||
let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("#Preview(\"Workboard states\")"))
|
||||
#expect(source.contains("private struct IPadWorkboardStatesPreview"))
|
||||
#expect(source.contains("self.previewHeader(\"Connected\")"))
|
||||
#expect(source.contains("self.previewHeader(\"Empty\")"))
|
||||
#expect(source.contains("self.previewHeader(\"Loading\")"))
|
||||
#expect(source.contains("self.previewHeader(\"Error\")"))
|
||||
#expect(source.contains("title: \"Loading cards\""))
|
||||
#expect(source.contains("title: \"Cards unavailable\""))
|
||||
#expect(source.contains("IPadWorkboardKanbanColumn("))
|
||||
|
||||
#expect(source.contains("#Preview(\"Skill Workshop states\")"))
|
||||
#expect(source.contains("private struct IPadSkillWorkshopStatesPreview"))
|
||||
#expect(source.contains("self.previewHeader(\"Offline / Error\")"))
|
||||
#expect(source.contains("title: \"No proposals\""))
|
||||
#expect(source.contains("title: \"Workshop offline\""))
|
||||
#expect(source.contains("title: \"Proposal unavailable\""))
|
||||
#expect(source.contains("#Preview(\n \"Skill Workshop iPad kanban lanes\""))
|
||||
#expect(source.contains("private struct IPadSkillWorkshopKanbanPreview"))
|
||||
#expect(source.contains("\"needs-review\""))
|
||||
#expect(source.contains("\"manual_QA\""))
|
||||
}
|
||||
|
||||
@Test func activityPreviewMatrixCoversConnectionStates() throws {
|
||||
let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("#Preview(\"Activity states\")"))
|
||||
#expect(source.contains("private struct IPadActivityStatesPreview"))
|
||||
#expect(source.contains("self.previewHeader(\"Connected\")"))
|
||||
#expect(source.contains("self.previewHeader(\"Loading\")"))
|
||||
#expect(source.contains("self.previewHeader(\"Empty\")"))
|
||||
#expect(source.contains("self.previewHeader(\"Error\")"))
|
||||
#expect(source.contains("title: \"Sessions unavailable\""))
|
||||
#expect(source.contains("title: \"No recent sessions\""))
|
||||
#expect(source.contains("title: \"Loading sessions\""))
|
||||
}
|
||||
|
||||
@Test func routedFeatureScreensReuseSharedProComponents() throws {
|
||||
let source = try Self.iPadTaskFeatureScreensSource()
|
||||
let componentsSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8)
|
||||
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("ProMetricGrid(metrics: self.metrics)"))
|
||||
#expect(source.contains("ProPanelHeader("))
|
||||
#expect(source.contains("ProStatusRow("))
|
||||
#expect(!source.contains("private struct ProMetricGrid"))
|
||||
#expect(!source.contains("private struct ProMetric"))
|
||||
#expect(!source.contains("private struct ProPanelHeader"))
|
||||
#expect(!source.contains("private struct ProStatusRow"))
|
||||
#expect(!channelsSource.contains("private struct SettingsChannelPanelHeader"))
|
||||
#expect(!channelsSource.contains("private struct SettingsChannelInfoRow"))
|
||||
#expect(componentsSource.contains("struct ProMetricGrid"))
|
||||
#expect(componentsSource.contains("struct ProPanelHeader"))
|
||||
#expect(componentsSource.contains("struct ProStatusRow"))
|
||||
}
|
||||
|
||||
@Test func activityScreenStaysSplitFromTaskFeatureScreens() throws {
|
||||
let taskSource = try Self.iPadTaskFeatureScreensSource()
|
||||
let activitySource = try String(contentsOf: Self.iPadActivityScreenSourceURL(), encoding: .utf8)
|
||||
let projectSource = try String(contentsOf: Self.xcodeProjectSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(activitySource.contains("struct IPadActivityScreen: View"))
|
||||
#expect(activitySource.contains("IOSGatewayChatTransport(gateway: self.appModel.operatorSession)"))
|
||||
#expect(activitySource.contains("IPadSidebarScreenChrome("))
|
||||
#expect(!taskSource.contains("struct IPadActivityScreen"))
|
||||
#expect(!taskSource.contains("import OpenClawChatUI"))
|
||||
#expect(projectSource.contains("IPadActivityScreen.swift in Sources"))
|
||||
}
|
||||
|
||||
@Test func routedFeatureChromeStaysSplitFromTaskFeatureScreens() throws {
|
||||
let taskSource = try Self.iPadTaskFeatureScreensSource()
|
||||
let chromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8)
|
||||
let projectSource = try String(contentsOf: Self.xcodeProjectSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(chromeSource.contains("struct IPadSidebarScreenChrome<Content: View>: View"))
|
||||
#expect(chromeSource.contains("OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)"))
|
||||
#expect(chromeSource.contains("OpenClawGatewayCompactPill()"))
|
||||
#expect(!taskSource.contains("struct IPadSidebarScreenChrome"))
|
||||
#expect(projectSource.contains("IPadSidebarScreenChrome.swift in Sources"))
|
||||
}
|
||||
|
||||
@Test func routedFeatureChromeKeepsGatewayPillActionable() throws {
|
||||
let chromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8)
|
||||
let featureSource = try Self.iPadTaskFeatureScreensSource()
|
||||
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(chromeSource.contains("let gatewayAction: (() -> Void)?"))
|
||||
#expect(chromeSource.contains("private var gatewayPill: some View"))
|
||||
#expect(chromeSource.contains("Button(action: gatewayAction)"))
|
||||
#expect(chromeSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
|
||||
#expect(featureSource.matches(of: /gatewayAction: self\.openSettings/).count == 2)
|
||||
#expect(rootSource.contains("IPadActivityScreen("))
|
||||
#expect(rootSource
|
||||
.matches(of: /IPadActivityScreen\([\s\S]*?openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/)
|
||||
.count == 1)
|
||||
}
|
||||
|
||||
@Test func routedGatewayPillsOpenGatewaySettings() throws {
|
||||
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let agentSource = try String(contentsOf: Self.agentProTabSourceURL(), encoding: .utf8)
|
||||
let agentOverviewSource = try String(contentsOf: Self.agentProTabOverviewSourceURL(), encoding: .utf8)
|
||||
let overviewSource = try String(contentsOf: Self.commandCenterSourceURL(), encoding: .utf8)
|
||||
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
|
||||
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
|
||||
let settingsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
|
||||
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(rootSource.matches(of: /openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count >= 2)
|
||||
#expect(rootSource.matches(of: /gatewayAction: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count == 1)
|
||||
#expect(!rootSource.contains("showGatewayActions"))
|
||||
#expect(!rootSource.contains("gatewayActionsDialog"))
|
||||
#expect(overviewSource.contains("Button(action: self.openSettings)"))
|
||||
#expect(overviewSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
|
||||
#expect(agentSource.contains("let openSettings: (() -> Void)?"))
|
||||
#expect(agentOverviewSource.contains("OpenClawGatewayCompactPill()"))
|
||||
#expect(agentOverviewSource.contains("Button(action: openSettings)"))
|
||||
#expect(rootSource
|
||||
.matches(of: /AgentProTab\([\s\S]*?openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/)
|
||||
.count >= 3)
|
||||
#expect(chatSource.contains("let openSettings: (() -> Void)?"))
|
||||
#expect(chatSource.contains("private var connectionPillButton: some View"))
|
||||
#expect(docsSource.contains("let gatewayAction: (() -> Void)?"))
|
||||
#expect(settingsSource.contains("NavigationLink(value: SettingsRoute.gateway)"))
|
||||
#expect(rootSource.contains("case .settings:"))
|
||||
#expect(rootSource.contains("SettingsProTab(headerLeadingAction: self.sidebarHeaderLeadingAction)"))
|
||||
#expect(rootSource.contains("directRoute: self.selectedSidebarDestination.settingsRoute ?? .gateway"))
|
||||
#expect(rootSource.contains("SettingsProTab(initialRoute: self.selectedSidebarDestination.settingsRoute)"))
|
||||
#expect(settingsSource.contains("title: \"Channels / Integrations\""))
|
||||
#expect(settingsSource.contains("route: .channels"))
|
||||
#expect(channelsSource.contains("let gatewayAction: (() -> Void)?"))
|
||||
#expect(docsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
|
||||
#expect(channelsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
|
||||
}
|
||||
|
||||
@Test func gatewaySettingsKeepsPairingTrustDiagnosticsAndTailscaleActions() throws {
|
||||
let settingsSource = try String(contentsOf: Self.settingsProTabSourceURL(), encoding: .utf8)
|
||||
let sectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
|
||||
let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
|
||||
let trustSource = try String(contentsOf: Self.gatewayTrustPromptAlertSourceURL(), encoding: .utf8)
|
||||
let controllerSource = try String(contentsOf: Self.gatewayConnectionControllerSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(sectionsSource.contains("var gatewayDestination: some View"))
|
||||
#expect(sectionsSource.contains("self.gatewayActions"))
|
||||
#expect(sectionsSource.contains("self.manualGatewayCard"))
|
||||
#expect(sectionsSource.contains("self.gatewaySetupCard"))
|
||||
#expect(sectionsSource.contains("self.discoveredGatewaysCard"))
|
||||
#expect(sectionsSource.contains("self.gatewayAdvancedCard"))
|
||||
#expect(sectionsSource.contains("title: \"Reconnect\""))
|
||||
#expect(sectionsSource.contains("Task { await self.reconnectGateway() }"))
|
||||
#expect(sectionsSource.contains("title: \"Diagnose\""))
|
||||
#expect(sectionsSource.contains("Task { await self.runDiagnostics() }"))
|
||||
#expect(sectionsSource.contains("title: \"Scan QR\""))
|
||||
#expect(sectionsSource.contains("self.openGatewayQRScanner()"))
|
||||
#expect(sectionsSource.contains("title: \"Connect\""))
|
||||
#expect(sectionsSource.contains("Task { await self.applySetupCodeAndConnect() }"))
|
||||
#expect(sectionsSource.contains("Task { await self.connect(gateway) }"))
|
||||
#expect(sectionsSource.contains("tailnetWarningText"))
|
||||
#expect(sectionsSource.contains("GatewayProblemBanner("))
|
||||
#expect(sectionsSource.contains("Task { await self.handleGatewayProblemPrimaryAction(problem) }"))
|
||||
|
||||
#expect(actionsSource.contains("await self.gatewayController.connectLastKnown()"))
|
||||
#expect(actionsSource.contains("self.gatewayController.refreshActiveGatewayRegistrationFromSettings()"))
|
||||
#expect(actionsSource.contains("self.gatewayController.restartDiscovery()"))
|
||||
#expect(actionsSource.contains("await self.appModel.refreshGatewayOverviewIfConnected()"))
|
||||
#expect(actionsSource.contains("await TCPProbe.probe(host: trimmed, port: port"))
|
||||
#expect(actionsSource.contains("Check Tailscale or LAN."))
|
||||
#expect(actionsSource.contains("Tailscale is off on this device. Turn it on, then try again."))
|
||||
#expect(actionsSource.contains("Run /pair approve in your OpenClaw chat"))
|
||||
#expect(actionsSource.contains("self.resetOnboarding()"))
|
||||
#expect(actionsSource.contains("self.gatewayController.trustRotatedGatewayCertificate(from: problem)"))
|
||||
#expect(actionsSource.contains("await self.retryGatewayConnectionFromProblem()"))
|
||||
|
||||
#expect(settingsSource.contains("GatewayProblemDetailsSheet("))
|
||||
#expect(settingsSource.contains("QRScannerView("))
|
||||
#expect(trustSource.contains("Trust this gateway?"))
|
||||
#expect(trustSource.contains("Trust and connect"))
|
||||
#expect(controllerSource.contains("acceptPendingTrustPrompt()"))
|
||||
#expect(controllerSource.contains("trustRotatedGatewayCertificate(from problem: GatewayConnectionProblem)"))
|
||||
}
|
||||
|
||||
@Test func gatewaySettingsPreviewMatrixCoversPrimaryStates() throws {
|
||||
let supportSource = try String(contentsOf: Self.settingsProTabSupportSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(supportSource.contains("#Preview(\"Gateway settings states\")"))
|
||||
#expect(supportSource.contains("private struct SettingsGatewayStatesPreview"))
|
||||
#expect(supportSource.contains("self.stateSection(\"Connected\")"))
|
||||
#expect(supportSource.contains("self.stateSection(\"Loading\")"))
|
||||
#expect(supportSource.contains("self.stateSection(\"Empty\")"))
|
||||
#expect(supportSource.contains("self.stateSection(\"Error\")"))
|
||||
#expect(supportSource.contains("GatewayProblemBanner("))
|
||||
#expect(supportSource.contains("kind: .pairingRequired"))
|
||||
#expect(supportSource.contains("Run /pair approve in your OpenClaw chat"))
|
||||
#expect(supportSource.contains("Tailscale is off on this device. Turn it on, then try again."))
|
||||
#expect(supportSource.contains("self.previewButton(\"Scan QR\""))
|
||||
#expect(supportSource.contains("self.previewButton(\"Connect\""))
|
||||
#expect(supportSource.contains("self.previewButton(\"Reconnect\""))
|
||||
#expect(supportSource.contains("self.previewButton(\"Diagnose\""))
|
||||
}
|
||||
|
||||
@Test func nativeChatUsesGatewayTransport() throws {
|
||||
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
|
||||
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(chatSource.contains("IOSGatewayChatTransport(gateway: self.appModel.operatorSession)"))
|
||||
#expect(channelsSource.contains("Message routing and external channel clients."))
|
||||
#expect(channelsSource.contains("\"clickclack\": SettingsChannelFallbackMetadata"))
|
||||
#expect(channelsSource.contains("label: \"ClickClack\""))
|
||||
#expect(channelsSource.contains("Self-hosted chat bot routing."))
|
||||
}
|
||||
|
||||
private static func rootTabsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/RootTabs.swift")
|
||||
}
|
||||
|
||||
private static func phoneHubSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/RootTabsPhoneControlHub.swift")
|
||||
}
|
||||
|
||||
private static func proComponentsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/OpenClawProComponents.swift")
|
||||
}
|
||||
|
||||
private static func commandCenterSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/CommandCenterTab.swift")
|
||||
}
|
||||
|
||||
private static func agentProTabSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/AgentProTab.swift")
|
||||
}
|
||||
|
||||
private static func agentProTabOverviewSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/AgentProTab+Overview.swift")
|
||||
}
|
||||
|
||||
private static func agentProTabDestinationsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/AgentProTab+Destinations.swift")
|
||||
}
|
||||
|
||||
private static func agentProNodesDestinationSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/AgentProNodesDestination.swift")
|
||||
}
|
||||
|
||||
private static func agentProDreamingDestinationSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/AgentProDreamingDestination.swift")
|
||||
}
|
||||
|
||||
private static func rootTabsNavigationSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/RootTabsNavigation.swift")
|
||||
}
|
||||
|
||||
private static func iPadSidebarFeatureScreensSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/IPadSidebarFeatureScreens.swift")
|
||||
}
|
||||
|
||||
private static func iPadTaskFeatureScreensSource() throws -> String {
|
||||
try [
|
||||
self.iPadWorkboardScreenSourceURL(),
|
||||
self.iPadSkillWorkshopScreenSourceURL(),
|
||||
self.iPadSidebarFeatureScreensSourceURL(),
|
||||
]
|
||||
.map { try String(contentsOf: $0, encoding: .utf8) }
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static func iPadWorkboardScreenSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/IPadWorkboardScreen.swift")
|
||||
}
|
||||
|
||||
private static func iPadSkillWorkshopScreenSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/IPadSkillWorkshopScreen.swift")
|
||||
}
|
||||
|
||||
private static func iPadSidebarFeaturePreviewsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/IPadSidebarFeaturePreviews.swift")
|
||||
}
|
||||
|
||||
private static func iPadActivityScreenSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/IPadActivityScreen.swift")
|
||||
}
|
||||
|
||||
private static func iPadSidebarScreenChromeSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/IPadSidebarScreenChrome.swift")
|
||||
}
|
||||
|
||||
private static func chatProTabSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/ChatProTab.swift")
|
||||
}
|
||||
|
||||
private static func docsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/OpenClawDocsScreen.swift")
|
||||
}
|
||||
|
||||
private static func settingsProTabSectionsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/SettingsProTabSections.swift")
|
||||
}
|
||||
|
||||
private static func settingsProTabSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/SettingsProTab.swift")
|
||||
}
|
||||
|
||||
private static func settingsProTabActionsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/SettingsProTabActions.swift")
|
||||
}
|
||||
|
||||
private static func settingsProTabSupportSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/SettingsProTabSupport.swift")
|
||||
}
|
||||
|
||||
private static func gatewayTrustPromptAlertSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Gateway/GatewayTrustPromptAlert.swift")
|
||||
}
|
||||
|
||||
private static func gatewayConnectionControllerSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Gateway/GatewayConnectionController.swift")
|
||||
}
|
||||
|
||||
private static func channelsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/SettingsChannelsDestination.swift")
|
||||
}
|
||||
|
||||
private static func sharedChatPreviewSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("shared/OpenClawKit/Sources/OpenClawChatUI/ChatView+Previews.swift")
|
||||
}
|
||||
|
||||
private static func xcodeProjectSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("OpenClaw.xcodeproj/project.pbxproj")
|
||||
}
|
||||
|
||||
private static func extract(_ source: String, from start: String, to end: String) throws -> String {
|
||||
let startRange = try #require(source.range(of: start))
|
||||
let tail = source[startRange.lowerBound...]
|
||||
let endRange = try #require(tail.range(of: end))
|
||||
return String(tail[..<endRange.lowerBound])
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,9 @@ import UIKit
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite struct SwiftUIRenderSmokeTests {
|
||||
@MainActor private static func host(_ view: some View) -> UIWindow {
|
||||
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||
@MainActor private static func host(_ view: some View, size: CGSize? = nil) -> UIWindow {
|
||||
let frame = CGRect(origin: .zero, size: size ?? UIScreen.main.bounds.size)
|
||||
let window = UIWindow(frame: frame)
|
||||
window.rootViewController = UIHostingController(rootView: view)
|
||||
window.makeKeyAndVisible()
|
||||
window.rootViewController?.view.setNeedsLayout()
|
||||
@@ -41,18 +42,102 @@ import UIKit
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func rootTabsBuildAViewHierarchy() {
|
||||
let appModel = NodeAppModel()
|
||||
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
@Test @MainActor func rootTabsBuildsDeviceOrientationShellMatrix() {
|
||||
for scenario in Self.rootTabsShellScenarios() {
|
||||
let appModel = NodeAppModel()
|
||||
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let root = RootTabs()
|
||||
let root = RootTabs()
|
||||
.environment(appModel)
|
||||
.environment(appModel.voiceWake)
|
||||
.environment(gatewayController)
|
||||
.environment(\.rootTabsUserInterfaceIdiomOverride, scenario.idiom)
|
||||
.environment(\.horizontalSizeClass, scenario.horizontalSizeClass)
|
||||
.environment(\.verticalSizeClass, scenario.verticalSizeClass)
|
||||
|
||||
_ = Self.host(root, size: scenario.size)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func rootTabsBuildGatewayStateViewHierarchies() {
|
||||
for appModel in Self.rootTabsGatewayStateModels() {
|
||||
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let root = RootTabs()
|
||||
.environment(appModel)
|
||||
.environment(appModel.voiceWake)
|
||||
.environment(gatewayController)
|
||||
|
||||
_ = Self.host(root)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func phoneControlHubBuildsGatewayStateViewHierarchies() {
|
||||
for appModel in Self.rootTabsGatewayStateModels() {
|
||||
let root = RootTabsPhoneControlHub(
|
||||
groups: RootTabs.phoneControlGroups,
|
||||
initialDestination: nil,
|
||||
openRootDestination: { _ in })
|
||||
.environment(appModel)
|
||||
|
||||
_ = Self.host(root)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func phoneControlHubBuildsLandscapeCompactState() {
|
||||
let appModel = NodeAppModel()
|
||||
let root = RootTabsPhoneControlHub(
|
||||
groups: RootTabs.phoneControlGroups,
|
||||
initialDestination: nil,
|
||||
openRootDestination: { _ in })
|
||||
.environment(appModel)
|
||||
.environment(appModel.voiceWake)
|
||||
.environment(gatewayController)
|
||||
.environment(\.horizontalSizeClass, .regular)
|
||||
.environment(\.verticalSizeClass, .compact)
|
||||
|
||||
_ = Self.host(root)
|
||||
}
|
||||
|
||||
@Test @MainActor func routedSidebarScreensBuildOfflineStates() {
|
||||
let appModel = NodeAppModel()
|
||||
let screens: [AnyView] = [
|
||||
AnyView(CommandCenterTab(openChat: {}, openSettings: {})),
|
||||
AnyView(IPadActivityScreen(openChat: {}, openSettings: {})),
|
||||
AnyView(OpenClawDocsScreen()),
|
||||
AnyView(SettingsChannelsScreen()),
|
||||
AnyView(IPadWorkboardScreen(openChat: {}, openSettings: {})),
|
||||
AnyView(IPadSkillWorkshopScreen(openSettings: {})),
|
||||
AnyView(AgentProTab(directRoute: .agents)),
|
||||
AnyView(AgentProTab(directRoute: .instances)),
|
||||
AnyView(CommandSessionsScreen(openChat: {})),
|
||||
AnyView(AgentProTab(directRoute: .dreaming)),
|
||||
AnyView(AgentProTab(directRoute: .usage)),
|
||||
AnyView(AgentProTab(directRoute: .cron)),
|
||||
]
|
||||
|
||||
for screen in screens {
|
||||
let root = NavigationStack { screen }
|
||||
.environment(appModel)
|
||||
_ = Self.host(root)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func taskScreensBuildPhoneLandscapeCompactStates() {
|
||||
let appModel = NodeAppModel()
|
||||
let screens: [AnyView] = [
|
||||
AnyView(IPadWorkboardScreen(openChat: {}, openSettings: {})),
|
||||
AnyView(IPadSkillWorkshopScreen(openSettings: {})),
|
||||
]
|
||||
|
||||
for screen in screens {
|
||||
let root = NavigationStack { screen }
|
||||
.environment(appModel)
|
||||
.environment(\.horizontalSizeClass, .regular)
|
||||
.environment(\.verticalSizeClass, .compact)
|
||||
|
||||
_ = Self.host(root)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func voiceWakeWordsViewBuildsAViewHierarchy() {
|
||||
let appModel = NodeAppModel()
|
||||
let root = NavigationStack { VoiceWakeWordsSettingsView() }
|
||||
@@ -64,4 +149,51 @@ import UIKit
|
||||
let root = VoiceWakeToast(command: "openclaw: do something")
|
||||
_ = Self.host(root)
|
||||
}
|
||||
|
||||
@MainActor private static func rootTabsGatewayStateModels() -> [NodeAppModel] {
|
||||
let offlineModel = NodeAppModel()
|
||||
|
||||
let connectingModel = NodeAppModel()
|
||||
connectingModel.gatewayStatusText = "Connecting..."
|
||||
|
||||
let connectedModel = NodeAppModel()
|
||||
connectedModel.enterAppleReviewDemoMode()
|
||||
|
||||
let errorModel = NodeAppModel()
|
||||
errorModel.gatewayStatusText = "Gateway error: connection refused"
|
||||
|
||||
return [offlineModel, connectingModel, connectedModel, errorModel]
|
||||
}
|
||||
|
||||
private static func rootTabsShellScenarios() -> [RootTabsShellScenario] {
|
||||
[
|
||||
RootTabsShellScenario(
|
||||
idiom: .phone,
|
||||
size: CGSize(width: 393, height: 852),
|
||||
horizontalSizeClass: .compact,
|
||||
verticalSizeClass: .regular),
|
||||
RootTabsShellScenario(
|
||||
idiom: .phone,
|
||||
size: CGSize(width: 852, height: 393),
|
||||
horizontalSizeClass: .regular,
|
||||
verticalSizeClass: .compact),
|
||||
RootTabsShellScenario(
|
||||
idiom: .pad,
|
||||
size: CGSize(width: 1024, height: 1366),
|
||||
horizontalSizeClass: .regular,
|
||||
verticalSizeClass: .regular),
|
||||
RootTabsShellScenario(
|
||||
idiom: .pad,
|
||||
size: CGSize(width: 1366, height: 1024),
|
||||
horizontalSizeClass: .regular,
|
||||
verticalSizeClass: .regular),
|
||||
]
|
||||
}
|
||||
|
||||
private struct RootTabsShellScenario {
|
||||
let idiom: UIUserInterfaceIdiom
|
||||
let size: CGSize
|
||||
let horizontalSizeClass: UserInterfaceSizeClass
|
||||
let verticalSizeClass: UserInterfaceSizeClass
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ targets:
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID)"
|
||||
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_APP_PROFILE)"
|
||||
TARGETED_DEVICE_FAMILY: "1"
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
SUPPORTS_LIVE_ACTIVITIES: YES
|
||||
@@ -183,7 +183,7 @@ targets:
|
||||
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_SHARE_BUNDLE_ID)"
|
||||
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_SHARE_PROFILE)"
|
||||
TARGETED_DEVICE_FAMILY: "1"
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
info:
|
||||
@@ -220,7 +220,7 @@ targets:
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID)"
|
||||
TARGETED_DEVICE_FAMILY: "1"
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
SUPPORTS_LIVE_ACTIVITIES: YES
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "a88730a64ccb5fd092108256c37d6c80bc7b92a5b6b563d83a9a26988550234d",
|
||||
"originHash" : "035a4fe955164c62c1628de75f6437a14443a947eea2a1b0176ba484d6fde6f8",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "axorcist",
|
||||
@@ -42,8 +42,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/Peekaboo.git",
|
||||
"state" : {
|
||||
"revision" : "faf843032772c2074d834b931911bf0002704136",
|
||||
"version" : "3.3.0"
|
||||
"revision" : "3a56ed2aa769bfefb5a78722dfce3c34088cfba1",
|
||||
"version" : "3.4.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.3.0"),
|
||||
.package(url: "https://github.com/steipete/Peekaboo.git", exact: "3.4.0"),
|
||||
.package(path: "../shared/OpenClawKit"),
|
||||
.package(path: "../swabble"),
|
||||
],
|
||||
|
||||
@@ -24,6 +24,17 @@ enum HostEnvSanitizer {
|
||||
"NO_COLOR",
|
||||
"FORCE_COLOR",
|
||||
]
|
||||
private static let gitAllowProtocolKey = "GIT_ALLOW_PROTOCOL"
|
||||
private static let gitProtocolFromUserKey = "GIT_PROTOCOL_FROM_USER"
|
||||
private static let gitProtocolFromUserDisabledValue = "0"
|
||||
private static let cargoTargetExecutableOverridePattern =
|
||||
#"^CARGO_TARGET_[A-Z0-9_]+_(LINKER|RUNNER)$"#
|
||||
private static let gitDefaultAlwaysAllowedProtocols: Set<String> = [
|
||||
"git",
|
||||
"http",
|
||||
"https",
|
||||
"ssh",
|
||||
]
|
||||
|
||||
private static func isBlocked(_ upperKey: String) -> Bool {
|
||||
if self.blockedKeys.contains(upperKey) { return true }
|
||||
@@ -37,6 +48,12 @@ enum HostEnvSanitizer {
|
||||
|
||||
private static func isBlockedOverride(_ upperKey: String) -> Bool {
|
||||
if self.blockedOverrideKeys.contains(upperKey) { return true }
|
||||
if upperKey.range(
|
||||
of: self.cargoTargetExecutableOverridePattern,
|
||||
options: .regularExpression) != nil
|
||||
{
|
||||
return true
|
||||
}
|
||||
return self.blockedOverridePrefixes.contains(where: { upperKey.hasPrefix($0) })
|
||||
}
|
||||
|
||||
@@ -82,6 +99,25 @@ enum HostEnvSanitizer {
|
||||
Array(Set(values)).sorted()
|
||||
}
|
||||
|
||||
private static func isPermissiveGitProtocolFromUserValue(_ value: String) -> Bool {
|
||||
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if normalized == "true" || normalized == "yes" || normalized == "on" {
|
||||
return true
|
||||
}
|
||||
let isInteger = normalized.range(of: #"^[+-]?[0-9]+$"#, options: .regularExpression) != nil
|
||||
let isZero = normalized.range(of: #"^[+-]?0+$"#, options: .regularExpression) != nil
|
||||
return isInteger && !isZero
|
||||
}
|
||||
|
||||
private static func sanitizeInheritedGitAllowProtocolValue(_ value: String) -> String {
|
||||
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if normalized.isEmpty { return "" }
|
||||
let safeProtocols = normalized
|
||||
.split(separator: ":", omittingEmptySubsequences: false)
|
||||
.filter { self.gitDefaultAlwaysAllowedProtocols.contains(String($0)) }
|
||||
return safeProtocols.joined(separator: ":")
|
||||
}
|
||||
|
||||
static func inspectOverrides(
|
||||
overrides: [String: String]?,
|
||||
blockPathOverrides: Bool = true) -> HostEnvOverrideDiagnostics
|
||||
@@ -120,6 +156,22 @@ enum HostEnvSanitizer {
|
||||
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { continue }
|
||||
let upper = key.uppercased()
|
||||
// Preserve inherited Git allowlists without widening malformed or unsafe entries by
|
||||
// deletion. Protocols outside Git's safe default set are removed instead.
|
||||
if upper == self.gitAllowProtocolKey {
|
||||
merged[key] = self.sanitizeInheritedGitAllowProtocolValue(value)
|
||||
continue
|
||||
}
|
||||
// Preserve non-permissive Git boolean values. Permissive values must become explicit
|
||||
// `0` because Git's unset default still permits protocols with policy `user`.
|
||||
if upper == self.gitProtocolFromUserKey {
|
||||
if !self.isPermissiveGitProtocolFromUserValue(value) {
|
||||
merged[key] = value
|
||||
} else {
|
||||
merged[key] = self.gitProtocolFromUserDisabledValue
|
||||
}
|
||||
continue
|
||||
}
|
||||
if self.isBlockedInherited(upper) { continue }
|
||||
merged[key] = value
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ enum HostEnvSecurityPolicy {
|
||||
"AWS_SESSION_TOKEN",
|
||||
"AZURE_CLIENT_ID",
|
||||
"AZURE_CLIENT_SECRET",
|
||||
"BASHOPTS",
|
||||
"BASH_ENV",
|
||||
"BROWSER",
|
||||
"BUNDLE_GEMFILE",
|
||||
@@ -36,7 +37,9 @@ enum HostEnvSecurityPolicy {
|
||||
"BZR_PLUGIN_PATH",
|
||||
"BZR_SSH",
|
||||
"CARGO_BUILD_RUSTC",
|
||||
"CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER",
|
||||
"CARGO_BUILD_RUSTC_WRAPPER",
|
||||
"CARGO_BUILD_RUSTDOC",
|
||||
"CARGO_HOME",
|
||||
"CATALINA_OPTS",
|
||||
"CC",
|
||||
@@ -70,12 +73,14 @@ enum HostEnvSecurityPolicy {
|
||||
"ERL_ZFLAGS",
|
||||
"EXINIT",
|
||||
"FCEDIT",
|
||||
"FPATH",
|
||||
"GCONV_PATH",
|
||||
"GEM_HOME",
|
||||
"GEM_PATH",
|
||||
"GH_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
"GITLAB_TOKEN",
|
||||
"GIT_ALLOW_PROTOCOL",
|
||||
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||
"GIT_ASKPASS",
|
||||
"GIT_COMMON_DIR",
|
||||
@@ -87,6 +92,7 @@ enum HostEnvSecurityPolicy {
|
||||
"GIT_INDEX_FILE",
|
||||
"GIT_NAMESPACE",
|
||||
"GIT_OBJECT_DIRECTORY",
|
||||
"GIT_PROTOCOL_FROM_USER",
|
||||
"GIT_PROXY_COMMAND",
|
||||
"GIT_SEQUENCE_EDITOR",
|
||||
"GIT_SSH",
|
||||
@@ -109,6 +115,8 @@ enum HostEnvSecurityPolicy {
|
||||
"GVIMINIT",
|
||||
"HELM_HOME",
|
||||
"HELM_PLUGINS",
|
||||
"HGEDITOR",
|
||||
"HGMERGE",
|
||||
"HGRCPATH",
|
||||
"HOSTALIASES",
|
||||
"IFS",
|
||||
@@ -116,6 +124,7 @@ enum HostEnvSecurityPolicy {
|
||||
"JAVA_TOOL_OPTIONS",
|
||||
"JDK_JAVA_OPTIONS",
|
||||
"JULIA_EDITOR",
|
||||
"KSH_ENV",
|
||||
"LDFLAGS",
|
||||
"LESSCLOSE",
|
||||
"LESSOPEN",
|
||||
@@ -127,6 +136,7 @@ enum HostEnvSecurityPolicy {
|
||||
"LUA_INIT_5_3",
|
||||
"LUA_INIT_5_4",
|
||||
"LUA_PATH",
|
||||
"MAKE",
|
||||
"MAKEFLAGS",
|
||||
"MAVEN_OPTS",
|
||||
"MFLAGS",
|
||||
@@ -167,7 +177,10 @@ enum HostEnvSecurityPolicy {
|
||||
"RUBYLIB",
|
||||
"RUBYOPT",
|
||||
"RUBYSHELL",
|
||||
"RUSTC",
|
||||
"RUSTC_WORKSPACE_WRAPPER",
|
||||
"RUSTC_WRAPPER",
|
||||
"RUSTDOC",
|
||||
"RUSTFLAGS",
|
||||
"R_ENVIRON",
|
||||
"R_ENVIRON_USER",
|
||||
@@ -183,6 +196,7 @@ enum HostEnvSecurityPolicy {
|
||||
"SUDO_EDITOR",
|
||||
"SVN_EDITOR",
|
||||
"SVN_SSH",
|
||||
"TCLLIBPATH",
|
||||
"TF_CLI_CONFIG_FILE",
|
||||
"TF_PLUGIN_CACHE_DIR",
|
||||
"UV_DEFAULT_INDEX",
|
||||
@@ -207,13 +221,16 @@ enum HostEnvSecurityPolicy {
|
||||
|
||||
static let blockedKeys: Set<String> = [
|
||||
"ANT_OPTS",
|
||||
"BASHOPTS",
|
||||
"BASH_ENV",
|
||||
"BROWSER",
|
||||
"BZR_EDITOR",
|
||||
"BZR_PLUGIN_PATH",
|
||||
"BZR_SSH",
|
||||
"CARGO_BUILD_RUSTC",
|
||||
"CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER",
|
||||
"CARGO_BUILD_RUSTC_WRAPPER",
|
||||
"CARGO_BUILD_RUSTDOC",
|
||||
"CATALINA_OPTS",
|
||||
"CC",
|
||||
"CMAKE_CXX_COMPILER",
|
||||
@@ -232,7 +249,9 @@ enum HostEnvSecurityPolicy {
|
||||
"ERL_FLAGS",
|
||||
"ERL_ZFLAGS",
|
||||
"EXINIT",
|
||||
"FPATH",
|
||||
"GCONV_PATH",
|
||||
"GIT_ALLOW_PROTOCOL",
|
||||
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||
"GIT_COMMON_DIR",
|
||||
"GIT_DIR",
|
||||
@@ -243,6 +262,7 @@ enum HostEnvSecurityPolicy {
|
||||
"GIT_INDEX_FILE",
|
||||
"GIT_NAMESPACE",
|
||||
"GIT_OBJECT_DIRECTORY",
|
||||
"GIT_PROTOCOL_FROM_USER",
|
||||
"GIT_SEQUENCE_EDITOR",
|
||||
"GIT_SSL_CAINFO",
|
||||
"GIT_SSL_CAPATH",
|
||||
@@ -253,6 +273,8 @@ enum HostEnvSecurityPolicy {
|
||||
"GRADLE_OPTS",
|
||||
"GVIMINIT",
|
||||
"HELM_PLUGINS",
|
||||
"HGEDITOR",
|
||||
"HGMERGE",
|
||||
"HGRCPATH",
|
||||
"HOSTALIASES",
|
||||
"IFS",
|
||||
@@ -260,11 +282,13 @@ enum HostEnvSecurityPolicy {
|
||||
"JAVA_TOOL_OPTIONS",
|
||||
"JDK_JAVA_OPTIONS",
|
||||
"JULIA_EDITOR",
|
||||
"KSH_ENV",
|
||||
"LUA_INIT",
|
||||
"LUA_INIT_5_1",
|
||||
"LUA_INIT_5_2",
|
||||
"LUA_INIT_5_3",
|
||||
"LUA_INIT_5_4",
|
||||
"MAKE",
|
||||
"MAKEFLAGS",
|
||||
"MAVEN_OPTS",
|
||||
"MFLAGS",
|
||||
@@ -285,7 +309,10 @@ enum HostEnvSecurityPolicy {
|
||||
"RUBYLIB",
|
||||
"RUBYOPT",
|
||||
"RUBYSHELL",
|
||||
"RUSTC",
|
||||
"RUSTC_WORKSPACE_WRAPPER",
|
||||
"RUSTC_WRAPPER",
|
||||
"RUSTDOC",
|
||||
"R_ENVIRON",
|
||||
"R_ENVIRON_USER",
|
||||
"R_PROFILE",
|
||||
@@ -297,6 +324,7 @@ enum HostEnvSecurityPolicy {
|
||||
"SUDO_ASKPASS",
|
||||
"SVN_EDITOR",
|
||||
"SVN_SSH",
|
||||
"TCLLIBPATH",
|
||||
"VAGRANT_VAGRANTFILE",
|
||||
"VIMINIT",
|
||||
"_JAVA_OPTIONS"
|
||||
@@ -425,6 +453,11 @@ enum HostEnvSecurityPolicy {
|
||||
"REQUESTS_CA_BUNDLE",
|
||||
"RUSTC_WRAPPER",
|
||||
"RUSTFLAGS",
|
||||
"RUSTUP_DIST_ROOT",
|
||||
"RUSTUP_DIST_SERVER",
|
||||
"RUSTUP_HOME",
|
||||
"RUSTUP_TOOLCHAIN",
|
||||
"RUSTUP_UPDATE_ROOT",
|
||||
"R_LIBS_USER",
|
||||
"SSH_ASKPASS",
|
||||
"SSH_AUTH_SOCK",
|
||||
|
||||
@@ -47,19 +47,20 @@ struct VoiceWakeSettings: View {
|
||||
private var voiceSummaryPanel: some View {
|
||||
let enabled = voiceWakeSupported && self.state.swabbleEnabled
|
||||
let pushToTalk = voiceWakeSupported && self.state.voicePushToTalkEnabled
|
||||
let statusColor: Color = !voiceWakeSupported ? .orange : enabled || pushToTalk ? .green : .secondary
|
||||
|
||||
return HStack(alignment: .center, spacing: 14) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill((enabled || pushToTalk ? Color.green : Color.secondary).opacity(0.18))
|
||||
Image(systemName: enabled ? "waveform.badge.mic" : "mic.slash")
|
||||
.fill(statusColor.opacity(0.18))
|
||||
Image(systemName: self.voiceSummaryIconName(enabled: enabled))
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(enabled || pushToTalk ? .green : .secondary)
|
||||
.foregroundStyle(statusColor)
|
||||
}
|
||||
.frame(width: 46, height: 46)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(enabled ? "Voice Wake active" : pushToTalk ? "Push-to-talk active" : "Voice controls idle")
|
||||
Text(self.voiceSummaryTitle(enabled: enabled, pushToTalk: pushToTalk))
|
||||
.font(.headline)
|
||||
Text(self.voiceSummarySubtitle)
|
||||
.font(.footnote)
|
||||
@@ -84,6 +85,26 @@ struct VoiceWakeSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func voiceSummaryIconName(enabled: Bool) -> String {
|
||||
if !voiceWakeSupported {
|
||||
return "exclamationmark.triangle.fill"
|
||||
}
|
||||
return enabled ? "waveform.badge.mic" : "mic.slash"
|
||||
}
|
||||
|
||||
private func voiceSummaryTitle(enabled: Bool, pushToTalk: Bool) -> String {
|
||||
if !voiceWakeSupported {
|
||||
return "Voice Wake unavailable"
|
||||
}
|
||||
if enabled {
|
||||
return "Voice Wake active"
|
||||
}
|
||||
if pushToTalk {
|
||||
return "Push-to-talk active"
|
||||
}
|
||||
return "Voice controls idle"
|
||||
}
|
||||
|
||||
private var voiceSummarySubtitle: String {
|
||||
if !voiceWakeSupported {
|
||||
return "Voice Wake requires macOS 26 or newer."
|
||||
@@ -98,16 +119,31 @@ struct VoiceWakeSettings: View {
|
||||
}
|
||||
|
||||
private var unsupportedVoiceWakePanel: some View {
|
||||
HStack(spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.yellow)
|
||||
Text("Voice Wake requires macOS 26 or newer.")
|
||||
.font(.callout.weight(.medium))
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.orange)
|
||||
.frame(width: 28)
|
||||
.padding(.top, 1)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Voice Wake requires macOS 26 or newer")
|
||||
.font(.callout.weight(.semibold))
|
||||
Text("The Voice Wake and push-to-talk controls are hidden on older macOS versions.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(.yellow.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(.orange.opacity(0.18))
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -119,72 +155,68 @@ struct VoiceWakeSettings: View {
|
||||
|
||||
self.voiceSummaryPanel
|
||||
|
||||
SettingsCardGroup("Activation") {
|
||||
SettingsCardToggleRow(
|
||||
title: "Enable Voice Wake",
|
||||
subtitle: "Listen for a wake phrase before running voice commands. Recognition runs fully on-device.",
|
||||
binding: self.voiceWakeBinding)
|
||||
.disabled(!voiceWakeSupported)
|
||||
if voiceWakeSupported {
|
||||
SettingsCardGroup("Activation") {
|
||||
SettingsCardToggleRow(
|
||||
title: "Enable Voice Wake",
|
||||
subtitle: "Listen for a wake phrase before running voice commands. Recognition runs fully on-device.",
|
||||
binding: self.voiceWakeBinding)
|
||||
|
||||
SettingsCardToggleRow(
|
||||
title: "Trigger Talk Mode",
|
||||
subtitle: "Start a full voice conversation when a wake phrase is detected.",
|
||||
binding: self.$state.voiceWakeTriggersTalkMode)
|
||||
.disabled(!self.state.swabbleEnabled)
|
||||
SettingsCardToggleRow(
|
||||
title: "Trigger Talk Mode",
|
||||
subtitle: "Start a full voice conversation when a wake phrase is detected.",
|
||||
binding: self.$state.voiceWakeTriggersTalkMode)
|
||||
.disabled(!self.state.swabbleEnabled)
|
||||
|
||||
SettingsCardToggleRow(
|
||||
title: "Hold Right Option to talk",
|
||||
subtitle: "Start listening while you hold the key and show the preview overlay.",
|
||||
binding: self.$state.voicePushToTalkEnabled)
|
||||
.disabled(!voiceWakeSupported)
|
||||
SettingsCardToggleRow(
|
||||
title: "Hold Right Option to talk",
|
||||
subtitle: "Start listening while you hold the key and show the preview overlay.",
|
||||
binding: self.$state.voicePushToTalkEnabled)
|
||||
|
||||
if self.state.voicePushToTalkEnabled, self.state.talkEnabled {
|
||||
SettingsCardRow(
|
||||
title: "Push-to-talk paused",
|
||||
subtitle: "Push-to-Talk resumes when Talk Mode is turned off.")
|
||||
{
|
||||
Image(systemName: "pause.circle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
if self.state.voicePushToTalkEnabled, self.state.talkEnabled {
|
||||
SettingsCardRow(
|
||||
title: "Push-to-talk paused",
|
||||
subtitle: "Push-to-Talk resumes when Talk Mode is turned off.")
|
||||
{
|
||||
Image(systemName: "pause.circle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCardToggleRow(
|
||||
title: "Play phase-transition sounds",
|
||||
subtitle: "Play short sounds when Talk Mode switches between listening, thinking, and speaking.",
|
||||
binding: self.$state.talkPhaseSoundsEnabled)
|
||||
|
||||
SettingsCardToggleRow(
|
||||
title: "Right Option stops speech",
|
||||
subtitle: "Tap Right Option to interrupt speech and return to listening.",
|
||||
binding: self.$state.talkShiftToStopEnabled,
|
||||
showsDivider: false)
|
||||
}
|
||||
|
||||
SettingsCardToggleRow(
|
||||
title: "Play phase-transition sounds",
|
||||
subtitle: "Play short sounds when Talk Mode switches between listening, thinking, and speaking.",
|
||||
binding: self.$state.talkPhaseSoundsEnabled)
|
||||
.disabled(!voiceWakeSupported)
|
||||
SettingsCardGroup("Recognition") {
|
||||
self.localePicker
|
||||
self.micPicker
|
||||
self.levelMeter
|
||||
}
|
||||
|
||||
SettingsCardToggleRow(
|
||||
title: "Right Option stops speech",
|
||||
subtitle: "Tap Right Option to interrupt speech and return to listening.",
|
||||
binding: self.$state.talkShiftToStopEnabled,
|
||||
showsDivider: false)
|
||||
.disabled(!voiceWakeSupported)
|
||||
}
|
||||
SettingsCardGroup("Test") {
|
||||
VoiceWakeTestCard(
|
||||
testState: self.$testState,
|
||||
isTesting: self.$isTesting,
|
||||
onToggle: self.toggleTest)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
if !voiceWakeSupported {
|
||||
self.chimeSection
|
||||
|
||||
self.triggerTable
|
||||
} else {
|
||||
self.unsupportedVoiceWakePanel
|
||||
}
|
||||
|
||||
SettingsCardGroup("Recognition") {
|
||||
self.localePicker
|
||||
self.micPicker
|
||||
self.levelMeter
|
||||
}
|
||||
|
||||
SettingsCardGroup("Test") {
|
||||
VoiceWakeTestCard(
|
||||
testState: self.$testState,
|
||||
isTesting: self.$isTesting,
|
||||
onToggle: self.toggleTest)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
self.chimeSection
|
||||
|
||||
self.triggerTable
|
||||
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
.settingsDetailContent()
|
||||
@@ -217,9 +249,13 @@ struct VoiceWakeSettings: View {
|
||||
}
|
||||
|
||||
private func activateLivePreview() {
|
||||
self.loadTriggerEntries()
|
||||
guard voiceWakeSupported else {
|
||||
self.deactivateLivePreview()
|
||||
return
|
||||
}
|
||||
self.meterStartupTask?.cancel()
|
||||
self.startMicObserver()
|
||||
self.loadTriggerEntries()
|
||||
self.meterStartupTask = Task { @MainActor in
|
||||
await self.loadMicsIfNeeded()
|
||||
guard !Task.isCancelled, self.isActive else { return }
|
||||
@@ -244,6 +280,11 @@ struct VoiceWakeSettings: View {
|
||||
}
|
||||
|
||||
private func scheduleMeterRestart() {
|
||||
guard voiceWakeSupported else {
|
||||
self.state.voiceWakeMeterActive = false
|
||||
Task { await self.meter.stop() }
|
||||
return
|
||||
}
|
||||
self.meterStartupTask?.cancel()
|
||||
self.meterStartupTask = Task { @MainActor in
|
||||
guard !Task.isCancelled, self.isActive else { return }
|
||||
@@ -662,7 +703,7 @@ struct VoiceWakeSettings: View {
|
||||
|
||||
@MainActor
|
||||
private func scheduleMicRefresh() {
|
||||
guard self.isActive else { return }
|
||||
guard voiceWakeSupported, self.isActive else { return }
|
||||
MicRefreshSupport.schedule(refreshTask: &self.micRefreshTask) {
|
||||
await self.loadMicsIfNeeded(force: true)
|
||||
await self.restartMeter()
|
||||
@@ -724,6 +765,11 @@ struct VoiceWakeSettings: View {
|
||||
|
||||
@MainActor
|
||||
private func restartMeter() async {
|
||||
guard voiceWakeSupported else {
|
||||
self.state.voiceWakeMeterActive = false
|
||||
await self.meter.stop()
|
||||
return
|
||||
}
|
||||
guard self.isActive else {
|
||||
self.state.voiceWakeMeterActive = false
|
||||
await self.meter.stop()
|
||||
@@ -890,6 +936,7 @@ extension VoiceWakeSettings {
|
||||
_ = view.levelMeter
|
||||
_ = view.triggerTable
|
||||
_ = view.chimeSection
|
||||
_ = view.unsupportedVoiceWakePanel
|
||||
|
||||
view.addWord()
|
||||
if let entryId = view.triggerEntries.first?.id {
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
private struct OpenClawChatPreviewTransport: OpenClawChatTransport {
|
||||
enum Scenario {
|
||||
case connected
|
||||
case empty
|
||||
case loading
|
||||
case error
|
||||
}
|
||||
|
||||
let scenario: Scenario
|
||||
|
||||
init(scenario: Scenario = .connected) {
|
||||
self.scenario = scenario
|
||||
}
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||
switch self.scenario {
|
||||
case .connected:
|
||||
break
|
||||
case .empty:
|
||||
return OpenClawChatHistoryPayload(
|
||||
sessionKey: sessionKey,
|
||||
sessionId: "preview-empty-session",
|
||||
messages: [],
|
||||
thinkingLevel: "medium")
|
||||
case .loading:
|
||||
try await Task.sleep(nanoseconds: 60_000_000_000)
|
||||
return OpenClawChatHistoryPayload(
|
||||
sessionKey: sessionKey,
|
||||
sessionId: "preview-loading-session",
|
||||
messages: [],
|
||||
thinkingLevel: "medium")
|
||||
case .error:
|
||||
throw NSError(
|
||||
domain: "OpenClawChatPreviewTransport",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Gateway not connected. Check Tailscale and retry."])
|
||||
}
|
||||
|
||||
return OpenClawChatHistoryPayload(
|
||||
sessionKey: sessionKey,
|
||||
sessionId: "preview-session",
|
||||
messages: [
|
||||
Self.message(
|
||||
role: "user",
|
||||
text: "Can you check the gateway status and summarize anything risky?",
|
||||
timestamp: 1),
|
||||
Self.message(
|
||||
role: "assistant",
|
||||
text: "Gateway is reachable. The only notable item is that push relay is still using local distribution, so device tests should stay on the local lane.",
|
||||
timestamp: 2),
|
||||
Self.toolCall(
|
||||
id: "tool-preview-1",
|
||||
name: "gateway.status",
|
||||
arguments: ["deep": AnyCodable(true)],
|
||||
timestamp: 3),
|
||||
Self.toolResult(
|
||||
toolCallId: "tool-preview-1",
|
||||
name: "gateway.status",
|
||||
text: "status=ok, channels=ios,macos, lastHeartbeat=12s",
|
||||
timestamp: 4),
|
||||
],
|
||||
thinkingLevel: "medium")
|
||||
}
|
||||
|
||||
func listModels() async throws -> [OpenClawChatModelChoice] {
|
||||
[
|
||||
OpenClawChatModelChoice(
|
||||
modelID: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
provider: "openai",
|
||||
contextWindow: 400_000),
|
||||
OpenClawChatModelChoice(
|
||||
modelID: "sonnet-4.6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
provider: "anthropic",
|
||||
contextWindow: 200_000),
|
||||
]
|
||||
}
|
||||
|
||||
func sendMessage(
|
||||
sessionKey _: String,
|
||||
message _: String,
|
||||
thinking _: String,
|
||||
idempotencyKey: String,
|
||||
attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
OpenClawChatSendResponse(runId: idempotencyKey, status: "ok")
|
||||
}
|
||||
|
||||
func listSessions(limit _: Int?) async throws -> OpenClawChatSessionsListResponse {
|
||||
OpenClawChatSessionsListResponse(
|
||||
ts: 0,
|
||||
path: nil,
|
||||
count: 2,
|
||||
defaults: OpenClawChatSessionsDefaults(
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
contextTokens: 400_000,
|
||||
thinkingLevels: [
|
||||
OpenClawChatThinkingLevelOption(id: "off", label: "off"),
|
||||
OpenClawChatThinkingLevelOption(id: "medium", label: "medium"),
|
||||
OpenClawChatThinkingLevelOption(id: "high", label: "high"),
|
||||
],
|
||||
thinkingDefault: "medium",
|
||||
mainSessionKey: "main"),
|
||||
sessions: [
|
||||
Self.session(key: "main", displayName: "Main", updatedAt: 2),
|
||||
Self.session(key: "ios-preview", displayName: "iOS preview", updatedAt: 1),
|
||||
])
|
||||
}
|
||||
|
||||
func requestHealth(timeoutMs _: Int) async throws -> Bool {
|
||||
switch self.scenario {
|
||||
case .connected, .empty, .loading:
|
||||
true
|
||||
case .error:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
func events() -> AsyncStream<OpenClawChatTransportEvent> {
|
||||
AsyncStream { continuation in
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
func setActiveSessionKey(_: String) async throws {}
|
||||
|
||||
private static func message(role: String, text: String, timestamp: Double) -> AnyCodable {
|
||||
AnyCodable([
|
||||
"role": role,
|
||||
"content": [["type": "text", "text": text]],
|
||||
"timestamp": timestamp,
|
||||
])
|
||||
}
|
||||
|
||||
private static func toolCall(
|
||||
id: String,
|
||||
name: String,
|
||||
arguments: [String: AnyCodable],
|
||||
timestamp: Double) -> AnyCodable
|
||||
{
|
||||
AnyCodable([
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
[
|
||||
"type": "toolCall",
|
||||
"id": id,
|
||||
"name": name,
|
||||
"arguments": AnyCodable(arguments),
|
||||
],
|
||||
],
|
||||
"timestamp": timestamp,
|
||||
])
|
||||
}
|
||||
|
||||
private static func toolResult(
|
||||
toolCallId: String,
|
||||
name: String,
|
||||
text: String,
|
||||
timestamp: Double) -> AnyCodable
|
||||
{
|
||||
AnyCodable([
|
||||
"role": "tool",
|
||||
"content": [["type": "text", "text": text]],
|
||||
"timestamp": timestamp,
|
||||
"toolCallId": toolCallId,
|
||||
"toolName": name,
|
||||
])
|
||||
}
|
||||
|
||||
private static func session(
|
||||
key: String,
|
||||
displayName: String,
|
||||
updatedAt: Double) -> OpenClawChatSessionEntry
|
||||
{
|
||||
OpenClawChatSessionEntry(
|
||||
key: key,
|
||||
kind: nil,
|
||||
displayName: displayName,
|
||||
surface: "ios",
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: updatedAt,
|
||||
sessionId: nil,
|
||||
systemSent: nil,
|
||||
abortedLastRun: nil,
|
||||
thinkingLevel: "medium",
|
||||
verboseLevel: nil,
|
||||
inputTokens: 2500,
|
||||
outputTokens: 900,
|
||||
totalTokens: 3400,
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
contextTokens: 400_000)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Chat") {
|
||||
OpenClawChatPreview(scenario: .connected)
|
||||
}
|
||||
|
||||
#Preview("Chat connected") {
|
||||
OpenClawChatPreview(scenario: .connected)
|
||||
}
|
||||
|
||||
#Preview("Chat empty") {
|
||||
OpenClawChatPreview(
|
||||
scenario: .empty,
|
||||
sessionKey: "empty-preview")
|
||||
}
|
||||
|
||||
#Preview("Chat loading") {
|
||||
OpenClawChatPreview(
|
||||
scenario: .loading,
|
||||
sessionKey: "loading-preview")
|
||||
}
|
||||
|
||||
#Preview("Chat gateway error") {
|
||||
OpenClawChatPreview(
|
||||
scenario: .error,
|
||||
sessionKey: "error-preview")
|
||||
}
|
||||
|
||||
#Preview("Onboarding chat") {
|
||||
OpenClawChatView(
|
||||
viewModel: OpenClawChatViewModel(
|
||||
sessionKey: "ios-preview",
|
||||
transport: OpenClawChatPreviewTransport()),
|
||||
showsSessionSwitcher: false,
|
||||
style: .onboarding,
|
||||
markdownVariant: .standard,
|
||||
userAccent: .blue)
|
||||
}
|
||||
|
||||
private struct OpenClawChatPreview: View {
|
||||
let scenario: OpenClawChatPreviewTransport.Scenario
|
||||
var sessionKey: String = "main"
|
||||
|
||||
var body: some View {
|
||||
OpenClawChatView(
|
||||
viewModel: OpenClawChatViewModel(
|
||||
sessionKey: self.sessionKey,
|
||||
transport: OpenClawChatPreviewTransport(scenario: self.scenario)),
|
||||
showsSessionSwitcher: true,
|
||||
style: .standard,
|
||||
markdownVariant: .standard,
|
||||
userAccent: .blue,
|
||||
showsAssistantTrace: true)
|
||||
}
|
||||
}
|
||||
@@ -982,21 +982,25 @@ public struct WakeParams: Codable, Sendable {
|
||||
public let mode: AnyCodable
|
||||
public let text: String
|
||||
public let sessionkey: String?
|
||||
public let agentid: String?
|
||||
|
||||
public init(
|
||||
mode: AnyCodable,
|
||||
text: String,
|
||||
sessionkey: String?)
|
||||
sessionkey: String?,
|
||||
agentid: String? = nil)
|
||||
{
|
||||
self.mode = mode
|
||||
self.text = text
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case mode
|
||||
case text
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2773,6 +2777,7 @@ public struct ConfigPatchParams: Codable, Sendable {
|
||||
public let deliverycontext: [String: AnyCodable]?
|
||||
public let note: String?
|
||||
public let restartdelayms: Int?
|
||||
public let replacepaths: [String]?
|
||||
|
||||
public init(
|
||||
raw: String,
|
||||
@@ -2780,7 +2785,8 @@ public struct ConfigPatchParams: Codable, Sendable {
|
||||
sessionkey: String?,
|
||||
deliverycontext: [String: AnyCodable]?,
|
||||
note: String?,
|
||||
restartdelayms: Int?)
|
||||
restartdelayms: Int?,
|
||||
replacepaths: [String]?)
|
||||
{
|
||||
self.raw = raw
|
||||
self.basehash = basehash
|
||||
@@ -2788,6 +2794,7 @@ public struct ConfigPatchParams: Codable, Sendable {
|
||||
self.deliverycontext = deliverycontext
|
||||
self.note = note
|
||||
self.restartdelayms = restartdelayms
|
||||
self.replacepaths = replacepaths
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -2797,6 +2804,7 @@ public struct ConfigPatchParams: Codable, Sendable {
|
||||
case deliverycontext = "deliveryContext"
|
||||
case note
|
||||
case restartdelayms = "restartDelayMs"
|
||||
case replacepaths = "replacePaths"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -217,6 +217,16 @@ const config = {
|
||||
entry: ["index.js!", "scripts/postinstall.js!"],
|
||||
project: ["index.js!", "scripts/**/*.js!"],
|
||||
},
|
||||
[`${BUNDLED_PLUGIN_ROOT_DIR}/llama-cpp`]: {
|
||||
entry: bundledPluginEntries,
|
||||
project: ["index.ts!", "src/**/*.{js,mjs,ts}!"],
|
||||
ignoreDependencies: [
|
||||
// The provider resolves node-llama-cpp from its own package at runtime
|
||||
// so local embeddings use the plugin-owned native dependency.
|
||||
"node-llama-cpp",
|
||||
...bundledPluginIgnoredRuntimeDependencies,
|
||||
],
|
||||
},
|
||||
[`${BUNDLED_PLUGIN_ROOT_DIR}/*`]: {
|
||||
// Bundled plugins often load their public surface via string specifiers in
|
||||
// `index.ts` contracts, so Knip needs these convention-based entry files.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
de06fd99257e4b010e54578ea46605c3bc631c31cac5f68aaed4e301f924f8af plugin-sdk-api-baseline.json
|
||||
1c7a5420c4bcb1ec08544ff43b83fa4d43f3c0dcda597a5a25aa5f5bab0cb199 plugin-sdk-api-baseline.jsonl
|
||||
8a2769df428906990ee0d1bf8b0423f2a099b053c64c816d092ff84d61e11633 plugin-sdk-api-baseline.json
|
||||
28b798973f3fb2a5b33ccbb6e3c1ac0453fa234a3a1c6cdc27935c27639bd104 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,7 @@ Requires OpenClaw 2026.5.29 or above. Run `openclaw --version` to check. Upgrade
|
||||
Configure `dmPolicy` to control who can DM the bot:
|
||||
|
||||
- `"pairing"` - unknown users receive a pairing code; approve via CLI
|
||||
- `"allowlist"` - only users listed in `allowFrom` can chat (default: bot owner only)
|
||||
- `"allowlist"` - only users listed in `allowFrom` can chat
|
||||
- `"open"` - allow public DMs only when `allowFrom` includes `"*"`; with restrictive entries, only matching users can chat
|
||||
- `"disabled"` - disable all DMs
|
||||
|
||||
@@ -567,8 +567,8 @@ Full configuration: [Gateway configuration](/gateway/configuration)
|
||||
| `channels.feishu.accounts.<id>.appSecret` | App Secret | - |
|
||||
| `channels.feishu.accounts.<id>.domain` | Per-account domain override | `feishu` |
|
||||
| `channels.feishu.accounts.<id>.tts` | Per-account TTS override | `messages.tts` |
|
||||
| `channels.feishu.dmPolicy` | DM policy | `allowlist` |
|
||||
| `channels.feishu.allowFrom` | DM allowlist (open_id list) | [BotOwnerId] |
|
||||
| `channels.feishu.dmPolicy` | DM policy | `pairing` |
|
||||
| `channels.feishu.allowFrom` | DM allowlist (open_id list) | - |
|
||||
| `channels.feishu.groupPolicy` | Group policy | `allowlist` |
|
||||
| `channels.feishu.groupAllowFrom` | Group allowlist | - |
|
||||
| `channels.feishu.requireMention` | Require @mention in groups | `true` |
|
||||
|
||||
@@ -763,6 +763,31 @@ imessage: suppressed stale inbound backlog account=<id> sent=<iso> recovery=<boo
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Messages send but inbound iMessages do not arrive">
|
||||
First prove whether the message reached the local Mac. If `chat.db` does not change, OpenClaw cannot receive the message even when `imsg status --json` reports a healthy bridge.
|
||||
|
||||
```bash
|
||||
imsg chats --limit 10 --json
|
||||
imsg watch --chat-id <chat-id> --json
|
||||
sqlite3 ~/Library/Messages/chat.db \
|
||||
"select datetime(max(date)/1000000000 + 978307200, 'unixepoch', 'localtime'), max(ROWID) from message;"
|
||||
```
|
||||
|
||||
If phone-sent messages create no new rows, repair the macOS Messages and Apple Push layer before changing OpenClaw config. A one-shot service refresh is often enough:
|
||||
|
||||
```bash
|
||||
launchctl kickstart -k system/com.apple.apsd
|
||||
launchctl kickstart -k gui/$(id -u)/com.apple.CommCenter
|
||||
launchctl kickstart -k gui/$(id -u)/com.apple.identityservicesd
|
||||
launchctl kickstart -k gui/$(id -u)/com.apple.imagent
|
||||
imsg launch
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
Send a fresh iMessage from the phone and confirm a new `chat.db` row or `imsg watch` event before debugging OpenClaw sessions. Do not run this as a periodic bridge-relaunch loop; repeated `imsg launch` plus gateway restarts during active work can interrupt deliveries and strand in-flight channel runs.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Gateway is not running on macOS">
|
||||
The default `cliPath: "imsg"` must run on the Mac signed into Messages. On Linux or Windows, set `channels.imessage.cliPath` to a wrapper script that SSHes to that Mac and runs `imsg "$@"`.
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ For most users, the upgrade is in place:
|
||||
- runtime state stays under `~/.openclaw/matrix/`
|
||||
|
||||
You do not need to rename config keys or reinstall the plugin under a new name.
|
||||
The root `openclaw` package no longer bundles Matrix runtime code or Matrix SDK
|
||||
dependencies. If `openclaw channels status` shows Matrix is configured but the
|
||||
plugin is missing after an update, run `openclaw doctor --fix` or
|
||||
`openclaw plugins install @openclaw/matrix`; do not install Matrix SDK packages
|
||||
into the root OpenClaw package.
|
||||
|
||||
## What the migration does automatically
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ Precedence is intentional:
|
||||
```bash
|
||||
openclaw approvals set --file ./exec-approvals.json
|
||||
openclaw approvals set --stdin <<'EOF'
|
||||
{ version: 1, defaults: { security: "full", ask: "off" } }
|
||||
{ version: 1, defaults: { security: "full", ask: "off", askFallback: "full" } }
|
||||
EOF
|
||||
openclaw approvals set --node <id|name|ip> --file ./exec-approvals.json
|
||||
openclaw approvals set --gateway --file ./exec-approvals.json
|
||||
@@ -137,7 +137,8 @@ Why `tools.exec.host=gateway` in this example:
|
||||
- YOLO is about approvals, not routing.
|
||||
- If you want host exec even when a sandbox is configured, make the host choice explicit with `gateway` or `/exec host=gateway`.
|
||||
|
||||
This matches the current host-default YOLO behavior. Tighten it if you want approvals.
|
||||
Omitted `askFallback` defaults to `deny`. Set `askFallback: "full"`
|
||||
explicitly when upgrading a no-UI host that should keep never-prompt behavior.
|
||||
|
||||
Local shortcut:
|
||||
|
||||
|
||||
@@ -194,11 +194,14 @@ openclaw browser select <ref> OptionA OptionB
|
||||
openclaw browser fill --fields '[{"ref":"1","value":"Ada"}]'
|
||||
openclaw browser wait --text "Done"
|
||||
openclaw browser evaluate --fn '(el) => el.textContent' --ref <ref>
|
||||
openclaw browser evaluate --fn 'const title = document.title; return title;'
|
||||
openclaw browser evaluate --timeout-ms 30000 --fn 'async () => { await window.ready; return true; }'
|
||||
```
|
||||
|
||||
Use `evaluate --timeout-ms <ms>` when the page-side function may need longer
|
||||
than the default evaluate timeout.
|
||||
`evaluate --fn` accepts a function source, an expression, or a statement body.
|
||||
Statement bodies are wrapped as async functions, so use `return` for the value
|
||||
you want back. Use `evaluate --timeout-ms <ms>` when the page-side function may
|
||||
need longer than the default evaluate timeout.
|
||||
|
||||
Action responses return the current raw `targetId` after action-triggered page
|
||||
replacement when OpenClaw can prove the replacement tab. Scripts should still
|
||||
@@ -277,10 +280,14 @@ Use the built-in `user` profile, or create your own `existing-session` profile:
|
||||
openclaw browser --browser-profile user tabs
|
||||
openclaw browser create-profile --name chrome-live --driver existing-session
|
||||
openclaw browser create-profile --name brave-live --driver existing-session --user-data-dir "~/Library/Application Support/BraveSoftware/Brave-Browser"
|
||||
openclaw browser create-profile --name chrome-port --driver existing-session --cdp-url http://127.0.0.1:9222
|
||||
openclaw browser --browser-profile chrome-live tabs
|
||||
```
|
||||
|
||||
This path is host-only. For Docker, headless servers, Browserless, or other remote setups, use a CDP profile instead.
|
||||
The default existing-session path is host-only Chrome MCP auto-connect. If the browser is already
|
||||
running with a DevTools endpoint, pass `--cdp-url` so Chrome MCP attaches to that endpoint instead.
|
||||
For Docker, Browserless, or other remote setups where Chrome MCP semantics are not needed, use a
|
||||
CDP profile.
|
||||
|
||||
Current existing-session limits:
|
||||
|
||||
|
||||
@@ -673,7 +673,7 @@ Launches a local child process and communicates over stdin/stdout.
|
||||
<Warning>
|
||||
**Stdio env safety filter**
|
||||
|
||||
OpenClaw rejects interpreter-startup env keys that can alter how a stdio MCP server starts up before the first RPC, even if they appear in a server's `env` block. Blocked keys include `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHONSTARTUP`, `PYTHONPATH`, `PERL5OPT`, `RUBYOPT`, `SHELLOPTS`, `PS4`, and similar runtime-control variables. Startup rejects these with a configuration error so they cannot inject an implicit prelude, swap the interpreter, enable a debugger, or redirect runtime output against the stdio process. Ordinary credential, proxy, and server-specific env vars (`GITHUB_TOKEN`, `HTTP_PROXY`, custom `*_API_KEY`, etc.) are unaffected.
|
||||
OpenClaw rejects interpreter-startup env keys that can alter how a stdio MCP server starts up before the first RPC, even if they appear in a server's `env` block. Blocked keys include `BASHOPTS`, `FPATH`, `KSH_ENV`, `NODE_OPTIONS`, `NODE_REDIRECT_WARNINGS`, `NODE_REPL_EXTERNAL_MODULE`, `NODE_REPL_HISTORY`, `NODE_V8_COVERAGE`, `PYTHONSTARTUP`, `PYTHONPATH`, `PERL5OPT`, `RUBYOPT`, `SHELLOPTS`, `PS4`, `TCLLIBPATH`, and similar runtime-control variables. Startup rejects these with a configuration error so they cannot inject an implicit prelude, swap the interpreter, enable a debugger, or redirect runtime output against the stdio process. Ordinary credential, proxy, and server-specific env vars (`GITHUB_TOKEN`, `HTTP_PROXY`, custom `*_API_KEY`, etc.) are unaffected.
|
||||
|
||||
If your MCP server genuinely needs one of the blocked variables, set it on the gateway host process instead of under the stdio server's `env`.
|
||||
</Warning>
|
||||
|
||||
@@ -417,7 +417,7 @@ openclaw plugins inspect <id> --runtime
|
||||
openclaw plugins inspect <id> --json
|
||||
```
|
||||
|
||||
Inspect shows identity, load status, source, manifest capabilities, policy flags, diagnostics, install metadata, bundle capabilities, and any detected MCP or LSP server support without importing plugin runtime by default. Add `--runtime` to load the plugin module and include registered hooks, tools, commands, services, gateway methods, and HTTP routes. Runtime inspection reports missing plugin dependencies directly; installs and repairs stay in `openclaw plugins install`, `openclaw plugins update`, and `openclaw doctor --fix`.
|
||||
Inspect shows identity, load status, source, manifest capabilities, policy flags, diagnostics, install metadata, bundle capabilities, and any detected MCP or LSP server support without importing plugin runtime by default. JSON output includes the plugin manifest contracts, such as `contracts.agentToolResultMiddleware` and `contracts.trustedToolPolicies`, so operators can audit trusted-surface declarations before enabling or restarting a plugin. Add `--runtime` to load the plugin module and include registered hooks, tools, commands, services, gateway methods, and HTTP routes. Runtime inspection reports missing plugin dependencies directly; installs and repairs stay in `openclaw plugins install`, `openclaw plugins update`, and `openclaw doctor --fix`.
|
||||
|
||||
Plugin-owned CLI commands are usually installed as root `openclaw` command groups, but plugins may also register nested commands under a core parent such as `openclaw nodes`. After `inspect --runtime` shows a command under `cliCommands`, run it at the listed path; for example a plugin that registers `demo-git` can be verified with `openclaw demo-git ping`.
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ Notes:
|
||||
- `--local` cannot be combined with `--url`, `--token`, or `--password`.
|
||||
- `tui` resolves configured gateway auth SecretRefs for token/password auth when possible (`env`/`file`/`exec` providers).
|
||||
- When launched from inside a configured agent workspace directory, TUI auto-selects that agent for the session key default (unless `--session` is explicitly `agent:<id>:...`).
|
||||
- To show the Gateway hostname in the footer for non-local URL-backed connections, run `openclaw config set tui.footer.showRemoteHost true`. The host label is off by default and never appears for loopback or embedded local connections.
|
||||
- Local mode uses the embedded agent runtime directly. Most local tools work, but Gateway-only features are unavailable.
|
||||
- Local mode adds `/auth [provider]` inside the TUI command surface.
|
||||
- Plugin approval gates still apply in local mode. Tools that require approval prompt for a decision in the terminal; nothing is silently auto-approved because the Gateway is not involved.
|
||||
|
||||
@@ -19,6 +19,7 @@ updates happen via the package-manager flow in [Updating](/install/updating).
|
||||
```bash
|
||||
openclaw update
|
||||
openclaw update status
|
||||
openclaw update repair
|
||||
openclaw update wizard
|
||||
openclaw update --channel beta
|
||||
openclaw update --channel dev
|
||||
@@ -76,6 +77,36 @@ Options:
|
||||
- `--json`: print machine-readable status JSON.
|
||||
- `--timeout <seconds>`: timeout for checks (default is 3s).
|
||||
|
||||
## `update repair`
|
||||
|
||||
Rerun update finalization after the core package already changed but later
|
||||
repair work did not finish cleanly. This is the supported recovery path when
|
||||
`openclaw update` installed the new core package but post-core plugin sync,
|
||||
managed npm plugin metadata, registry refresh, or doctor repair still needs to
|
||||
converge.
|
||||
|
||||
```bash
|
||||
openclaw update repair
|
||||
openclaw update repair --channel beta
|
||||
openclaw update repair --json
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `--channel <stable|beta|dev>`: persist the update channel before repair and
|
||||
run plugin convergence against that channel.
|
||||
- `--json`: print machine-readable finalization JSON.
|
||||
- `--timeout <seconds>`: timeout for repair steps (default `1800`).
|
||||
- `--yes`: skip confirmation prompts.
|
||||
- `--no-restart`: accepted for update command parity; repair never restarts the
|
||||
Gateway.
|
||||
|
||||
`openclaw update repair` runs `openclaw doctor --fix`, reloads the repaired
|
||||
config and install records, syncs tracked plugins for the active update channel,
|
||||
updates managed npm plugin installs, repairs missing configured plugin payloads,
|
||||
refreshes the plugin registry, and writes the converged install-record metadata.
|
||||
It does not install a new core package and does not restart the Gateway.
|
||||
|
||||
## `update wizard`
|
||||
|
||||
Interactive flow to pick an update channel and confirm whether to restart the Gateway
|
||||
@@ -218,9 +249,9 @@ If an exact pinned npm plugin update resolves to an artifact whose integrity dif
|
||||
</Warning>
|
||||
|
||||
<Note>
|
||||
Post-update plugin sync failures that are scoped to a managed plugin and that the sync path can route around (e.g. an unreachable npm registry for a non-essential plugin) are reported as warnings after the core update succeeds. The JSON result keeps the top-level update `status: "ok"` and reports `postUpdate.plugins.status: "warning"` with `openclaw doctor --fix` and `openclaw plugins inspect <id> --runtime --json` guidance. Unexpected updater or sync exceptions still fail the update result. Fix the plugin install or update error, then rerun `openclaw doctor --fix` or `openclaw update`.
|
||||
Post-update plugin sync failures that are scoped to a managed plugin and that the sync path can route around (e.g. an unreachable npm registry for a non-essential plugin) are reported as warnings after the core update succeeds. The JSON result keeps the top-level update `status: "ok"` and reports `postUpdate.plugins.status: "warning"` with `openclaw update repair` and `openclaw plugins inspect <id> --runtime --json` guidance. Unexpected updater or sync exceptions still fail the update result. Fix the plugin install or update error, then rerun `openclaw update repair`.
|
||||
|
||||
After the per-plugin sync step, `openclaw update` runs a mandatory **post-core convergence** pass before the gateway is restarted: it repairs missing configured plugin payloads, validates each _active_ tracked install record on disk, and statically verifies its `package.json` is parseable (and any explicitly-declared `main` exists). Failures from this pass — and an invalid OpenClaw config snapshot — return `postUpdate.plugins.status: "error"` and flip the top-level update `status` to `"error"`, so `openclaw update` exits non-zero and the gateway is _not_ restarted with an unverified plugin set. The error includes structured `postUpdate.plugins.warnings[].guidance` lines pointing at `openclaw doctor --fix` and `openclaw plugins inspect <id> --runtime --json` for follow-up. Disabled plugin entries and records that are not trusted-source-linked official sync targets are skipped here, mirroring the `skipDisabledPlugins` policy used by the missing-payload check, so a stale disabled plugin record cannot block an otherwise valid update.
|
||||
After the per-plugin sync step, `openclaw update` runs a mandatory **post-core convergence** pass before the gateway is restarted: it repairs missing configured plugin payloads, validates each _active_ tracked install record on disk, and statically verifies its `package.json` is parseable (and any explicitly-declared `main` exists). Failures from this pass — and an invalid OpenClaw config snapshot — return `postUpdate.plugins.status: "error"` and flip the top-level update `status` to `"error"`, so `openclaw update` exits non-zero and the gateway is _not_ restarted with an unverified plugin set. The error includes structured `postUpdate.plugins.warnings[].guidance` lines pointing at `openclaw update repair` and `openclaw plugins inspect <id> --runtime --json` for follow-up. Disabled plugin entries and records that are not trusted-source-linked official sync targets are skipped here, mirroring the `skipDisabledPlugins` policy used by the missing-payload check, so a stale disabled plugin record cannot block an otherwise valid update.
|
||||
|
||||
When the updated Gateway starts, plugin loading is verify-only: startup does not
|
||||
run package managers or mutate dependency trees. Package-manager `update.run`
|
||||
|
||||
@@ -21,7 +21,7 @@ Context is _not the same thing_ as "memory": memory can be stored on disk and re
|
||||
|
||||
- `/status` → quick "how full is my window?" view + session settings.
|
||||
- `/context list` → what's injected + rough sizes (per file + totals).
|
||||
- `/context detail` → deeper breakdown: per-file, per-tool schema sizes, per-skill entry sizes, and system prompt size.
|
||||
- `/context detail` → deeper breakdown: per-file, per-tool schema sizes, per-skill entry sizes, system prompt size, and compactable transcript message counts.
|
||||
- `/context map` → WinDirStat-style treemap image of the current session's tracked context contributors.
|
||||
- `/usage tokens` → append per-reply usage footer to normal replies.
|
||||
- `/compact` → summarize older history into a compact entry to free window space.
|
||||
@@ -179,7 +179,7 @@ pluggable interface, lifecycle hooks, and configuration.
|
||||
- `System prompt (run)` = captured from the last embedded (tool-capable) run and persisted in the session store.
|
||||
- `System prompt (estimate)` = computed on the fly when no run report exists (or when running via a CLI backend that doesn't generate the report).
|
||||
|
||||
Either way, it reports sizes and top contributors; it does **not** dump the full system prompt or tool schemas.
|
||||
Either way, it reports sizes and top contributors; it does **not** dump the full system prompt or tool schemas. In detailed mode, it also compares the session transcript with the same real-conversation message predicate used by compaction, so high prompt/cache usage is easier to distinguish from compactable conversation history.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -39,9 +39,12 @@ To set a provider explicitly:
|
||||
|
||||
Without an embedding provider, only keyword search is available.
|
||||
|
||||
To force the built-in local embedding provider, install the optional
|
||||
`node-llama-cpp` runtime package next to OpenClaw, then point `local.modelPath`
|
||||
at a GGUF file:
|
||||
To force local GGUF embeddings, install the official llama.cpp provider plugin,
|
||||
then point `local.modelPath` at a GGUF file:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/llama-cpp-provider
|
||||
```
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -67,7 +70,7 @@ at a GGUF file:
|
||||
| DeepInfra | `deepinfra` | Default: `BAAI/bge-m3` |
|
||||
| Gemini | `gemini` | Supports multimodal (image + audio) |
|
||||
| GitHub Copilot | `github-copilot` | Uses Copilot subscription |
|
||||
| Local | `local` | Optional `node-llama-cpp` runtime |
|
||||
| Local | `local` | `@openclaw/llama-cpp-provider` |
|
||||
| Mistral | `mistral` | |
|
||||
| Ollama | `ollama` | Local/self-hosted |
|
||||
| OpenAI | `openai` | Default: `text-embedding-3-small` |
|
||||
|
||||
@@ -15,7 +15,7 @@ binary, and can index content beyond your workspace memory files.
|
||||
- **Reranking and query expansion** for better recall.
|
||||
- **Index extra directories** -- project docs, team notes, anything on disk.
|
||||
- **Index session transcripts** -- recall earlier conversations.
|
||||
- **Fully local** -- runs with the optional node-llama-cpp runtime package and
|
||||
- **Fully local** -- runs with the official llama.cpp provider plugin and
|
||||
auto-downloads GGUF models.
|
||||
- **Automatic fallback** -- if QMD is unavailable, OpenClaw falls back to the
|
||||
builtin engine seamlessly.
|
||||
@@ -62,10 +62,12 @@ present.
|
||||
`build`. Gateway startup does not initialize QMD by default, so cold boot
|
||||
avoids importing the memory runtime or creating the long-lived watcher before
|
||||
memory is first used.
|
||||
- If you want a gateway-start refresh anyway, set
|
||||
`memory.qmd.update.startup` to `idle` or `immediate`. The opt-in startup
|
||||
refresh uses a one-shot QMD subprocess path instead of creating the full
|
||||
long-lived in-process watcher.
|
||||
- If you want QMD initialized at gateway start anyway, set
|
||||
`memory.qmd.update.startup` to `idle` or `immediate`. With
|
||||
`memory.qmd.update.onBoot: true`, startup runs the initial refresh. With
|
||||
`onBoot: false`, startup skips that immediate refresh but still opens the
|
||||
long-lived manager when update or embed intervals are configured, so QMD can
|
||||
own its regular watcher and timers.
|
||||
- Searches use the configured `searchMode` (default: `search`; also supports
|
||||
`vsearch` and `query`). `search` is BM25-only, so OpenClaw skips semantic
|
||||
vector readiness probes and embedding maintenance in that mode. If a mode
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user