diff --git a/.changelog/v2.58.0.md b/.changelog/v2.58.0.md
new file mode 100644
index 0000000000..d2a53257ef
--- /dev/null
+++ b/.changelog/v2.58.0.md
@@ -0,0 +1,53 @@
+# Release v2.58.0
+
+Released: 2026-09-04
+
+## Highlights
+
+**CoS / agent orchestration**
+- CoS runs can now split into architect/implementer/reviewer roles, each with its own reasoning-effort setting, instead of one monolithic pass.
+- A run that ships clean code for the wrong task is now held for review instead of merging silently.
+- pr-reviewer's Stage 3 can run as an attachable TUI session you can watch and interact with, rather than only headless.
+- pr-reviewer stages that produce no output are now blocked instead of being silently re-spawned, and a stage that produces multiple conflicting outputs is resolved consistently.
+- Per-app provider overrides were renamed to per-app options, and the run toggle now reads "Enabled" instead of the ambiguous "Run".
+- Agent worktrees now attach to fork PR head branches, so external contributor PRs are actually workable by the CoS pipeline.
+- Fork detection now classifies by owner alone, so renaming a fork no longer disables it.
+- Fixed a reviewer-chain gap where a claim-work reviewer override pinned for GitHub/GitLab/PLAN.md runs silently didn't apply to a JIRA ticket run's play button.
+
+**Local LLM / model management**
+- Slotstream checkpoints can now be downloaded directly from Models → LLMs, with correct byte accounting and unfinished checkpoints hidden until complete; a weightless repo is refused up front.
+- Local model tuning (context window, pinned settings) now survives daemon-unreachable conditions and context-window reloads instead of resetting.
+- A local provider whose offered models have disappeared from its daemon is now reported as not-ready rather than silently stale.
+- Ollama-backed Claude harnesses get a pinned 128K context window, and the numCtx migration correctly stands down when `OLLAMA_CONTEXT_LENGTH` is already set.
+- A large local prompt's prefill is now budgeted so a big context window doesn't look like a wedged/hung run.
+
+**Animation / sprites / rigging**
+- A new RigPanel UI drives retargeting end-to-end: clip picker, diagnostic preview, and write handoff.
+- Rigged animated records can now be exposed to CoS avatars.
+- The degenerate-frame probe is memoized on frame content, cutting redundant sprite-processing work.
+
+**Reliability and data integrity**
+- Fixed a real race where the PromptManager variable race reappeared after a prior fix only relocated it; unsaved prompt-stage edits are now guarded consistently.
+- Bulk-star, media-collection moves, and yt-dlp import cancellation now report failure/cancellation accurately instead of a false success.
+- FableLoom's asset manifest and restorable-field set were both missing coverage that caused older peers to erase delivery plans/beat outlines on sync — fixed.
+- Tribe now reports emails/phones shared by more than one contact, helping catch duplicate-identity records.
+- `safeJSONParse`'s scalar-parsing change had broken object-shape assumptions downstream — guarded, and the related `isValidJSON` fork was inlined back into `safeJSONParse` to stop the two from drifting.
+
+**Performance**
+- Client tests now run on happy-dom instead of jsdom, and 36 DOM-free client test files skip jsdom entirely.
+- Server test suites no longer statically import modules they never exercise, cutting CI import overhead twice this release (#6009, #6156).
+- Tribe's contact list and care summary no longer scan the entire contact table; iMessage handle-frequency counting moved into SQL instead of loading thousands of rows into memory.
+
+**Windows & platform fixes**
+- The in-app update now actually runs on Windows instead of reporting a phantom success.
+- `npm install`'s inline audit no longer stalls install paths.
+- Fixed a path-separator mismatch in the Slotstream in-flight check on Windows.
+
+**Accessibility & UI polish**
+- Swept sub-44px icon buttons tree-wide and widened the tap-target guard.
+- Practice rating hints are now visible as button subtext (with the redundant tooltip removed), and their accessible name is fixed after the change.
+- Fixed mobile responsive layout regressions in POST tests and the songbook transpose readout.
+
+## Full Changelog
+
+**Full Diff**: https://github.com/atomantic/PortOS/compare/v2.57.0...v2.58.0
diff --git a/.env.example b/.env.example
index ae0783973d..7883c2ded6 100644
--- a/.env.example
+++ b/.env.example
@@ -194,6 +194,12 @@ PGPASSWORD=portos
# ~/.slotstream/models). The binary always lives in ~/.slotstream/bin.
# SLOTSTREAM_MODEL_DIR=/path/to/slotstream/models
+# Abandon a Slotstream checkpoint download after this many milliseconds with no
+# bytes received (slotstreamModelManager.js; default: 1200000 / 20 minutes). A
+# transfer that is still receiving bytes is never cut off by this, and an
+# abandoned one keeps its progress so a retry resumes rather than restarts.
+# SLOTSTREAM_IDLE_STALL_MS=1200000
+
# Abort a speculative-decoding model download after this many milliseconds with
# no bytes received (specDecodeModels.js; default: 1200000 / 20 minutes). Raise
# it for a slow Hugging Face/CDN handshake; a download still receiving bytes is
diff --git a/.gitignore b/.gitignore
index 7fc4906e28..4d97b6dc44 100644
--- a/.gitignore
+++ b/.gitignore
@@ -63,6 +63,11 @@ Thumbs.db
/.agent-done
/.agent-done-*
+# Public-review input bundle PortOS materializes into a reviewer's worktree
+# (see server/lib/agentScratchPaths.js) — pipeline state, never work product
+/PORTOS_PUBLIC_REVIEW_INPUT.json
+/.portos-public-review/
+
# BTW messages (ephemeral agent context, cleaned up after ingestion)
BTW.md
diff --git a/.npmrc b/.npmrc
index e2b20841e5..e371dff8c7 100644
--- a/.npmrc
+++ b/.npmrc
@@ -27,3 +27,15 @@ ignore-scripts=true
# root `setup` / `start` / `dev` scripts instead — a check that gates on this
# project's floor and nothing else. `engines` stays for the npm warning and as
# the machine-readable declaration.
+
+# Skip the advisory lookup `npm install` runs AFTER it has already resolved and
+# written node_modules. It only prints a summary, but npm BLOCKS on it — a
+# stalled request costs `fetch-timeout` (300s) times `fetch-retries` (2) before
+# npm moves on. On 2026-09-03 that endpoint began completing the TLS handshake
+# and never answering, and a 0.15s warm install took 153s; every managed install
+# path runs four of them in sequence. Set here rather than as `--no-audit` on a
+# dozen call sites for the same reason `ignore-scripts` is: npm reads config from
+# the local prefix only, so a flag one path forgets is a silent regression.
+# Rationale and what enforces dependency safety instead: docs/DEPS.md
+# "Inline Audit Policy".
+audit=false
diff --git a/AGENTS.md b/AGENTS.md
index 7ceb6a2541..13b63f1909 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -14,7 +14,7 @@ npm run install:all # includes git submodule update --init --recursive
# Root `npm test` runs both workspaces in sequence (server, then client). Run them
# per workspace to scope to one — both are Vitest, with different environments:
cd server && npm test # Vitest (node) — ALSO globs ../scripts, ../lib, ../autofixer
-cd client && npm test # Vitest (jsdom) — component/unit tests
+cd client && npm test # Vitest (happy-dom) — component/unit tests
# No NODE_ENV prefix needed: server/vitest.config.js FORCES NODE_ENV=test (#4554).
# Vitest only defaults it when unset, and PortOS runs under PM2 with
# NODE_ENV=development — a suite run that inherits that aims at the real Postgres.
diff --git a/GOALS.md b/GOALS.md
index 368d75186e..5c1fb98fba 100644
--- a/GOALS.md
+++ b/GOALS.md
@@ -42,7 +42,7 @@ AI agents should be capable of operating fully autonomously across all connected
### 9. Knowledge Legacy
-Preserve personal knowledge, identity, decision-making patterns, creative output, and life story beyond a single lifetime. The autobiography system, genome data, behavioral profiles, captured memories, written work, and built worlds form a durable record — not just of what you built, but of who you are and how you think.
+Preserve personal knowledge, identity, decision-making patterns, creative output, and life story beyond a single lifetime. The autobiography system, genome data, behavioral profiles, captured memories, written work, and built worlds form a durable record — not just of what you built, but of who you are and how you think. PortOS itself is the backup of record (local data + automatic snapshots); download/export buttons exist only for sharing or handoff to other tools (e.g. Sharing buckets, Legacy Bundle), never as a parallel backup mechanism.
### 10. Anywhere Access on Private Network
diff --git a/PRD.md b/PRD.md
index 1ab0933245..9d0bc0976e 100644
--- a/PRD.md
+++ b/PRD.md
@@ -22,7 +22,7 @@ Reused verbatim (condensed to objective statements) from [GOALS.md](./GOALS.md)'
6. **Developer Productivity Toolkit** — shell, git, browser control, and process tooling available from any device.
7. **Self-Improving Intelligence** — the system tunes its own routing/metrics from observed outcomes rather than staying static.
8. **Full Digital Autonomy** — agents can act across connected platforms (voice, Telegram, messaging, social) around the clock.
-9. **Knowledge Legacy** — personal knowledge, identity, and creative output are preserved as a durable, exportable record.
+9. **Knowledge Legacy** — personal knowledge, identity, and creative output are preserved as a durable, local record. PortOS itself is the backup of record (data lives on the user's hardware and is covered by automatic snapshots — see `docs/BACKUP.md`), so per-domain backup-style exports are out of scope; download/export buttons exist only for sharing or handoff to other tools (Sharing buckets, Legacy Bundle, format-specific creative deliverables).
10. **Anywhere Access on Private Network** — every feature is reachable from any device on the user's Tailnet, with no public exposure.
11. **Health & Longevity** — health data (MeatSpace) is tracked and made actionable via mortality/longevity-aware goal scoring.
12. **Personal Productivity & Life Management** — calendar, goals, and communications are unified with the same tooling that manages digital projects.
@@ -208,6 +208,7 @@ Reused verbatim (condensed to objective statements) from [GOALS.md](./GOALS.md)'
| NR-7 | The system MUST NOT treat leakage of a free, non-monetary third-party API key (e.g. CivitAI) to an unintended host as a security finding requiring host-allowlisting or key-stripping. | Won't-fix precedent (#2200): worst case is quota abuse against a free service, borne by that service — no monetary loss or meaningful security consequence. Does not extend to paid/quota-billed providers or money-bearing/destructive-action keys, which retain full hardening requirements. |
| NR-8 | The system MUST NOT send an AI-drafted outbound message (email, social post) without explicit user review-and-approve, regardless of how confident the draft is. | Full digital autonomy (Goal 8) extends to task execution, not to irreversible outward-facing communication acting under the user's identity without a human gate. |
| NR-9 | The system MUST NOT instruct the user to run a shell/terminal command in order to complete a workflow PortOS can perform itself — including installing or removing a runtime, and searching for, downloading, or deleting a model. Blocked-state copy points at the in-app control, not at a command line. | PortOS is the control surface for the machine; sending the user to a terminal for one step of an otherwise-managed lifecycle is a dead end that breaks remote/mobile use (Anywhere Access) and leaves the app's own state stale. **Carve-out:** genuinely privileged one-time host setup PortOS deliberately refuses to perform (`pm2 startup`, `sudo` fan-control helpers, `gcloud auth login`) may be named as an operator step — the refusal must be deliberate and documented, not a gap in the UI. |
+| NR-10 | The system MUST NOT add per-domain backup-style export endpoints (e.g. a generic "export my Brain/memories/thoughts to file" download) as a durability or backup story. | PortOS is a locally hosted app that is itself the user's backup of record — data lives on the user's hardware and is covered by automatic snapshots (`docs/BACKUP.md`). Download/export buttons exist only for sharing or handoff to other tools (Sharing buckets, Legacy Bundle, format-specific creative deliverables), never as a parallel backup mechanism. |
---
@@ -222,6 +223,7 @@ Reused verbatim (condensed to objective statements) from [GOALS.md](./GOALS.md)'
- **Federated peer-to-peer sharing beyond bucket-based Sharing** — direct P2P distribution between instances is a secondary goal, not yet built.
- **Federated media-provider routing for image/video generation** — the queued-job delegation contract (FR-52/FR-53) is implemented and live for audio/music generation today; extending the same provider/consumer contract to image and video generation is tracked separately (issue #4348), not yet built.
- **User-directed assignment of a CoS task to a specific federated peer instance** — task coordination across peers is currently opportunistic only (first peer to see a synced task claims it via the existing lease mechanism); an explicit "run this task on instance X" control is a decided, ready-to-work follow-up (issue #4520), not yet implemented.
+- **Per-domain backup-style exports** — PortOS is locally hosted and is itself the backup of record (automatic snapshots); exports exist only for sharing or handoff to other tools, per NR-10.
---
diff --git a/autofixer/.npmrc b/autofixer/.npmrc
index 8c5481f990..45d2a962f4 100644
--- a/autofixer/.npmrc
+++ b/autofixer/.npmrc
@@ -17,3 +17,10 @@ ignore-scripts=true
# root `setup` / `start` / `dev` scripts instead — a check that gates on this
# project's floor and nothing else. `engines` stays for the npm warning and as
# the machine-readable declaration.
+
+# Skip the post-install advisory lookup. npm BLOCKS on it (`fetch-timeout` 300s
+# times `fetch-retries` 2), so an endpoint that stalls hangs this workspace's
+# install for minutes to print a summary. Same local-prefix rule as above — the
+# repo-root file does not cover this workspace's install path.
+# Rationale: docs/DEPS.md "Inline Audit Policy".
+audit=false
diff --git a/browser/.npmrc b/browser/.npmrc
index dab1a7db8c..437334a363 100644
--- a/browser/.npmrc
+++ b/browser/.npmrc
@@ -6,3 +6,11 @@
# it means the guard is already in place if any are ever added, rather than the
# addition silently arriving with an install-time execution slot.
ignore-scripts=true
+
+# Skip the post-install advisory lookup. npm BLOCKS on it (`fetch-timeout` 300s
+# times `fetch-retries` 2), so an endpoint that stalls hangs an install for
+# minutes to print a summary. Pre-emptive here for the same reason as the setting
+# above — this workspace has no dependencies today, so the guard is in place
+# before any arrive rather than added afterwards.
+# Rationale: docs/DEPS.md "Inline Audit Policy".
+audit=false
diff --git a/client/.npmrc b/client/.npmrc
index c6bb7f64fd..6c59005cd0 100644
--- a/client/.npmrc
+++ b/client/.npmrc
@@ -24,3 +24,10 @@ ignore-scripts=true
# root `setup` / `start` / `dev` scripts instead — a check that gates on this
# project's floor and nothing else. `engines` stays for the npm warning and as
# the machine-readable declaration.
+
+# Skip the post-install advisory lookup. npm BLOCKS on it (`fetch-timeout` 300s
+# times `fetch-retries` 2), so an endpoint that stalls hangs this workspace's
+# install for minutes to print a summary. Same local-prefix rule as above — the
+# repo-root file does not cover this workspace's install path.
+# Rationale: docs/DEPS.md "Inline Audit Policy".
+audit=false
diff --git a/client/package-lock.json b/client/package-lock.json
index 1a1ca1c90f..98520a0126 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -32,7 +32,7 @@
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.7",
"@vitejs/plugin-react": "6.1.1",
- "jsdom": "30.0.1",
+ "happy-dom": "20.14.0",
"rollup-plugin-visualizer": "7.1.1",
"tailwindcss": "4.3.3",
"vite": "8.2.2",
@@ -126,59 +126,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/@asamuzakjp/css-color": {
- "version": "6.0.5",
- "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz",
- "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@csstools/css-calc": "^3.2.1",
- "@csstools/css-color-parser": "^4.1.9",
- "@csstools/css-parser-algorithms": "^4.0.0",
- "@csstools/css-tokenizer": "^4.0.0",
- "lru-cache": "^11.5.2"
- },
- "engines": {
- "node": "^22.13.0 || >=24.0.0"
- }
- },
- "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
- "version": "11.5.2",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
- "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "engines": {
- "node": "20 || >=22"
- }
- },
- "node_modules/@asamuzakjp/dom-selector": {
- "version": "8.3.2",
- "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz",
- "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "bidi-js": "^1.0.3",
- "css-tree": "^3.2.1",
- "is-potential-custom-element-name": "^1.0.1",
- "lru-cache": "^11.5.2"
- },
- "engines": {
- "node": "^22.13.0 || >=24.0.0"
- }
- },
- "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
- "version": "11.5.2",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
- "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "engines": {
- "node": "20 || >=22"
- }
- },
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -424,19 +371,6 @@
"node": ">=14.21.3"
}
},
- "node_modules/@bramus/specificity": {
- "version": "2.4.2",
- "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
- "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "css-tree": "^3.0.0"
- },
- "bin": {
- "specificity": "bin/cli.js"
- }
- },
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
@@ -591,146 +525,6 @@
"w3c-keyname": "^2.2.4"
}
},
- "node_modules/@csstools/color-helpers": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
- "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/csstools"
- },
- {
- "type": "opencollective",
- "url": "https://opencollective.com/csstools"
- }
- ],
- "license": "MIT-0",
- "engines": {
- "node": ">=20.19.0"
- }
- },
- "node_modules/@csstools/css-calc": {
- "version": "3.3.0",
- "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
- "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/csstools"
- },
- {
- "type": "opencollective",
- "url": "https://opencollective.com/csstools"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=20.19.0"
- },
- "peerDependencies": {
- "@csstools/css-parser-algorithms": "^4.0.0",
- "@csstools/css-tokenizer": "^4.0.0"
- }
- },
- "node_modules/@csstools/css-color-parser": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
- "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/csstools"
- },
- {
- "type": "opencollective",
- "url": "https://opencollective.com/csstools"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "@csstools/color-helpers": "^6.1.0",
- "@csstools/css-calc": "^3.3.0"
- },
- "engines": {
- "node": ">=20.19.0"
- },
- "peerDependencies": {
- "@csstools/css-parser-algorithms": "^4.0.0",
- "@csstools/css-tokenizer": "^4.0.0"
- }
- },
- "node_modules/@csstools/css-parser-algorithms": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
- "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/csstools"
- },
- {
- "type": "opencollective",
- "url": "https://opencollective.com/csstools"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=20.19.0"
- },
- "peerDependencies": {
- "@csstools/css-tokenizer": "^4.0.0"
- }
- },
- "node_modules/@csstools/css-syntax-patches-for-csstree": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
- "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/csstools"
- },
- {
- "type": "opencollective",
- "url": "https://opencollective.com/csstools"
- }
- ],
- "license": "MIT-0",
- "peerDependencies": {
- "css-tree": "^3.2.1"
- },
- "peerDependenciesMeta": {
- "css-tree": {
- "optional": true
- }
- }
- },
- "node_modules/@csstools/css-tokenizer": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
- "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/csstools"
- },
- {
- "type": "opencollective",
- "url": "https://opencollective.com/csstools"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=20.19.0"
- }
- },
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
@@ -790,24 +584,6 @@
"react": ">=16.8.0"
}
},
- "node_modules/@exodus/bytes": {
- "version": "1.15.1",
- "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
- "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
- },
- "peerDependencies": {
- "@noble/hashes": "^1.8.0 || ^2.0.0"
- },
- "peerDependenciesMeta": {
- "@noble/hashes": {
- "optional": true
- }
- }
- },
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
@@ -2696,6 +2472,23 @@
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"license": "MIT"
},
+ "node_modules/@types/whatwg-mimetype": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
+ "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@ungap/structured-clone": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
@@ -3271,6 +3064,19 @@
"ieee754": "^1.2.1"
}
},
+ "node_modules/buffer-image-size": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
+ "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
"node_modules/bundle-name": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
@@ -3477,20 +3283,6 @@
"node": ">= 8"
}
},
- "node_modules/css-tree": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
- "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "mdn-data": "2.27.1",
- "source-map-js": "^1.2.1"
- },
- "engines": {
- "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
- }
- },
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
@@ -3645,20 +3437,6 @@
"node": ">=12"
}
},
- "node_modules/data-urls": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
- "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "whatwg-mimetype": "^5.0.0",
- "whatwg-url": "^16.0.0"
- },
- "engines": {
- "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
- }
- },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -3676,13 +3454,6 @@
}
}
},
- "node_modules/decimal.js": {
- "version": "10.6.0",
- "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
- "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
@@ -3848,19 +3619,6 @@
"node": ">=10.13.0"
}
},
- "node_modules/entities": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
- "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=20.19.0"
- },
- "funding": {
- "url": "https://github.com/fb55/entities?sponsor=1"
- }
- },
"node_modules/es-module-lexer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
@@ -4083,6 +3841,48 @@
"node": ">=18.18.0"
}
},
+ "node_modules/happy-dom": {
+ "version": "20.14.0",
+ "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.14.0.tgz",
+ "integrity": "sha512-4bRh1KzRvKDnFNTlLhzT1RZTpkKhQbQDl9j+7GXszWsvuspYdo29k6OHRf4PwiM6oLb8r/pMWeYiJjkfod5AvQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": ">=20.0.0",
+ "@types/whatwg-mimetype": "^3.0.2",
+ "@types/ws": "^8.18.1",
+ "buffer-image-size": "^0.6.4",
+ "entities": "^7.0.1",
+ "whatwg-mimetype": "^3.0.0",
+ "ws": "^8.21.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/happy-dom/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/happy-dom/node_modules/whatwg-mimetype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
+ "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/hast-util-embedded": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz",
@@ -4437,19 +4237,6 @@
"integrity": "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==",
"license": "MIT"
},
- "node_modules/html-encoding-sniffer": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
- "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@exodus/bytes": "^1.6.0"
- },
- "engines": {
- "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
- }
- },
"node_modules/html-void-elements": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
@@ -4640,13 +4427,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/is-potential-custom-element-name": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
- "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/is-promise": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
@@ -4722,72 +4502,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/jsdom": {
- "version": "30.0.1",
- "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
- "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@asamuzakjp/css-color": "^6.0.5",
- "@asamuzakjp/dom-selector": "^8.3.0",
- "@bramus/specificity": "^2.4.2",
- "@csstools/css-syntax-patches-for-csstree": "^1.1.7",
- "@exodus/bytes": "^1.15.1",
- "css-tree": "^3.2.1",
- "data-urls": "^7.0.0",
- "decimal.js": "^10.6.0",
- "html-encoding-sniffer": "^6.0.0",
- "is-potential-custom-element-name": "^1.0.1",
- "lru-cache": "^11.5.2",
- "parse5": "^8.0.1",
- "saxes": "^6.0.0",
- "symbol-tree": "^3.2.4",
- "tough-cookie": "^6.0.2",
- "undici": "^8.9.0",
- "w3c-xmlserializer": "^5.0.0",
- "webidl-conversions": "^8.0.1",
- "whatwg-mimetype": "^5.0.0",
- "whatwg-url": "^17.1.0",
- "xml-name-validator": "^5.0.0"
- },
- "engines": {
- "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
- },
- "peerDependencies": {
- "canvas": "^3.2.3"
- },
- "peerDependenciesMeta": {
- "canvas": {
- "optional": true
- }
- }
- },
- "node_modules/jsdom/node_modules/lru-cache": {
- "version": "11.5.2",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
- "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "engines": {
- "node": "20 || >=22"
- }
- },
- "node_modules/jsdom/node_modules/whatwg-url": {
- "version": "17.1.0",
- "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
- "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@exodus/bytes": "^1.15.1",
- "tr46": "^6.0.0",
- "webidl-conversions": "^8.0.1"
- },
- "engines": {
- "node": "^22.14.0 || >=24.0.0"
- }
- },
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
@@ -5403,13 +5117,6 @@
"url": "https://opencollective.com/unified"
}
},
- "node_modules/mdn-data": {
- "version": "2.27.1",
- "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
- "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
- "dev": true,
- "license": "CC0-1.0"
- },
"node_modules/meshline": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz",
@@ -6105,19 +5812,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/parse5": {
- "version": "8.0.1",
- "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
- "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "entities": "^8.0.0"
- },
- "funding": {
- "url": "https://github.com/inikulin/parse5?sponsor=1"
- }
- },
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -6269,16 +5963,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
- "node_modules/punycode": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
- "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/radix-vue": {
"version": "1.9.17",
"resolved": "https://registry.npmjs.org/radix-vue/-/radix-vue-1.9.17.tgz",
@@ -6803,19 +6487,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/saxes": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
- "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "xmlchars": "^2.2.0"
- },
- "engines": {
- "node": ">=v12.22.7"
- }
- },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -7097,13 +6768,6 @@
"vue": ">=3.2.26 < 4"
}
},
- "node_modules/symbol-tree": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
- "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/tabbable": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz",
@@ -7185,9 +6849,9 @@
}
},
"node_modules/three-stdlib/node_modules/fflate": {
- "version": "0.6.10",
- "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz",
- "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==",
+ "version": "0.6.11",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.11.tgz",
+ "integrity": "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==",
"license": "MIT"
},
"node_modules/time-span": {
@@ -7255,52 +6919,6 @@
"node": ">=14.0.0"
}
},
- "node_modules/tldts": {
- "version": "7.4.10",
- "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz",
- "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "tldts-core": "^7.4.10"
- },
- "bin": {
- "tldts": "bin/cli.js"
- }
- },
- "node_modules/tldts-core": {
- "version": "7.4.10",
- "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz",
- "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/tough-cookie": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
- "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
- "dev": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "tldts": "^7.0.5"
- },
- "engines": {
- "node": ">=16"
- }
- },
- "node_modules/tr46": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
- "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "punycode": "^2.3.1"
- },
- "engines": {
- "node": ">=20"
- }
- },
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -7423,16 +7041,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/undici": {
- "version": "8.9.0",
- "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz",
- "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=22.19.0"
- }
- },
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
@@ -8114,19 +7722,6 @@
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
- "node_modules/w3c-xmlserializer": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
- "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "xml-name-validator": "^5.0.0"
- },
- "engines": {
- "node": ">=18"
- }
- },
"node_modules/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
@@ -8154,41 +7749,6 @@
"integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==",
"license": "MIT"
},
- "node_modules/webidl-conversions": {
- "version": "8.0.1",
- "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
- "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=20"
- }
- },
- "node_modules/whatwg-mimetype": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
- "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=20"
- }
- },
- "node_modules/whatwg-url": {
- "version": "16.0.1",
- "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
- "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@exodus/bytes": "^1.11.0",
- "tr46": "^6.0.0",
- "webidl-conversions": "^8.0.1"
- },
- "engines": {
- "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
- }
- },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -8295,23 +7855,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/xml-name-validator": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
- "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/xmlchars": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
- "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
diff --git a/client/package.json b/client/package.json
index 21cef5a4b6..d2c0e19918 100644
--- a/client/package.json
+++ b/client/package.json
@@ -43,7 +43,7 @@
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.7",
"@vitejs/plugin-react": "6.1.1",
- "jsdom": "30.0.1",
+ "happy-dom": "20.14.0",
"rollup-plugin-visualizer": "7.1.1",
"tailwindcss": "4.3.3",
"vite": "8.2.2",
diff --git a/client/src/a11yConventions.test.js b/client/src/a11yConventions.test.js
index df241a30f7..3f345c99d5 100644
--- a/client/src/a11yConventions.test.js
+++ b/client/src/a11yConventions.test.js
@@ -32,9 +32,9 @@
* 6. An `` with no `alt`, which is announced by its `src` — a hashed
* filename or a blob URL. `alt=""` is the correct spelling for a
* decorative image and passes; only the omission is the bug.
- * 7. An icon-only `
+ {/* Which layer supplied the seeded list. A claim-work override wins
+ over Models → Code Reviewers silently, so a user who changed the
+ install default and sees a different chain here has to be told
+ where it came from. */}
+ {!review && claimReviewers?.source === 'task-override' && (
+
+ Seeded
+
+ )}
>
)}
diff --git a/client/src/components/apps/SlashDoRunDrawer.test.jsx b/client/src/components/apps/SlashDoRunDrawer.test.jsx
index 2dfbc27bf8..24d4c38df5 100644
--- a/client/src/components/apps/SlashDoRunDrawer.test.jsx
+++ b/client/src/components/apps/SlashDoRunDrawer.test.jsx
@@ -1,6 +1,7 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router';
import SlashDoRunDrawer from './SlashDoRunDrawer';
const api = vi.hoisted(() => ({
@@ -9,6 +10,9 @@ const api = vi.hoisted(() => ({
// Backs the reviewer table's Model column (useReviewerModelOptions).
getLocalLlmStatus: vi.fn(),
getAppWorkItems: vi.fn(),
+ // What the RUN resolves — the claim-work override layer getCodeReviewDefaults
+ // cannot see, and what the untouched picker is seeded from.
+ getAppClaimReviewers: vi.fn(),
createSlashdoTask: vi.fn()
}));
@@ -22,17 +26,21 @@ vi.mock('../../services/apiLocalLlm', () => ({
getToolUseModels: vi.fn(() => new Promise(() => {})),
}));
+// Routed: the override note links to the panel that owns the pin, so the drawer
+// needs a router the way it has one in the app.
const renderDrawer = (props = {}) => render(
-
+
+
+
);
describe('SlashDoRunDrawer', () => {
@@ -49,6 +57,10 @@ describe('SlashDoRunDrawer', () => {
reason: 'actionable-issues',
transient: false
});
+ api.getAppClaimReviewers.mockResolvedValue({
+ source: 'defaults', reviewers: ['copilot'], usernames: [], optionalReviewers: [],
+ reviewerMaxRounds: {}, reviewerModels: {}, reviewerEfforts: {}, csv: 'copilot'
+ });
api.createSlashdoTask.mockResolvedValue({ id: 'task-1', status: 'pending' });
});
@@ -70,6 +82,35 @@ describe('SlashDoRunDrawer', () => {
expect(settings.reviewers).toBeUndefined();
});
+ // The bug this seeding fixes: the picker used to display the Code Review
+ // Defaults, which do NOT include the claim-work task override the run resolves
+ // FIRST. A user who had moved the install default to `antigravity` saw
+ // `antigravity` here while every claim actually reviewed with codex + claude.
+ it('seeds the untouched picker from the reviewers the RUN resolves, not the install defaults', async () => {
+ api.getCodeReviewDefaults.mockResolvedValue({ reviewers: ['antigravity'], usernames: [], optionalReviewers: [] });
+ api.getAppClaimReviewers.mockResolvedValue({
+ source: 'task-override', reviewers: ['codex', 'claude'], usernames: [], optionalReviewers: [],
+ reviewerMaxRounds: {}, reviewerModels: {}, reviewerEfforts: {}, csv: 'codex,claude'
+ });
+
+ renderDrawer();
+
+ // Selected reviewers render as Remove buttons; unselected ones as Add.
+ await waitFor(() => expect(screen.getByRole('button', { name: /Remove Codex/ })).toBeInTheDocument());
+ expect(screen.getByRole('button', { name: /Remove Claude/ })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /Remove Antigravity/ })).not.toBeInTheDocument();
+ // …and the user is told WHERE that list comes from, since it isn't the panel
+ // they would go to in order to change it.
+ expect(screen.getByText(/claim-work/)).toBeInTheDocument();
+ });
+
+ it('does not blame a claim-work override when the reviewers came from the install defaults', async () => {
+ renderDrawer();
+
+ await waitFor(() => expect(screen.getByText('Reviewers (in order):')).toBeInTheDocument());
+ expect(screen.queryByText(/claim-work/)).not.toBeInTheDocument();
+ });
+
it('sends the reviewer list only once the user edits it', async () => {
const onQueued = vi.fn();
renderDrawer({ onQueued });
diff --git a/client/src/components/apps/tabs/AutomationTab.jsx b/client/src/components/apps/tabs/AutomationTab.jsx
index 82d73be830..ac819a2066 100644
--- a/client/src/components/apps/tabs/AutomationTab.jsx
+++ b/client/src/components/apps/tabs/AutomationTab.jsx
@@ -209,10 +209,13 @@ export default function AutomationTab({ appId, appName }) {
-
Task Type Overrides
-
Per-app automation preferences for CoS task scheduling
+
Scheduled Task Options
+
+ Each toggle turns that CoS scheduled task on or off for this app. The controls beside it are optional —
+ leave one on Inherit and it follows the global schedule defaults.
+
-
+
{/* Row 1: name + toggle + configure + run now */}
- handleToggle(taskType, isEnabled)} size="sm" activeColor="bg-port-success" />
+ {/* Labelled "Enabled", not "Run" — the row already has a Run
+ (trigger now) button, and this switch is the on/off state
+ that gates both the schedule and that button. */}
+
+ Enabled
+ handleToggle(taskType, isEnabled)}
+ size="sm"
+ activeColor="bg-port-success"
+ ariaLabel={`${taskType} enabled for this app: ${isEnabled ? 'on' : 'off'}`}
+ />
+
{taskType}
{effectiveLabel}{intervalSuffix}
@@ -261,7 +281,7 @@ export default function AutomationTab({ appId, appName }) {
setExpandedTaskType(prev => prev === taskType ? null : taskType)}
aria-expanded={isExpanded}
- aria-label={`${isExpanded ? 'Hide' : 'Show'} provider and model overrides for ${taskType}`}
+ aria-label={`${isExpanded ? 'Hide' : 'Show'} provider and model options for ${taskType}`}
className="px-2 py-1 bg-port-border/60 text-gray-300 hover:bg-port-border rounded text-xs inline-flex items-center gap-1 shrink-0"
>
{isExpanded ? : }
diff --git a/client/src/components/apps/tabs/AutomationTab.test.jsx b/client/src/components/apps/tabs/AutomationTab.test.jsx
index 69426a5c55..1822b93d0e 100644
--- a/client/src/components/apps/tabs/AutomationTab.test.jsx
+++ b/client/src/components/apps/tabs/AutomationTab.test.jsx
@@ -74,11 +74,11 @@ beforeEach(() => {
vi.clearAllMocks();
});
-describe('AutomationTab per-app overrides', () => {
+describe('AutomationTab per-app options', () => {
it('Configure toggle expands the provider override panel', async () => {
await renderTab();
const row = rowFor('layered-intelligence');
- const configureBtn = within(row).getByRole('button', { name: /show provider and model overrides/i });
+ const configureBtn = within(row).getByRole('button', { name: /show provider and model options/i });
expect(configureBtn).toHaveAttribute('aria-expanded', 'false');
// Provider selector is not rendered until expanded.
expect(within(row).queryByLabelText('Provider override')).toBeNull();
@@ -92,7 +92,7 @@ describe('AutomationTab per-app overrides', () => {
it('changing the provider PATCHes updateAppTaskTypeOverride with providerId + cleared model', async () => {
await renderTab();
const row = rowFor('layered-intelligence');
- fireEvent.click(within(row).getByRole('button', { name: /show provider and model overrides/i }));
+ fireEvent.click(within(row).getByRole('button', { name: /show provider and model options/i }));
const providerSelect = within(row).getByLabelText('Provider override');
fireEvent.change(providerSelect, { target: { value: 'claude-cli' } });
@@ -109,7 +109,7 @@ describe('AutomationTab per-app overrides', () => {
it('changing the model PATCHes updateAppTaskTypeOverride with the model', async () => {
await renderTab({ 'layered-intelligence': { providerId: 'claude-cli' } });
const row = rowFor('layered-intelligence');
- fireEvent.click(within(row).getByRole('button', { name: /show provider and model overrides/i }));
+ fireEvent.click(within(row).getByRole('button', { name: /show provider and model options/i }));
fireEvent.change(within(row).getByLabelText('Model'), { target: { value: 'sonnet' } });
@@ -124,7 +124,7 @@ describe('AutomationTab per-app overrides', () => {
it('excludes disabled providers from the picker', async () => {
await renderTab();
const row = rowFor('layered-intelligence');
- fireEvent.click(within(row).getByRole('button', { name: /show provider and model overrides/i }));
+ fireEvent.click(within(row).getByRole('button', { name: /show provider and model options/i }));
const providerSelect = within(row).getByLabelText('Provider override');
expect(within(providerSelect).queryByText('Disabled')).toBeNull();
expect(within(providerSelect).getByText('Claude Code')).toBeInTheDocument();
@@ -133,7 +133,7 @@ describe('AutomationTab per-app overrides', () => {
it('layered-intelligence row shows a behavior link that deep-links to the Intelligence tab', async () => {
await renderTab();
const row = rowFor('layered-intelligence');
- fireEvent.click(within(row).getByRole('button', { name: /show provider and model overrides/i }));
+ fireEvent.click(within(row).getByRole('button', { name: /show provider and model options/i }));
const link = within(row).getByRole('button', { name: /configure behavior/i });
fireEvent.click(link);
@@ -145,14 +145,14 @@ describe('AutomationTab per-app overrides', () => {
it('offers the same provider picker on a task type with no hook', async () => {
await renderTab();
const row = rowFor('app-improvement');
- fireEvent.click(within(row).getByRole('button', { name: /show provider and model overrides/i }));
+ fireEvent.click(within(row).getByRole('button', { name: /show provider and model options/i }));
expect(within(row).getByLabelText('Provider override')).toBeInTheDocument();
});
it('clearing the provider sends explicit nulls, matching the other pin surfaces', async () => {
await renderTab({ 'app-improvement': { providerId: 'claude-cli', model: 'opus' } });
const row = rowFor('app-improvement');
- fireEvent.click(within(row).getByRole('button', { name: /show provider and model overrides/i }));
+ fireEvent.click(within(row).getByRole('button', { name: /show provider and model options/i }));
fireEvent.change(within(row).getByLabelText('Provider override'), { target: { value: '' } });
await waitFor(() => expect(api.updateAppTaskTypeOverride).toHaveBeenCalledWith(
diff --git a/client/src/components/apps/tabs/IssuesTab.jsx b/client/src/components/apps/tabs/IssuesTab.jsx
index 71956ecea5..f01032c1c2 100644
--- a/client/src/components/apps/tabs/IssuesTab.jsx
+++ b/client/src/components/apps/tabs/IssuesTab.jsx
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useMemo, useId, useRef } from 'react'
import { Link } from 'react-router';
import {
AlertTriangle, Bot, ChevronDown, ChevronRight, CircleDot, ClipboardCheck,
- ExternalLink, Loader2, RefreshCw, Rocket, Search, Tag, User
+ ExternalLink, Loader2, MessageSquare, RefreshCw, Rocket, Search, Tag, User
} from 'lucide-react';
import BrailleSpinner from '../../BrailleSpinner';
import Banner from '../../ui/Banner';
@@ -12,6 +12,8 @@ import ProviderModelSelector from '../../ProviderModelSelector';
import { useThemeContext } from '../../ThemeContext';
import { useCosTaskUpdates } from '../../../hooks/useCosTaskUpdates';
import useProviderModels from '../../../hooks/useProviderModels';
+import useClaimReviewers from '../../../hooks/useClaimReviewers';
+import ClaimReviewerSource from '../ClaimReviewerSource';
import { chipColors } from '../../../lib/chipContrast';
import { isProcessProvider } from '../../../utils/providers';
import * as api from '../../../services/api';
@@ -241,8 +243,10 @@ export default function IssuesTab({ appId, appName }) {
// Page-level provider/model/effort pin for every Claim AND Replan button on this tab —
// left untouched (blank), a claim resolves the install's active provider,
// same as the bare button always did (POST /tasks/slashdo -> resolveAgentProviderAndModel;
- // this manual path does NOT consult the app's scheduled claim-work override —
- // that's a separate resolution used only by the automated claim-work task).
+ // this manual path does NOT consult the app's scheduled claim-work override for
+ // the PROVIDER — that pin is read only by the automated claim-work task).
+ // Scoped to the provider deliberately: the REVIEWERS below do come from that
+ // override, which is precisely the mismatch `claimReviewers` exists to surface.
// This picker never persists across a reload; it's a session convenience for
// "claim the next several issues with model X" without reopening the Agent
// Operations drawer each time.
@@ -252,6 +256,11 @@ export default function IssuesTab({ appId, appName }) {
} = useProviderModels({ filter: enabledProcessProviderFilter, allowDefault: true, silent: true, withEffort: true });
const [effort, setEffort] = useState('');
const [overrideContext, setOverrideContext] = useState('');
+ // The reviewers a Claim launched from this tab will actually run — NOT the
+ // Models → Code Reviewers list, whenever a claim-work override is in play (see
+ // `GET /apps/:id/claim-reviewers`). This tab has no reviewer picker, so it
+ // names them read-only beside the provider pin.
+ const claimReviewers = useClaimReviewers(appId);
// Keep the event-driven path based on the latest runs without putting a
// mutable state snapshot in its effect dependencies. Socket callbacks can
@@ -575,6 +584,20 @@ export default function IssuesTab({ appId, appName }) {
/>
+ {claimReviewers && (
+
+
+ Reviewed by
+
+ {/* No empty-list branch: the route resolves through
+ `claimSafeReviewers`, which falls back to a non-empty list rather
+ than ever handing a claim agent nothing to run. */}
+
{/* Refusals PortOS's shared update preflight raises (server/services/updatePreflight.js)
carry an explicit acknowledgement the user can opt into and retry with. */}
diff --git a/client/src/components/apps/tabs/RepositorySourcePanel.test.jsx b/client/src/components/apps/tabs/RepositorySourcePanel.test.jsx
index 3d4f43a948..085f382e9c 100644
--- a/client/src/components/apps/tabs/RepositorySourcePanel.test.jsx
+++ b/client/src/components/apps/tabs/RepositorySourcePanel.test.jsx
@@ -347,3 +347,24 @@ describe('managed app repository sources', () => {
expect(screen.getByRole('button', { name: 'Update app' })).toBeInTheDocument();
});
});
+
+describe('PortOS self-update restart handoff', () => {
+ it('swaps the banner copy to the restart notice once useAppOperation reports it', async () => {
+ // The detection itself lives in useAppOperation (see its own suite). What
+ // this panel owes the user is telling them the page will come back rather
+ // than leaving "Stopping PortOS apps..." on screen with no explanation.
+ useAppOperation.mockReturnValue({
+ steps: [{ step: 'pm2-stop', status: 'running', message: 'Stopping PortOS apps...' }],
+ isOperating: true, operationType: 'update', error: null, errorCode: null, completed: false,
+ restarting: true,
+ startUpdate: vi.fn(),
+ });
+ render();
+
+ expect(await screen.findByText(
+ 'PortOS is restarting — this page reloads once it answers again.',
+ )).toBeInTheDocument();
+ // Every action stays locked while the install is coming back.
+ expect(screen.getByRole('button', { name: 'Check sources' })).toBeDisabled();
+ });
+});
diff --git a/client/src/components/apps/tabs/UpdateTab.jsx b/client/src/components/apps/tabs/UpdateTab.jsx
index 57274c68cd..6543b33ea0 100644
--- a/client/src/components/apps/tabs/UpdateTab.jsx
+++ b/client/src/components/apps/tabs/UpdateTab.jsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useCallback, useRef } from 'react';
+import { useState, useEffect, useCallback } from 'react';
import { RefreshCw, Download, XCircle, Check, Loader, AlertTriangle, Trash2, ExternalLink, Tag, GitFork, GitBranch } from 'lucide-react';
import toast from '../../ui/Toast';
import BrailleSpinner from '../../BrailleSpinner';
@@ -8,7 +8,7 @@ import * as api from '../../../services/api';
import socket from '../../../services/socket';
import { formatDateTime, formatDateNumeric, formatTimeOfDaySeconds } from '../../../utils/formatters';
import { useAutoRefetch } from '../../../hooks/useAutoRefetch';
-import useMounted from '../../../hooks/useMounted';
+import { usePortosRestartWatch } from '../../../hooks/usePortosRestartWatch';
const STEP_LABELS = {
starting: 'Starting update',
@@ -43,31 +43,20 @@ export default function UpdateTab() {
const [updating, setUpdating] = useState(false);
const [steps, setSteps] = useState([]);
const [updateError, setUpdateError] = useState(null);
- const [polling, setPolling] = useState(false);
const [syncingFork, setSyncingFork] = useState(false);
const [forkSyncError, setForkSyncError] = useState(null);
- const attemptsRef = useRef(0);
- const targetVersionRef = useRef(null);
- const preUpdateVersionRef = useRef(null);
- // Mirrors `updating` for the socket 'disconnect' listener below, which is
- // registered once on mount and would otherwise close over a stale `false`.
- const updatingRef = useRef(false);
- // Guards the disconnect-confirmation setTimeout below: without this, an
- // unmount (e.g. the user navigates away) while the timer is pending lets
- // the deferred callback still fire, pop an undismissable "PortOS is
- // restarting..." toast (duration: Infinity), and set state on an unmounted
- // component — nothing is left running to ever dismiss it.
- const mountedRef = useMounted();
- // Tracks whether the health endpoint went down during a restart poll. A
- // reconcile (issue #1779) often lands the SAME version (new commits, no
- // release bump), so version-change detection alone can't confirm completion —
- // a down→up transition does.
- const healthWentDownRef = useRef(false);
- // Highest /system/health uptime seen so far. The server's uptime resets to
- // ~0 on restart, so an uptime that drops well below the previous peak proves
- // a restart happened — catching a same-version reconcile whose restart is too
- // fast for the 2s poll to ever sample the down window.
- const maxUptimeRef = useRef(0);
+
+ // There is no completion event to wait for once update.sh pm2-deletes this
+ // server, so the restart handoff — arming, health polling, the reload — lives
+ // in the shared watch that every PortOS self-update surface uses.
+ const { polling, captureBaseline } = usePortosRestartWatch({
+ active: updating,
+ onRestart: () => setUpdating(false),
+ onFailure: ({ message }) => {
+ setUpdating(false);
+ if (message) setUpdateError(message);
+ },
+ });
const fetchStatus = useCallback(async () => {
const data = await api.getUpdateStatus().catch(() => null);
@@ -80,26 +69,9 @@ export default function UpdateTab() {
fetchStatus();
}, [fetchStatus]);
+ // Step frames for the activity list. The restart handoff these frames end in
+ // is the shared watch's job, not this component's.
useEffect(() => {
- updatingRef.current = updating;
- }, [updating]);
-
- // Socket event listeners for update progress
- useEffect(() => {
- // Shared by the 'restart' step, 'portos:update:complete', and the
- // 'disconnect' fallback below — all three mean "the server is (or is
- // about to be) restarting, stop trusting the socket and start polling."
- // Sets `updatingRef` synchronously (not just via the syncing effect,
- // which only runs after the next commit) so a 'disconnect' arriving in
- // the same tick right after an error/complete can't read a stale `true`
- // and spuriously re-arm polling for an update that already ended.
- const armRestartPolling = () => {
- updatingRef.current = false;
- setUpdating(false);
- setPolling(true);
- toast.loading('PortOS is restarting...', { id: 'portos-update-restart', duration: Infinity });
- };
-
const handleStep = ({ step, status: stepStatus, message }) => {
setSteps(prev => {
const existing = prev.findIndex(s => s.step === step);
@@ -111,133 +83,12 @@ export default function UpdateTab() {
}
return [...prev, entry];
});
- // When the server signals it's restarting, begin health polling immediately.
- // The PM2 restart may kill the server before portos:update:complete fires.
- if ((step === 'restarting' || step === 'restart') && stepStatus !== 'error' && targetVersionRef.current) {
- armRestartPolling();
- }
- };
-
- const handleComplete = ({ success, newVersion, versionKnown }) => {
- if (!success) {
- updatingRef.current = false;
- setUpdating(false);
- return;
- }
- // Use server-reported actual version when available; fall back to target
- if (versionKnown && newVersion) {
- targetVersionRef.current = newVersion;
- }
- armRestartPolling();
- };
-
- const handleError = ({ message }) => {
- updatingRef.current = false;
- setUpdating(false);
- setPolling(false);
- toast.dismiss('portos-update-restart');
- setUpdateError(message);
- };
-
- // `pm2 delete ecosystem.config.cjs` (the update's own "pm2-stop" step) kills
- // this server process — and its socket — well before update.sh reaches its
- // 'restart' step. That step event, and 'portos:update:complete', are then
- // never emitted, and the UI hangs on "Reconciling..."/"Stopping apps"
- // forever even though update.sh finishes fine in the background. A raw
- // 'disconnect' isn't proof of that by itself, though — PortOS is commonly
- // used remotely over Tailscale, and a transient network blip during the
- // pre-pm2-stop steps (git-pull/submodules, while the server is still very
- // much alive) would fire 'disconnect' too. Confirm the server is actually
- // unreachable before treating this as "the update just tore the process
- // down" — otherwise a blip prematurely arms polling, which can time out
- // with a false "Restart timed out" error while the real update finishes
- // fine in the background with nothing left watching it.
- const handleDisconnect = () => {
- if (!updatingRef.current) return;
- setTimeout(async () => {
- if (!updatingRef.current || !mountedRef.current) return;
- // silent: true — a failed check here just means "confirmed, arm
- // polling"; the generic "Server unreachable" toast would otherwise
- // fire right alongside (and ahead of) the intended "restarting" toast
- // on the exact real-disconnect case this confirmation exists for.
- const ok = await api.checkHealth({ silent: true }).catch(() => null);
- if (!ok && updatingRef.current && mountedRef.current) armRestartPolling();
- }, 1500);
};
socket.on('portos:update:step', handleStep);
- socket.on('portos:update:complete', handleComplete);
- socket.on('portos:update:error', handleError);
- socket.on('disconnect', handleDisconnect);
-
- return () => {
- socket.off('portos:update:step', handleStep);
- socket.off('portos:update:complete', handleComplete);
- socket.off('portos:update:error', handleError);
- socket.off('disconnect', handleDisconnect);
- };
- }, []);
-
- // Poll health endpoint after restart to detect new version. The hook's
- // `enabled: polling` gate handles teardown automatically when polling flips
- // off; attemptsRef resets on every fresh polling cycle.
- useEffect(() => {
- if (polling) {
- attemptsRef.current = 0;
- healthWentDownRef.current = false;
- }
- }, [polling]);
-
- const pollHealth = useCallback(async () => {
- attemptsRef.current += 1;
- // silent: true — the server being unreachable is the EXPECTED state for
- // most of this restart poll (that's the down→up transition it's
- // watching for), not an error; the generic toast would spam "Server
- // unreachable" on every 2s tick throughout the "PortOS is restarting..."
- // loading toast's own lifetime.
- const ok = await api.checkHealth({ silent: true }).catch(() => null);
- const preUpdateVersion = preUpdateVersionRef.current;
- if (!ok) {
- // Server is mid-restart (PM2 stopped it) — record the dip so a same-version
- // recovery still counts as "restarted".
- healthWentDownRef.current = true;
- } else if (preUpdateVersion && ok.version && ok.version !== preUpdateVersion) {
- // The running version differs from before the update — restart confirmed.
- // (We don't gate on === targetVersion: that clause would fire on the FIRST
- // healthy poll of a same-version reconcile, where target === preUpdate, and
- // declare success before the server ever went down.)
- setPolling(false);
- toast.success(`Updated to v${ok.version}`, { id: 'portos-update-restart' });
- setTimeout(() => window.location.reload(), 1000);
- return;
- } else if (
- ok.version &&
- (healthWentDownRef.current ||
- (typeof ok.uptime === 'number' && ok.uptime < maxUptimeRef.current - 5))
- ) {
- // Same version, but the restart is proven either by a down→up dip or by
- // the server's uptime resetting below its pre-restart peak (the 5s slack
- // absorbs clock jitter). Catches a reconcile whose restart was too fast
- // for the 2s poll to ever sample the down window.
- setPolling(false);
- toast.success('Install reconciled — reloading', { id: 'portos-update-restart' });
- setTimeout(() => window.location.reload(), 1000);
- return;
- }
- // Track the running peak so a later uptime drop is detectable. Guard on
- // `ok` — the !ok (server-down) branch falls through to here, and a null
- // deref would throw before the attempts>=30 timeout check below, hanging the
- // UI on a restart that never recovers.
- if (ok && typeof ok.uptime === 'number' && ok.uptime > maxUptimeRef.current) {
- maxUptimeRef.current = ok.uptime;
- }
- if (attemptsRef.current >= 30) {
- setPolling(false);
- toast.error('Restart timed out — try reloading manually', { id: 'portos-update-restart' });
- }
+ return () => socket.off('portos:update:step', handleStep);
}, []);
- useAutoRefetch(pollHealth, 2000, { enabled: polling, pollOnly: true });
// Keep the status fresh while there's an update/reconcile surface on screen,
// so the agent block appears AND clears without a manual re-check: if an agent
@@ -265,29 +116,19 @@ export default function UpdateTab() {
// single source of truth for the just-loaded state.
const runUpdate = useCallback(async (opts = {}, fromStatus = null) => {
const s = fromStatus || status;
- if (s?.latestRelease?.version) {
- targetVersionRef.current = s.latestRelease.version;
- }
- preUpdateVersionRef.current = s?.currentVersion || null;
- // Seed the uptime peak with the still-running server's uptime, so even an
+ // Record the version and uptime of the still-running server, so even an
// instant restart (whose first post-restart poll already reports a small
// uptime) is detected as a drop below this pre-update value.
- const preHealth = await api.checkHealth().catch(() => null);
- maxUptimeRef.current = typeof preHealth?.uptime === 'number' ? preHealth.uptime : 0;
+ await captureBaseline(s?.currentVersion);
setUpdating(true);
setSteps([]);
setUpdateError(null);
- const result = await api.executePortosUpdate(opts).catch(err => {
+ return api.executePortosUpdate(opts).catch(err => {
setUpdateError(err.message);
- updatingRef.current = false;
setUpdating(false);
return null;
});
- if (result?.tag) {
- targetVersionRef.current = result.tag.replace(/^v/, '');
- }
- return result;
- }, [status]);
+ }, [captureBaseline, status]);
const handleUpdate = () => runUpdate();
diff --git a/client/src/components/brain/links/LinkChip.jsx b/client/src/components/brain/links/LinkChip.jsx
index d341d93c28..96de252b35 100644
--- a/client/src/components/brain/links/LinkChip.jsx
+++ b/client/src/components/brain/links/LinkChip.jsx
@@ -49,7 +49,7 @@ export default function LinkChip({ link, onRemove, draggable }) {
{onRemove && (
onRemove(link)}
- className="shrink-0 p-0.5 text-gray-600 hover:text-port-error opacity-40 sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
+ className="min-h-[44px] min-w-[44px] inline-flex items-center justify-center shrink-0 p-0.5 text-gray-600 hover:text-port-error opacity-40 sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
title="Remove from bucket" aria-label="Remove from bucket"
>
diff --git a/client/src/components/brain/tabs/InboxTab.jsx b/client/src/components/brain/tabs/InboxTab.jsx
index 0145b278c9..30892d0d8e 100644
--- a/client/src/components/brain/tabs/InboxTab.jsx
+++ b/client/src/components/brain/tabs/InboxTab.jsx
@@ -424,7 +424,7 @@ export default function InboxTab({ onRefresh, settings }) {
{ fetchInbox(); onRefresh?.(); }}
- className="p-1 text-gray-400 hover:text-white transition-colors"
+ className="min-h-[44px] min-w-[44px] inline-flex items-center justify-center p-1 text-gray-400 hover:text-white transition-colors"
title="Refresh inbox"
aria-label="Refresh inbox"
>
@@ -471,7 +471,7 @@ export default function InboxTab({ onRefresh, settings }) {
rows={3}
autoFocus
/>
-
onTrigger(job.id)}
disabled={editing || triggering}
- className={`p-1.5 transition-colors text-gray-500 ${editing || triggering ? 'opacity-50 cursor-not-allowed' : 'hover:text-port-accent'}`}
+ className={`min-h-[44px] min-w-[44px] inline-flex items-center justify-center p-1.5 transition-colors text-gray-500 ${editing || triggering ? 'opacity-50 cursor-not-allowed' : 'hover:text-port-accent'}`}
title={editing ? 'Save changes before running job' : triggering ? 'Triggering job' : 'Run now'}
aria-label={editing ? 'Save changes before running job' : triggering ? 'Triggering job' : 'Run now'}
>
@@ -396,7 +396,7 @@ export default function JobCard({
@@ -404,7 +404,7 @@ export default function JobCard({
setExpanded(!expanded)}
- className="p-1.5 text-gray-500 hover:text-white transition-colors"
+ className="min-h-[44px] min-w-[44px] inline-flex items-center justify-center p-1.5 text-gray-500 hover:text-white transition-colors"
title={expanded ? 'Collapse' : 'Expand'}
aria-label={expanded ? 'Collapse' : 'Expand'}
>
diff --git a/client/src/components/cos/MiniCharacterCoSAvatar.jsx b/client/src/components/cos/MiniCharacterCoSAvatar.jsx
index c9ea032a81..f78db6654e 100644
--- a/client/src/components/cos/MiniCharacterCoSAvatar.jsx
+++ b/client/src/components/cos/MiniCharacterCoSAvatar.jsx
@@ -5,6 +5,7 @@ import CoSAvatarOrbitControls from './CoSAvatarOrbitControls';
import CoSBackgroundCamera from './CoSBackgroundCamera';
import CoSCanvasGuard from './CoSCanvasGuard';
import useClonedGltf, { GltfPrimitive } from '../../hooks/useClonedGltf';
+import { resolvePlaybackClip } from '../../hooks/useAvatarCapabilities';
import { fitModelToHeight } from '../../utils/modelFit';
// Kenney Mini Characters (CC0) ship 32 named clips. We map the CoS agent
@@ -39,7 +40,7 @@ function buildModelUrl(variant) {
return variant ? `/api/avatar/model.glb?variant=${encodeURIComponent(variant)}` : '/api/avatar/model.glb';
}
-function MiniCharacter({ state, speaking, variant }) {
+function MiniCharacter({ state, speaking, variant, coverage = null }) {
const url = useMemo(() => buildModelUrl(variant), [variant]);
const group = useRef();
const { scene, actions, names } = useClonedGltf(url);
@@ -52,13 +53,14 @@ function MiniCharacter({ state, speaking, variant }) {
fitModelToHeight(scene, { targetHeight: TARGET_HEIGHT, feetOnGround: true, yOffset: GROUND_Y });
}, [scene]);
- // Resolve the active clip for this state, falling back gracefully.
+ // Resolve the active clip for this state, falling back gracefully. With a
+ // coverage report (a rigged record, #5894) the covered clip wins when the
+ // GLB carries it, else playback degrades to a clip the character actually
+ // has — an uncovered state never freezes the frame or pretends coverage.
const cfg = STATE_CLIP_MAP[state] || FALLBACK;
- const clipName = useMemo(() => {
- if (names.includes(cfg.clip)) return cfg.clip;
- if (names.includes('idle')) return 'idle';
- return names[0];
- }, [names, cfg.clip]);
+ const clipName = useMemo(() => (
+ resolvePlaybackClip(names, { state, coverage, fallbacks: [cfg.clip, 'idle'] })
+ ), [names, state, coverage, cfg.clip]);
// Crossfade between clips on state change.
const prevClip = useRef(null);
@@ -107,14 +109,14 @@ function StageLighting({ color }) {
);
}
-function Scene({ state, speaking, background, variant }) {
+function Scene({ state, speaking, background, variant, coverage = null }) {
const stateConfig = AGENT_STATES[state] || AGENT_STATES.sleeping;
const color = stateConfig.color;
return (
<>
-
+
>
);
@@ -148,8 +150,10 @@ function LoadingPlaceholder({ background = false }) {
);
}
-// `variant` is wired by the per-character style wrappers (MiniCharMaleC, etc.).
-export default function MiniCharacterCoSAvatar({ state, speaking, background = false, variant = 'mini-male-c' }) {
+// `variant` is wired by the per-character style wrappers (MiniCharMaleC, etc.)
+// or a `rigged-` spelling for an animated record (ChiefOfStaff passes
+// that record's coverage so playback falls back to a present clip).
+export default function MiniCharacterCoSAvatar({ state, speaking, background = false, variant = 'mini-male-c', coverage = null }) {
const [modelPresent, setModelPresent] = useState(null);
const url = useMemo(() => buildModelUrl(variant), [variant]);
@@ -177,7 +181,7 @@ export default function MiniCharacterCoSAvatar({ state, speaking, background = f
gl={{ alpha: true, antialias: true }}
>
-
+
diff --git a/client/src/components/cos/MiniCharacterCoSAvatar.test.jsx b/client/src/components/cos/MiniCharacterCoSAvatar.test.jsx
index f20e9343f7..f3e3623d98 100644
--- a/client/src/components/cos/MiniCharacterCoSAvatar.test.jsx
+++ b/client/src/components/cos/MiniCharacterCoSAvatar.test.jsx
@@ -48,4 +48,15 @@ describe('MiniCharacterCoSAvatar', () => {
'/api/avatar/model.glb?variant=mini-female-d',
));
});
+
+ // A rigged record probes through the same variant namespace — the avatar
+ // route resolves `rigged-` to the record's animated GLB, so the
+ // stage needs no special case for record-backed characters.
+ it('keys the canvas guard on the rigged variant url for an animated record', async () => {
+ render();
+ await waitFor(() => expect(screen.getByTestId('canvas-guard')).toHaveAttribute(
+ 'data-reset-key',
+ '/api/avatar/model.glb?variant=rigged-image3d-1',
+ ));
+ });
});
diff --git a/client/src/components/cos/ReviewerPicker.jsx b/client/src/components/cos/ReviewerPicker.jsx
index b3672f02b7..118f10a6d1 100644
--- a/client/src/components/cos/ReviewerPicker.jsx
+++ b/client/src/components/cos/ReviewerPicker.jsx
@@ -65,10 +65,10 @@ const CUSTOM_MODEL_OPTION = '[custom]';
*
* `modelOptions` is the resolved model-picker data, shaped like
* `useReviewerModelOptions()`'s return: `{ optionsByReviewer, defaultModels,
- * freeText, unavailable, loaded }`. Callers keep owning their own
- * `api.getLocalLlmStatus` / `api.getProviders` fetches (that's what the hook is
- * for) — passing nothing degrades every Model cell to a free-text input, which is
- * still fully usable, rather than hiding the column.
+ * freeText, unavailable, providerDisabled, loaded }`. Callers keep owning their
+ * own `api.getLocalLlmStatus` / `api.getProviders` fetches (that's what the hook
+ * is for) — passing nothing degrades every Model cell to a free-text input, which
+ * is still fully usable, rather than hiding the column.
*
* `showRunFlags={false}` hides the stop-mode select and the "reviewer applies
* fixes" checkbox for surfaces that can't honor them — the `/do:next` claim
@@ -79,11 +79,16 @@ const CUSTOM_MODEL_OPTION = '[custom]';
* `installed` is a per-reviewer-slug install probe from the Code Review
* Defaults endpoint (`GET /api/code-review/defaults`'s `installed` field,
* #3606) — `{ claude: true, antigravity: false, ... }`. Only an explicit
- * `false` renders a "not installed" badge; `undefined` (not a CLI reviewer,
- * or the caller didn't fetch it) renders nothing. Warn-only: a reviewer stays
- * selectable and selected even when flagged not-installed, since the CLI
- * check is local-machine-only and a federated peer (or a later install) may
- * satisfy it.
+ * `false` counts as missing; `undefined` (not a CLI reviewer, or the caller
+ * didn't fetch it) says nothing.
+ *
+ * Together with `modelOptions.providerDisabled`, that decides which reviewers
+ * the **Add** row offers up front: one whose CLI is missing here, or whose
+ * provider records are all switched off, is folded behind a `+N unavailable`
+ * toggle. Warn-only either way — the toggle reveals them with a badge and they
+ * stay selectable, and an ALREADY-SELECTED reviewer always renders its row
+ * (badged), since both checks are local-machine-only and the reviewer list is
+ * federation-wide config a peer may satisfy.
*/
export default function ReviewerPicker({
reviewers = [],
@@ -108,6 +113,9 @@ export default function ReviewerPicker({
// pin maps use. Purely presentational — nothing is stored until an id is typed,
// so this never has to round-trip through `onChange`.
const [customModelTokens, setCustomModelTokens] = useState(() => new Set());
+ // Whether the Add row also lists the reviewers this machine can't run (see
+ // `hiddenAddable`). Presentational only — nothing about it is stored.
+ const [showUnavailable, setShowUnavailable] = useState(false);
const isCustomModel = (token) => customModelTokens.has(token.toLowerCase());
const setCustomModel = (token, on) => setCustomModelTokens((prev) => {
const next = new Set(prev);
@@ -122,7 +130,7 @@ export default function ReviewerPicker({
// the active provider's own reviewer (falling back to copilot when that
// provider maps to none) — see `codeReviewDefaultsFromProvider`.
const selected = Array.isArray(reviewers) ? [...new Set(reviewers.map(normalizeReviewerValue))] : [];
- const available = REVIEWER_OPTIONS.filter(o => !selected.includes(o.value));
+ const addable = REVIEWER_OPTIONS.filter(o => !selected.includes(o.value));
const hasNonCopilot = selected.some(r => r !== 'copilot');
const selectedUsernames = normalizeReviewUsernames(usernames);
const atMaxUsernames = selectedUsernames.length >= MAX_REVIEW_USERNAMES;
@@ -165,20 +173,53 @@ export default function ReviewerPicker({
// normally does", so clearing the select DELETES the key rather than writing `''`.
const effortsMap = asMap(reviewerEfforts);
const efforts = keyedLookup(effortsMap);
- // Only an explicit `false` counts — `undefined` covers both "not a CLI
- // reviewer" (copilot/lmstudio/ollama/@username) and "caller didn't fetch
- // `installed`", neither of which should render a warning badge.
- const notInstalled = (token) => installed?.[token] === false;
- const renderInstalledBadge = (token) => notInstalled(token) && (
-
- not installed
-
- );
+ // Why this reviewer can't run here, or null when nothing says it can't.
+ //
+ // Two independent signals, both warn-only and both reported only when the
+ // caller actually fetched them — a reviewer stays selectable and selected
+ // either way, since the checks are local-machine-only and a federated peer
+ // (or a later install / a flip in Settings) may satisfy them:
+ //
+ // - `installed[token] === false` — the CLI binary isn't on PATH. Only an
+ // explicit `false` counts; `undefined` covers both "not a CLI reviewer"
+ // (copilot/@username) and "caller didn't fetch `installed`".
+ // - `providerDisabled[token]` — every provider record fronting that binary is
+ // switched off on this install, so the user has said they don't use it. A
+ // `/api/providers` that failed or hasn't landed reports nothing (see the
+ // hook), so this never fires on a slow page.
+ const unavailability = (token) => {
+ if (installed?.[token] === false) {
+ return {
+ label: 'not installed',
+ title: `${reviewerLabel(token)}'s CLI binary wasn't found on this machine. It still runs (federation-wide config), but the review loop here will report it unsatisfied until it's installed.`
+ };
+ }
+ if (modelOptions?.providerDisabled?.[token]) {
+ return {
+ label: 'disabled',
+ title: `${reviewerLabel(token)}'s provider records are all switched off in Settings → AI Providers, so this machine isn't set up to use it. Adding it still works — the review loop spawns its CLI directly, and a federated peer may have it enabled.`
+ };
+ }
+ return null;
+ };
+ const renderUnavailableBadge = (token) => {
+ const reason = unavailability(token);
+ return reason && (
+
+ {reason.label}
+
+ );
+ };
+ // The Add row lists what this machine can actually run, so a reviewer whose
+ // CLI is missing or whose providers are all switched off is folded behind a
+ // count instead of padding the row with things the review loop would report
+ // unsatisfied. HIDDEN, not dropped: the checks are local-machine-only and the
+ // reviewer list is federation-wide config, so the toggle reveals them (badged)
+ // rather than making a peer's reviewer unconfigurable from here.
+ const hiddenAddable = addable.filter(opt => unavailability(opt.value));
+ const addOptions = showUnavailable
+ ? addable
+ : addable.filter(opt => !hiddenAddable.includes(opt));
const emit = (next) => onChange?.({
reviewers: selected,
@@ -586,7 +627,7 @@ export default function ReviewerPicker({
{reviewerLabel(value)}
- {renderInstalledBadge(value)}
+ {renderUnavailableBadge(value)}
Model
+ );
+}
+
export default function AgentCard({ agent, onPause, onKill, onDelete, onResume, onRelaunch, completed, paused = false, liveOutput, durations, onFeedbackChange, remote, peerName }) {
const [expanded, setExpanded] = useState(false);
const [now, setNow] = useState(Date.now());
@@ -329,6 +377,43 @@ export default function AgentCard({ agent, onPause, onKill, onDelete, onResume,
), [inactive, fullOutput, liveOutput, agent.output]);
const lastOutput = output.length > 0 ? output[output.length - 1]?.line : null;
+ // Why this run has NO "Open Shell" link. Scoped to the one case where the user
+ // configured a TUI provider and got no shell anyway: a public-review stage
+ // runs headless unless it is the sandboxed-actions stage on a provider whose
+ // vendor declares an attachable recipe. An ordinary headless CLI agent gets no
+ // chip — nobody expected a shell there, and one on every card would be the
+ // noise this exists to remove.
+ //
+ // `executionMode` is the authority on which way the run actually spawned, and
+ // it is stamped at registration while `tuiSessionId` only lands once the PTY
+ // attaches. An ATTACHABLE public-review stage therefore passes through a
+ // window where the session id is still null — without this second condition
+ // the card would spend that window asserting the stage runs headless, then
+ // silently swap the claim for an "Open Shell" link (same reason the ordinary
+ // TUI case is excluded below).
+ const noShellReason = !agent.metadata?.tuiSessionId
+ && agent.metadata?.executionMode !== 'tui'
+ && agent.metadata?.publicReviewPosture
+ ? 'No shell: this public-review stage runs headless — either it is a tool-free reasoning stage, or its provider has no attachable sandbox recipe — so the screened PR content stays inside the sandboxed child. Watch the live output below to see what it is doing.'
+ : null;
+
+ // Why this run can be silent for minutes and still be perfectly healthy: its
+ // prompt is large and its model server is on this machine, so the whole
+ // prefill happens before the child emits its first line (#6117). Only shown
+ // when the server actually stamped a long-prefill budget — an absent stamp is
+ // "no estimate" (a cloud run, or a pre-upgrade record), never "instant".
+ const prefillBudget = agent.metadata?.localPromptBudget?.longPrefill
+ && Number.isFinite(agent.metadata.localPromptBudget.prefillMs)
+ ? agent.metadata.localPromptBudget
+ : null;
+ const prefillLabel = prefillBudget ? formatDurationMs(prefillBudget.prefillMs) : null;
+ const prefillReason = prefillBudget
+ ? `Large prompt (~${(prefillBudget.promptTokens ?? 0).toLocaleString()} tokens) on a local model server — expect roughly ${prefillLabel} of silent prefill before the first line of output. The run is working, not wedged.${
+ prefillBudget.expectedDurationMs
+ ? ` Its duration estimate was raised to ~${formatDurationMs(prefillBudget.expectedDurationMs)} to cover it.`
+ : ''}`
+ : null;
+
// Extract recent tool activity (last few tool lines) for live display
const recentActivity = useMemo(() => {
if (inactive || output.length === 0) return [];
@@ -410,7 +495,7 @@ export default function AgentCard({ agent, onPause, onKill, onDelete, onResume,
event.stopPropagation();
copyToClipboard(agent.id, 'Agent ID copied to clipboard');
}}
- className="p-1 rounded text-gray-500 hover:bg-port-border/60 hover:text-white transition-colors shrink-0"
+ className="min-h-[44px] min-w-[44px] inline-flex items-center justify-center p-1 rounded text-gray-500 hover:bg-port-border/60 hover:text-white transition-colors shrink-0"
title="Copy agent ID"
aria-label="Copy agent ID"
>
@@ -544,7 +629,7 @@ export default function AgentCard({ agent, onPause, onKill, onDelete, onResume,
) : (
requestDelete(agent.id)}
- className="p-1 text-gray-500 hover:text-port-error transition-colors"
+ className="min-h-[44px] min-w-[44px] inline-flex items-center justify-center p-1 text-gray-500 hover:text-port-error transition-colors"
aria-label="Remove agent"
>
@@ -616,6 +701,24 @@ export default function AgentCard({ agent, onPause, onKill, onDelete, onResume,
{agent.metadata.tuiSessionId.slice(0, 6)}
)}
+ {!inactive && noShellReason && (
+
+
+ No shell
+
+ )}
+ {!inactive && prefillReason && (
+
+
+ Long prefill ~{prefillLabel}
+
+ )}
{!remote && (
{sendingBtw ? : }
BTW
@@ -851,6 +955,8 @@ export default function AgentCard({ agent, onPause, onKill, onDelete, onResume,
@@ -879,7 +985,7 @@ export default function AgentCard({ agent, onPause, onKill, onDelete, onResume,
submitFeedback('positive')}
disabled={submittingFeedback}
- className={`p-1.5 rounded transition-colors min-w-[32px] min-h-[32px] flex items-center justify-center ${
+ className={`min-h-[44px] min-w-[44px] flex items-center justify-center p-1.5 rounded transition-colors ${
feedbackState === 'positive'
? 'bg-port-success/30 text-port-success'
: 'text-gray-500 hover:text-port-success hover:bg-port-success/10'
@@ -893,7 +999,7 @@ export default function AgentCard({ agent, onPause, onKill, onDelete, onResume,
submitFeedback('negative')}
disabled={submittingFeedback}
- className={`p-1.5 rounded transition-colors min-w-[32px] min-h-[32px] flex items-center justify-center ${
+ className={`min-h-[44px] min-w-[44px] flex items-center justify-center p-1.5 rounded transition-colors ${
feedbackState === 'negative'
? 'bg-port-error/30 text-port-error'
: 'text-gray-500 hover:text-port-error hover:bg-port-error/10'
@@ -906,7 +1012,7 @@ export default function AgentCard({ agent, onPause, onKill, onDelete, onResume,
setShowFeedbackComment(!showFeedbackComment)}
- className="p-1.5 rounded text-gray-500 hover:text-white hover:bg-port-border/50 transition-colors min-w-[32px] min-h-[32px] flex items-center justify-center"
+ className="min-h-[44px] min-w-[44px] flex items-center justify-center p-1.5 rounded text-gray-500 hover:text-white hover:bg-port-border/50 transition-colors"
title={feedbackState ? 'Add feedback detail' : 'Add feedback comment'}
aria-label={feedbackState ? 'Add feedback detail' : 'Add feedback comment'}
aria-expanded={showFeedbackComment}
diff --git a/client/src/components/cos/tabs/AgentCard.test.jsx b/client/src/components/cos/tabs/AgentCard.test.jsx
index b7a0bb282d..212d2b8c5e 100644
--- a/client/src/components/cos/tabs/AgentCard.test.jsx
+++ b/client/src/components/cos/tabs/AgentCard.test.jsx
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { render, screen, waitFor } from '@testing-library/react';
+import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router';
import { typeSettled } from '../../../test/settledInput';
@@ -368,3 +368,158 @@ describe('AgentCard task description (#4170)', () => {
expect(screen.queryByRole('button', { name: /Show more/ })).not.toBeInTheDocument();
});
});
+
+describe('AgentCard missing shell explanation', () => {
+ const running = (metadata) => ({
+ ...agent,
+ status: 'running',
+ completedAt: null,
+ metadata: { ...agent.metadata, ...metadata },
+ });
+
+ const renderCard = (a) => render(
+
+
+
+ );
+
+ it('says why a public-review stage has no shell instead of leaving the card silent', () => {
+ // A tool-free stage — or an actions stage on a provider with no attachable
+ // sandbox recipe — runs headless even on a TUI provider, so the "Open
+ // Shell" link never appears. Silence made a slow run look wedged with
+ // nowhere to look.
+ renderCard(running({ publicReviewPosture: 'sandboxed-actions', executionMode: 'direct' }));
+
+ expect(screen.queryByText('Open Shell')).not.toBeInTheDocument();
+ expect(screen.getByText('No shell')).toBeInTheDocument();
+ expect(screen.getByTitle(/this public-review stage runs headless/)).toBeInTheDocument();
+ });
+
+ it('keeps the shell link on an attachable sandboxed-actions run', () => {
+ // Stage 3 on a TUI provider whose vendor declares an attachable recipe DOES
+ // get a PTY (#6062) — the chip above must not fire on it, or the card would
+ // claim there is no shell while linking to one.
+ renderCard(running({
+ publicReviewPosture: 'sandboxed-actions',
+ executionMode: 'tui',
+ tuiSessionId: 'sess-6062',
+ }));
+
+ expect(screen.queryByText('No shell')).not.toBeInTheDocument();
+ });
+
+ it('stays silent on an attachable public-review run that has not registered its session yet', () => {
+ // The startup window an attachable Stage 3 now passes through: `executionMode`
+ // is already 'tui' but `tuiSessionId` has not landed. Gating on the session id
+ // alone made the card assert the stage runs headless for those seconds, then
+ // swap the claim for an "Open Shell" link — a false diagnostic, and a worse
+ // one if the PTY is merely slow to attach.
+ renderCard(running({
+ publicReviewPosture: 'sandboxed-actions',
+ executionMode: 'tui',
+ phase: 'initializing',
+ }));
+
+ expect(screen.queryByText('No shell')).not.toBeInTheDocument();
+ });
+
+ it('stays silent on a TUI run that has not registered its session yet', () => {
+ // `executionMode` is stamped at registration but `tuiSessionId` only lands
+ // once the PTY attaches, so every healthy TUI spawn passes through this
+ // state — a chip here would put the "looks wedged" noise straight back.
+ renderCard(running({ executionMode: 'tui', phase: 'initializing' }));
+
+ expect(screen.queryByText('No shell')).not.toBeInTheDocument();
+ });
+
+ it('adds no chip to an ordinary headless CLI agent — none was ever expected', () => {
+ renderCard(running({ executionMode: 'direct' }));
+
+ expect(screen.queryByText('No shell')).not.toBeInTheDocument();
+ });
+
+ it('links to the live shell, with no explanation chip, once a session exists', () => {
+ renderCard(running({ executionMode: 'tui', tuiSessionId: 'sess-abcdef123' }));
+
+ expect(screen.getByText('Open Shell')).toBeInTheDocument();
+ expect(screen.queryByText('No shell')).not.toBeInTheDocument();
+ });
+
+ // #6117: a ~100K-token public-review envelope aimed at a model server on this
+ // box spends minutes in prefill before the child emits its first line. The
+ // card showed a running agent with no output and no reason for it.
+ it('explains a long silent prefill and names the raised run estimate', () => {
+ renderCard(running({
+ executionMode: 'direct',
+ localPromptBudget: {
+ endpoint: 'localhost:18020',
+ promptTokens: 100_000,
+ prefillMs: 9 * 60_000,
+ baseDurationMs: 13 * 60_000,
+ expectedDurationMs: 22 * 60_000,
+ longPrefill: true,
+ },
+ }));
+
+ expect(screen.getByText(/Long prefill/)).toBeInTheDocument();
+ expect(screen.getByTitle(/100,000 tokens/)).toBeInTheDocument();
+ expect(screen.getByTitle(/duration estimate was raised/)).toBeInTheDocument();
+ });
+
+ it('stays silent when the prefill is short or the run is not on a local endpoint', () => {
+ // An absent budget is "no estimate" (a cloud run, or a record written before
+ // the stamp existed) — never "instant". Neither may render the chip.
+ renderCard(running({
+ executionMode: 'direct',
+ localPromptBudget: { endpoint: 'localhost:11434', promptTokens: 2_000, prefillMs: 16_667, longPrefill: false },
+ }));
+ expect(screen.queryByText(/Long prefill/)).not.toBeInTheDocument();
+
+ cleanup();
+ renderCard(running({ executionMode: 'direct' }));
+ expect(screen.queryByText(/Long prefill/)).not.toBeInTheDocument();
+ });
+});
+
+// #5994: the goal-fidelity verdict — whether the run built what the task asked
+// for, which no quality reviewer can answer because none of them see the request.
+describe('AgentCard goal fidelity', () => {
+ const withReview = (goalFidelity) => ({ ...agent, result: { ...agent.result, goalFidelity } });
+
+ it('names the missing and unrequested work behind a rethink verdict', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText(/Does not deliver the objective/)).toBeInTheDocument();
+ expect(screen.getByText('the retry backoff')).toBeInTheDocument();
+ expect(screen.getByText('an unrelated logging refactor')).toBeInTheDocument();
+ expect(screen.getByText('no tests were run')).toBeInTheDocument();
+ });
+
+ it('shows a clean ship verdict, and renders nothing at all for a run the gate never judged', () => {
+ const { unmount } = render(
+
+
+
+ );
+ expect(screen.getByText(/Delivers the objective/)).toBeInTheDocument();
+ unmount();
+
+ render(
+
+
+
+ );
+ expect(screen.queryByText(/Goal fidelity/)).not.toBeInTheDocument();
+ });
+});
+
diff --git a/client/src/components/cos/tabs/AgentsTab.jsx b/client/src/components/cos/tabs/AgentsTab.jsx
index d03645e10f..a9658699f6 100644
--- a/client/src/components/cos/tabs/AgentsTab.jsx
+++ b/client/src/components/cos/tabs/AgentsTab.jsx
@@ -8,16 +8,26 @@ import ResumeAgentModal from './ResumeAgentModal';
import RelaunchAgentModal from './RelaunchAgentModal';
import BrailleSpinner from '../../BrailleSpinner';
import InlineConfirmRow from '../../ui/InlineConfirmRow';
+import { agentResumeMessage } from '../../../lib/agentResumeOutcome';
// What each `resumeAgent` outcome actually did (server modes, agentManagement.js).
// `already-active` and `superseded` deliberately queue NOTHING — the task is already
-// in flight, or a later pause owns it — so an unmapped mode must NOT fall through to
-// "created a resume task". The server's `created` flag decides that (see below); this
-// map only supplies the specific wording.
+// in flight, or a later pause owns it — so they carry no `running` wording, and an
+// unmapped mode must NOT fall through to "created a resume task". The server's
+// `created` flag decides that (see below); this map only supplies the specific
+// wording. `requeued` has both variants because the server force-spawns the resumed
+// task when a slot is free — see `agentResumeMessage` for that contract.
const RESUME_MESSAGES = {
- requeued: 'Resumed — the paused task is queued on its preserved worktree',
- 'already-active': 'Its task is already queued or running — nothing new was created',
- superseded: 'A later agent now holds this task paused — that pause was left intact',
+ requeued: {
+ queued: 'Resumed — the paused task is queued on its preserved worktree',
+ running: 'Resumed — the paused task is running again on its preserved worktree',
+ },
+ 'new-task': {
+ queued: 'Resumed — a replacement task is queued',
+ running: 'Resumed — a replacement task is running',
+ },
+ 'already-active': { queued: 'Its task is already queued or running — nothing new was created' },
+ superseded: { queued: 'A later agent now holds this task paused — that pause was left intact' },
};
// Only agents from a manually-filled task form ask for a rating — scheduled/
@@ -29,7 +39,7 @@ const needsAgentFeedback = (agent) => {
return !isSystemAgent && isManualUserAgent && !agent.feedback?.rating;
};
-export default function AgentsTab({ agents, onRefresh, liveOutputs, providers, apps }) {
+export default function AgentsTab({ agents, onRefresh, liveOutputs, providers, providersLoaded, apps }) {
const [searchParams, setSearchParams] = useSearchParams();
const [resumingAgent, setResumingAgent] = useState(null);
const [relaunchingAgent, setRelaunchingAgent] = useState(null);
@@ -165,8 +175,8 @@ export default function AgentsTab({ agents, onRefresh, liveOutputs, providers, a
// A resume that created nothing (`created: false`) never claims it did, even for
// a mode this build has no wording for — the completed-agent branch above has no
// `created` field at all and did queue a task, so it keeps the default.
- toast.success(RESUME_MESSAGES[result.mode]
- || (result.created === false ? 'Resumed — nothing new was queued' : `Created ${type === 'internal' ? 'system ' : ''}resume task`));
+ toast.success(agentResumeMessage(result, RESUME_MESSAGES,
+ result.created === false ? 'Resumed — nothing new was queued' : `Created ${type === 'internal' ? 'system ' : ''}resume task`));
setResumingAgent(null);
onRefresh();
};
@@ -425,6 +435,7 @@ export default function AgentsTab({ agents, onRefresh, liveOutputs, providers, a
setRelaunchingAgent(null)}
@@ -437,6 +448,7 @@ export default function AgentsTab({ agents, onRefresh, liveOutputs, providers, a
agent={resumingAgent}
taskType={resumingAgent.taskId?.startsWith('sys-') || resumingAgent.metadata?.taskType === 'internal' ? 'internal' : 'user'}
providers={providers}
+ providersLoaded={providersLoaded}
apps={apps}
onSubmit={handleResumeSubmit}
onClose={() => setResumingAgent(null)}
diff --git a/client/src/components/cos/tabs/AgentsTab.test.jsx b/client/src/components/cos/tabs/AgentsTab.test.jsx
index ce4c6c6f2b..80e3efc9e4 100644
--- a/client/src/components/cos/tabs/AgentsTab.test.jsx
+++ b/client/src/components/cos/tabs/AgentsTab.test.jsx
@@ -191,6 +191,52 @@ describe('AgentsTab resume routing', () => {
expect(toast.success).not.toHaveBeenCalledWith(expect.stringMatching(/resume task/i));
});
+ // The server force-spawns the resumed task when a slot is free, so "queued" is the
+ // exception, not the rule — and a "queued" toast for a run that already started
+ // reads as the Resume click not having taken.
+ it('says the resumed task is running when the server started it', async () => {
+ const user = userEvent.setup();
+ api.resumeCosAgent.mockResolvedValue({ success: true, taskId: 'task-abc', mode: 'requeued', spawned: true });
+ renderTab([pausedAgent]);
+ await act(async () => {});
+
+ await user.click(screen.getByRole('button', { name: 'Resume agent-paused' }));
+ await user.click(screen.getByRole('button', { name: 'Submit resume' }));
+
+ await waitFor(() => expect(toast.success).toHaveBeenCalledWith(expect.stringMatching(/running again/i)));
+ });
+
+ it('names why a resumed task stayed queued instead of leaving the user to hunt for it', async () => {
+ const user = userEvent.setup();
+ api.resumeCosAgent.mockResolvedValue({
+ success: true, taskId: 'task-abc', mode: 'requeued',
+ spawned: false, spawnHold: 'No available agent slots (3/3)',
+ });
+ renderTab([pausedAgent]);
+ await act(async () => {});
+
+ await user.click(screen.getByRole('button', { name: 'Resume agent-paused' }));
+ await user.click(screen.getByRole('button', { name: 'Submit resume' }));
+
+ await waitFor(() => expect(toast.success).toHaveBeenCalledWith(expect.stringMatching(/No available agent slots \(3\/3\)/)));
+ });
+
+ // `new-task` is the mode where the paused task was gone, so a REPLACEMENT was
+ // queued — and it is force-spawned like any other. Without its own entry it fell
+ // through to the generic "Created resume task", which says nothing about whether
+ // the replacement actually started.
+ it('says a replacement task is running when the server started that too', async () => {
+ const user = userEvent.setup();
+ api.resumeCosAgent.mockResolvedValue({ success: true, taskId: 'task-new', mode: 'new-task', created: true, spawned: true });
+ renderTab([pausedAgent]);
+ await act(async () => {});
+
+ await user.click(screen.getByRole('button', { name: 'Resume agent-paused' }));
+ await user.click(screen.getByRole('button', { name: 'Submit resume' }));
+
+ await waitFor(() => expect(toast.success).toHaveBeenCalledWith(expect.stringMatching(/replacement task is running/i)));
+ });
+
// The default has to be safe by construction, not by keeping a copy of the server's
// mode enum in sync — a future non-creating mode this build has no wording for must
// not regress to announcing a task that was never queued.
diff --git a/client/src/components/cos/tabs/ConfigTab.jsx b/client/src/components/cos/tabs/ConfigTab.jsx
index aab5c6ce58..8200c38338 100644
--- a/client/src/components/cos/tabs/ConfigTab.jsx
+++ b/client/src/components/cos/tabs/ConfigTab.jsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import {
Activity,
@@ -32,6 +32,7 @@ import {
} from '../constants';
import ProviderModelSelector from '../../ProviderModelSelector';
import useProviderModels from '../../../hooks/useProviderModels';
+import { coverageSummary, isRiggedAvatarStyle, riggedRecordForStyle } from '../../../hooks/useAvatarCapabilities';
import { timeAgo } from '../../../utils/formatters';
const DOMAIN_MODE_COLORS = {
@@ -222,7 +223,7 @@ function PersistentMindStatus({ mind, loaded, error }) {
);
}
-export default function ConfigTab({ config, onUpdate, onEvaluate, avatarStyle }) {
+export default function ConfigTab({ config, onUpdate, onEvaluate, avatarStyle, riggedAvatars = [] }) {
const {
providers,
availableModels,
@@ -273,6 +274,37 @@ export default function ConfigTab({ config, onUpdate, onEvaluate, avatarStyle })
useEffect(() => { void refreshBudgetUsage(); }, [refreshBudgetUsage]);
useAutoRefetch(refreshMindStatus, 15_000, { pollOnly: true });
+ // Built-in styles plus the install's verified animated records (#5894). A
+ // record entry carries its state coverage in the label, so what the
+ // character can and cannot do is visible BEFORE it is picked.
+ const avatarOptions = useMemo(() => ([
+ ...Object.entries(AVATAR_STYLE_LABELS).map(([value, label]) => ({ value, label })),
+ ...(Array.isArray(riggedAvatars) ? riggedAvatars : [])
+ .filter((record) => record?.variant)
+ .map((record) => ({
+ value: record.variant,
+ label: `${record.name} (rigged 3D) — ${coverageSummary(record.coverage)}`,
+ })),
+ ]), [riggedAvatars]);
+
+ const avatarLabel = (style) => {
+ if (AVATAR_STYLE_LABELS[style]) return AVATAR_STYLE_LABELS[style];
+ const record = riggedRecordForStyle(riggedAvatars, style);
+ return record ? `${record.name} (rigged 3D)` : style;
+ };
+
+ // Honest coverage note for the staged value: which states the character
+ // covers, what the rest fall back to — or a warning when the record the
+ // saved style points at is gone.
+ const stagedRiggedNote = useMemo(() => {
+ if (!isRiggedAvatarStyle(formData.avatarStyle)) return null;
+ const record = riggedRecordForStyle(riggedAvatars, formData.avatarStyle);
+ if (!record) return 'That animated record is no longer available — pick another avatar.';
+ const covered = record.coverage?.coveredStates || [];
+ const fallback = record.clip ? `Other states play ${record.clip}.` : '';
+ return `${coverageSummary(record.coverage)}. Covered: ${covered.join(', ') || 'none'}. ${fallback}`.trim();
+ }, [formData.avatarStyle, riggedAvatars]);
+
const handleCancel = () => {
setFormData(getDefaultFormData(config, avatarStyle));
setEditing(false);
@@ -434,9 +466,12 @@ export default function ConfigTab({ config, onUpdate, onEvaluate, avatarStyle })
diff --git a/client/src/components/cos/tabs/RelaunchAgentModal.jsx b/client/src/components/cos/tabs/RelaunchAgentModal.jsx
index 842834b40b..d5041a0f32 100644
--- a/client/src/components/cos/tabs/RelaunchAgentModal.jsx
+++ b/client/src/components/cos/tabs/RelaunchAgentModal.jsx
@@ -6,17 +6,28 @@ import Modal from '../../ui/Modal';
import AppContextPicker from '../../AppContextPicker';
import ProviderModelSelector from '../../ProviderModelSelector';
import { FormField } from '../../ui/FormField';
+import CollapsibleText from '../../ui/CollapsibleText';
import { useAsyncAction } from '../../../hooks/useAsyncAction';
import { effortAwareModelOptions, seedModelEffort } from '../../../utils/providers';
+import { agentResumeMessage } from '../../../lib/agentResumeOutcome';
// What each relaunch outcome actually did. The server reuses `resumeAgent`'s
-// modes (agentManagement.js): only `requeued` restarts the work — `already-active`
-// and `superseded` deliberately queue NOTHING, so an unmapped mode must not fall
-// through to a message claiming the task was relaunched.
+// modes (agentManagement.js): only `requeued` and `new-task` put work back on the
+// queue — `already-active` and `superseded` deliberately queue NOTHING, so those
+// carry no `running` wording and an unmapped mode falls through to the plain
+// fallback rather than a message claiming the task was relaunched. See
+// `agentResumeMessage` for the queued-vs-running contract.
const RELAUNCH_MESSAGES = {
- requeued: 'Relaunched — the task is queued again on its preserved worktree',
- 'already-active': 'Its task is already queued or running — nothing new was created',
- superseded: 'A later agent now holds this task paused — that pause was left intact',
+ requeued: {
+ queued: 'Relaunched — the task is queued again on its preserved worktree',
+ running: 'Relaunched — the task is running again on its preserved worktree',
+ },
+ 'new-task': {
+ queued: 'Relaunched — a replacement task is queued',
+ running: 'Relaunched — a replacement task is running',
+ },
+ 'already-active': { queued: 'Its task is already queued or running — nothing new was created' },
+ superseded: { queued: 'A later agent now holds this task paused — that pause was left intact' },
};
/**
@@ -31,7 +42,7 @@ const RELAUNCH_MESSAGES = {
* because it is mounted from two places (the agent card and the in-progress task
* card) and the server's mode enum should have exactly one client reader.
*/
-export default function RelaunchAgentModal({ agent, providers, apps, onDone, onClose }) {
+export default function RelaunchAgentModal({ agent, providers, providersLoaded = true, apps, onDone, onClose }) {
const currentProvider = agent?.metadata?.providerId || agent?.metadata?.provider || '';
const taskDescription = agent?.metadata?.taskDescription || agent?.taskId || 'Current task';
@@ -71,7 +82,7 @@ export default function RelaunchAgentModal({ agent, providers, apps, onDone, onC
app: formData.app || undefined,
context: formData.note.trim() || undefined
}, { silent: true });
- toast.success(RELAUNCH_MESSAGES[result?.mode] || 'Relaunched');
+ toast.success(agentResumeMessage(result, RELAUNCH_MESSAGES, 'Relaunched'));
onDone?.(result);
onClose();
return result;
@@ -104,10 +115,24 @@ export default function RelaunchAgentModal({ agent, providers, apps, onDone, onC
Current task
-
{taskDescription}
+ {/* A task description is the agent's whole prompt — routinely hundreds of
+ lines. Rendered in full it pushes the provider/model selects and the
+ Relaunch button off a phone screen, so it opens clamped. Expanding
+ swaps in a height-capped scroll box rather than unclamping in place:
+ the point of the dialog is the controls below it, and an expanded
+ prompt must not bury them again. */}
+
- This stops the running agent and requeues the same task on the worktree it leaves
- behind — no second agent, and nothing to clean up afterward.
+ This stops the running agent and restarts the same task on the worktree it leaves
+ behind — no second agent, and nothing to clean up afterward. It starts right away
+ when an agent slot is free, and stays queued until one is otherwise.
@@ -138,6 +163,7 @@ export default function RelaunchAgentModal({ agent, providers, apps, onDone, onC
emptyModelOption="Default model"
alwaysShowModel
highlightToolUse
+ loading={!providersLoaded}
/>
diff --git a/client/src/components/cos/tabs/RelaunchAgentModal.test.jsx b/client/src/components/cos/tabs/RelaunchAgentModal.test.jsx
index 67c6466100..ceb1a582bf 100644
--- a/client/src/components/cos/tabs/RelaunchAgentModal.test.jsx
+++ b/client/src/components/cos/tabs/RelaunchAgentModal.test.jsx
@@ -1,4 +1,4 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
@@ -43,6 +43,12 @@ beforeEach(() => {
api.relaunchCosAgent.mockResolvedValue({ success: true, taskId: 'task-abc', mode: 'requeued' });
});
+// A prototype getter spy would otherwise survive a failing assertion and leak
+// into every test that runs after it.
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
describe('RelaunchAgentModal', () => {
it('submits the stalled run\'s own settings when the user changes nothing', async () => {
const user = userEvent.setup();
@@ -92,6 +98,55 @@ describe('RelaunchAgentModal', () => {
expect(toast.success).not.toHaveBeenCalledWith(expect.stringMatching(/queued again/i));
});
+ // A task description is the agent's whole prompt. Rendered in full it pushes
+ // the provider/model selects and the Relaunch button off a phone screen, so
+ // the dialog must open on a clamped preview and cap the expanded body's height.
+ it('opens the task prompt collapsed and caps it in a scroll box when expanded', async () => {
+ // jsdom reports 0 for scrollHeight and clientHeight alike, so nothing ever
+ // measures as overflowing without this.
+ vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(500);
+ const user = userEvent.setup();
+ const prompt = Array.from({ length: 200 }, (_, i) => `step ${i}`).join('\n');
+ renderModal({ agent: { ...STALLED_AGENT, metadata: { ...STALLED_AGENT.metadata, taskDescription: prompt } } });
+
+ const preview = document.getElementById('relaunch-task-agent-live');
+ expect(preview.className).toContain('line-clamp-3');
+
+ await user.click(screen.getByRole('button', { name: /show more/i }));
+
+ const expanded = document.getElementById('relaunch-task-agent-live');
+ expect(expanded.className).not.toContain('line-clamp-3');
+ expect(expanded.className).toContain('max-h-48');
+ expect(expanded.className).toContain('overflow-y-auto');
+ // The way back matters most on a phone — expanding must not strand the user
+ // in the prompt with the form below it out of reach.
+ expect(screen.getByRole('button', { name: /show less/i })).toBeInTheDocument();
+ });
+
+ it('says the task is running when the server started it, not that it is queued', async () => {
+ const user = userEvent.setup();
+ api.relaunchCosAgent.mockResolvedValue({ success: true, taskId: 'task-abc', mode: 'requeued', spawned: true });
+ renderModal();
+
+ await user.click(screen.getByRole('button', { name: 'Relaunch Agent' }));
+
+ await waitFor(() => expect(toast.success).toHaveBeenCalledWith(expect.stringMatching(/running again/i)));
+ expect(toast.success).not.toHaveBeenCalledWith(expect.stringMatching(/queued/i));
+ });
+
+ it('names why a relaunched task stayed queued instead of leaving the user to hunt for it', async () => {
+ const user = userEvent.setup();
+ api.relaunchCosAgent.mockResolvedValue({
+ success: true, taskId: 'task-abc', mode: 'requeued',
+ spawned: false, spawnHold: 'No available agent slots (3/3)',
+ });
+ renderModal();
+
+ await user.click(screen.getByRole('button', { name: 'Relaunch Agent' }));
+
+ await waitFor(() => expect(toast.success).toHaveBeenCalledWith(expect.stringMatching(/No available agent slots \(3\/3\)/)));
+ });
+
it('keeps the dialog open and surfaces the error when the relaunch fails', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
diff --git a/client/src/components/cos/tabs/ResumeAgentModal.jsx b/client/src/components/cos/tabs/ResumeAgentModal.jsx
index 5585202ba2..d56802876b 100644
--- a/client/src/components/cos/tabs/ResumeAgentModal.jsx
+++ b/client/src/components/cos/tabs/ResumeAgentModal.jsx
@@ -8,7 +8,7 @@ import { FormField } from '../../ui/FormField';
import EffortSelect from '../EffortSelect';
import { effectiveModelFor, effortAwareModelOptions, effortSurvivingModel, seedModelEffort } from '../../../utils/providers';
-export default function ResumeAgentModal({ agent, taskType = 'user', providers, apps, onSubmit, onClose }) {
+export default function ResumeAgentModal({ agent, taskType = 'user', providers, providersLoaded = true, apps, onSubmit, onClose }) {
// A paused agent resumes IN PLACE: its own task is requeued on the worktree its
// run left behind. Everything else (a completed/failed run, whose task is long
// settled) can only be continued by queueing a new task.
@@ -232,9 +232,14 @@ export default function ResumeAgentModal({ agent, taskType = 'user', providers,
value={formData.provider}
onChange={e => setFormData({ ...formData, provider: e.target.value, model: '', effort: '' })}
className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm focus:border-port-accent focus:outline-hidden"
+ disabled={!providersLoaded}
>
-
- {providers?.filter(p => p.enabled).map(p => (
+ {/* Mid-fetch, `providers` is empty — say so instead of rendering a
+ picker whose only option looks like a broken control. */}
+ {providersLoaded
+ ?
+ : }
+ {providersLoaded && providers?.filter(p => p.enabled).map(p => (
))}
@@ -250,9 +255,9 @@ export default function ResumeAgentModal({ agent, taskType = 'user', providers,
effort: effortSurvivingModel(selectedProvider, e.target.value, d.effort),
}))}
className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm focus:border-port-accent focus:outline-hidden"
- disabled={!formData.provider}
+ disabled={!providersLoaded || !formData.provider}
>
-
+
{availableModels.map(m => (
))}
diff --git a/client/src/components/cos/tabs/RunsTab.jsx b/client/src/components/cos/tabs/RunsTab.jsx
index e62495b4b6..33f7cbcc30 100644
--- a/client/src/components/cos/tabs/RunsTab.jsx
+++ b/client/src/components/cos/tabs/RunsTab.jsx
@@ -331,7 +331,7 @@ export default function RunsTab() {
{run.success === false && (
{ e.stopPropagation(); setLogModalRun(run); }}
- className="p-1 text-gray-500 hover:text-port-accent transition-colors sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100 sm:focus-visible:opacity-100"
+ className="min-h-[44px] min-w-[44px] inline-flex items-center justify-center p-1 text-gray-500 hover:text-port-accent transition-colors sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100 sm:focus-visible:opacity-100"
title="View system logs"
aria-label="View system logs"
data-testid={`view-logs-${run.id}`}
@@ -342,7 +342,7 @@ export default function RunsTab() {
{run.success !== null && (
handleResume(run, e)}
- className="p-1 text-gray-500 hover:text-port-accent transition-colors sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100 sm:focus-visible:opacity-100"
+ className="min-h-[44px] min-w-[44px] inline-flex items-center justify-center p-1 text-gray-500 hover:text-port-accent transition-colors sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100 sm:focus-visible:opacity-100"
title="Resume run" aria-label="Resume run"
data-testid={`resume-run-${run.id}`}
>
@@ -353,7 +353,7 @@ export default function RunsTab() {
handleStop(run.id, e)}
disabled={stoppingIds.has(run.id)}
- className="p-1 text-gray-500 hover:text-port-error transition-colors disabled:opacity-40 sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100 sm:focus-visible:opacity-100"
+ className="min-h-[44px] min-w-[44px] inline-flex items-center justify-center p-1 text-gray-500 hover:text-port-error transition-colors disabled:opacity-40 sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100 sm:focus-visible:opacity-100"
title={stoppingIds.has(run.id) ? 'Stopping run' : 'Stop run'}
aria-label={stoppingIds.has(run.id) ? 'Stopping run' : 'Stop run'}
data-testid={`stop-run-${run.id}`}
@@ -363,7 +363,7 @@ export default function RunsTab() {
)}
handleDelete(run.id, e)}
- className="p-1 text-gray-500 hover:text-port-error transition-colors sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100 sm:focus-visible:opacity-100"
+ className="min-h-[44px] min-w-[44px] inline-flex items-center justify-center p-1 text-gray-500 hover:text-port-error transition-colors sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100 sm:focus-visible:opacity-100"
title="Delete run" aria-label="Delete run"
data-testid={`delete-run-${run.id}`}
>
diff --git a/client/src/components/cos/tabs/ScheduleTab.jsx b/client/src/components/cos/tabs/ScheduleTab.jsx
index 0796500f47..04a56ad54b 100644
--- a/client/src/components/cos/tabs/ScheduleTab.jsx
+++ b/client/src/components/cos/tabs/ScheduleTab.jsx
@@ -40,7 +40,7 @@ function mergeOnDemandRequest(schedule, request) {
// passed down — same convention as TasksTab/AgentsTab — so this tab's provider/
// model pickers stay live without standing up a second independent poll of the
// same data.
-export default function ScheduleTab({ apps, providers, activeProviderId }) {
+export default function ScheduleTab({ apps, providers, providersLoaded, activeProviderId }) {
const [searchParams, setSearchParams] = useSearchParams();
const [schedule, setSchedule] = useState(null);
const [loading, setLoading] = useState(true);
@@ -185,6 +185,7 @@ export default function ScheduleTab({ apps, providers, activeProviderId }) {
tasks={tasks}
apps={apps}
providers={providers}
+ providersLoaded={providersLoaded}
activeProviderId={activeProviderId}
onTrigger={handleTriggerAppImprovement}
onUpdate={handleUpdateTask}
@@ -208,6 +209,7 @@ export default function ScheduleTab({ apps, providers, activeProviderId }) {
onUpdate={handleUpdateTask}
onTrigger={handleTriggerAppImprovement}
providers={providers}
+ providersLoaded={providersLoaded}
activeProviderId={activeProviderId}
apps={apps}
onUpdateOverride={handleUpdateOverride}
diff --git a/client/src/components/cos/tabs/SortableTaskItem.jsx b/client/src/components/cos/tabs/SortableTaskItem.jsx
index 84b259c425..cafd21fd2f 100644
--- a/client/src/components/cos/tabs/SortableTaskItem.jsx
+++ b/client/src/components/cos/tabs/SortableTaskItem.jsx
@@ -3,7 +3,7 @@ import { useSortable } from '@dnd-kit/sortable';
import { dndTransformToCss } from '../../../lib/dndTransform';
import TaskItem from './TaskItem';
-export default function SortableTaskItem({ task, selected = false, onRefresh, providers, durations, apps, instances }) {
+export default function SortableTaskItem({ task, selected = false, onRefresh, providers, providersLoaded, durations, apps, instances }) {
const [isEditing, setIsEditing] = useState(false);
const {
attributes,
@@ -28,6 +28,7 @@ export default function SortableTaskItem({ task, selected = false, onRefresh, pr
selected={selected}
onRefresh={onRefresh}
providers={providers}
+ providersLoaded={providersLoaded}
durations={durations}
apps={apps}
instances={instances}
diff --git a/client/src/components/cos/tabs/TaskItem.jsx b/client/src/components/cos/tabs/TaskItem.jsx
index 6f1ac97133..15e5be8f6d 100644
--- a/client/src/components/cos/tabs/TaskItem.jsx
+++ b/client/src/components/cos/tabs/TaskItem.jsx
@@ -141,7 +141,7 @@ function getSuccessRateStyle(rate) {
return { bg: 'bg-port-error/15', text: 'text-port-error', label: 'low' };
}
-export default function TaskItem({ task, agent = null, isSystem, spawning = false, selected = false, onRefresh, onTaskUnblocked, providers, durations, dragHandleProps, apps, instances = null, onEditingChange }) {
+export default function TaskItem({ task, agent = null, isSystem, spawning = false, selected = false, onRefresh, onTaskUnblocked, providers, providersLoaded, durations, dragHandleProps, apps, instances = null, onEditingChange }) {
// System tasks are persisted in COS-TASKS.md. Every task
// mutation must name that source; otherwise the API's user-queue default
// searches TASKS.md and reports the system task as missing.
@@ -402,7 +402,17 @@ export default function TaskItem({ task, agent = null, isSystem, spawning = fals
: requiresApproval ? 'border-yellow-500/50' : 'border-port-border'
}`}
>
-
+ {/* The icon rail and the task body cannot share a row on a phone: up to
+ five 44px targets leave the body ~120px wide, so the task id breaks a
+ few characters per line and every badge lands on its own row. The
+ rail's `basis-full` under `sm` is what drops it onto its own wrapped
+ line, leaving the handle, the status glyph and the body together on the
+ first; `sm:basis-auto` + `sm:flex-nowrap` put them back on one row above
+ it. Sizing the BODY instead (a `basis-64` that pushes the rail off the
+ line) reads the same at 390px but strands the two 16px glyphs alone on
+ row 1 once they no longer fit beside it — at 360px and below, and at
+ 390px on any row that also carries a drag handle. */}
+
{/* Drag handle - only show for user tasks. */}
{dragHandleProps && !isSystem && (
- {task.id}
+ {task.id}
{task.metadata?.app && apps?.find(a => a.id === task.metadata.app)?.name && (
{apps.find(a => a.id === task.metadata.app).name}
@@ -733,7 +743,7 @@ export default function TaskItem({ task, agent = null, isSystem, spawning = fals
{/* Action buttons. Keep the delete confirmation here, next to the trash
icon, rather than at the bottom of the card — a task with a lot of
context would otherwise push the confirm row far below the fold. */}
-
@@ -282,7 +282,7 @@ export default function TasksTab({ tasks, agents = [], onRefresh, onTaskAdded, o
{activeUserTasksLocal.map(task => (
-
+
))}
@@ -299,7 +299,7 @@ export default function TasksTab({ tasks, agents = [], onRefresh, onTaskAdded, o
{blockedUserTasksLocal.map(task => (
-
+
))}
@@ -322,7 +322,7 @@ export default function TasksTab({ tasks, agents = [], onRefresh, onTaskAdded, o
{showCompletedUserTasks && (
{completedUserTasksLocal.map(task => (
-
+
))}
)}
@@ -355,7 +355,7 @@ export default function TasksTab({ tasks, agents = [], onRefresh, onTaskAdded, o
{pendingSystemTasks.map(task => (
-
+
))}
@@ -372,7 +372,7 @@ export default function TasksTab({ tasks, agents = [], onRefresh, onTaskAdded, o
{activeSystemTasks.map(task => (
-
+
))}
@@ -389,7 +389,7 @@ export default function TasksTab({ tasks, agents = [], onRefresh, onTaskAdded, o
{blockedSystemTasks.map(task => (
-
+
))}
@@ -412,7 +412,7 @@ export default function TasksTab({ tasks, agents = [], onRefresh, onTaskAdded, o
{showCompletedSystemTasks && (
{completedSystemTasks.map(task => (
-
+
))}
)}
diff --git a/client/src/components/cos/tabs/WorkflowTab.jsx b/client/src/components/cos/tabs/WorkflowTab.jsx
index 274b76b338..8e2c5e27d3 100644
--- a/client/src/components/cos/tabs/WorkflowTab.jsx
+++ b/client/src/components/cos/tabs/WorkflowTab.jsx
@@ -106,7 +106,7 @@ function TrackGrid({ divisions }) {
// Reshapes a task node into the `config` shape PerAppOverrideList expects and
// renders it. Shared by the pinned TimelineRow and the flexible-queue rows so
// the config reconstruction lives in exactly one place.
-function AppOverridePanel({ node, apps, providers, onUpdateOverride, onBulkToggleOverride }) {
+function AppOverridePanel({ node, apps, providers, providersLoaded, onUpdateOverride, onBulkToggleOverride }) {
return (
);
}
-function TimelineRow({ node, occurrences, windows, timeline, hours, timezone, selected, apps, providers, expanded, onSelect, onToggleExpand, onUpdateOverride, onBulkToggleOverride }) {
+function TimelineRow({ node, occurrences, windows, timeline, hours, timezone, selected, apps, providers, providersLoaded, expanded, onSelect, onToggleExpand, onUpdateOverride, onBulkToggleOverride }) {
const palette = trackPalette(node);
const Icon = node.kind === 'job' ? Bot : GitBranch;
const divisions = hours === 168 ? 7 : 8;
const dependencyWarning = node.pendingDeps?.length > 0;
- // App overrides only apply to task types (system jobs are not per-app). The
+ // Per-app options only apply to task types (system jobs are not per-app). The
// server's active-app counts drive the toggle + badge (single source of
// truth); PerAppOverrideList does its own `apps` filtering when expanded.
const { enabledAppCount = 0, totalAppCount = 0 } = node;
@@ -148,7 +149,7 @@ function TimelineRow({ node, occurrences, windows, timeline, hours, timezone, se
type="button"
onClick={() => onToggleExpand(node.id)}
aria-expanded={expanded}
- aria-label={`${expanded ? 'Hide' : 'Show'} app overrides for ${node.label}`}
+ aria-label={`${expanded ? 'Hide' : 'Show'} per-app options for ${node.label}`}
title={countTitle}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-gray-500 hover:bg-white/10 hover:text-gray-300"
>
@@ -205,7 +206,7 @@ function TimelineRow({ node, occurrences, windows, timeline, hours, timezone, se
{canExpand && expanded && (
-
+
)}
@@ -246,7 +247,7 @@ function NextUp({ occurrences, nodeMap, hours, timezone, onSelect }) {
// `providers` is the same ChiefOfStaff-owned list ScheduleTab renders — without it
// the per-app rows here degraded to raw provider ids while the Schedule tab showed
// display names for the very same pin (#4783).
-export default function WorkflowTab({ apps, providers }) {
+export default function WorkflowTab({ apps, providers, providersLoaded }) {
// Zoom window + selected track live in the URL so the open editor and view
// are shareable/bookmarkable and survive reload — the same "URL is the
// source of truth for what's open" convention as ScheduleTab's ?task=.
@@ -271,7 +272,7 @@ export default function WorkflowTab({ apps, providers }) {
const [graph, setGraph] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
- // Which task rows have their per-app override panel expanded. Kept as local
+ // Which task rows have their per-app options panel expanded. Kept as local
// view state (a lightweight detail, not a selected record) rather than in the
// URL — the ?track= param already owns the selected editor panel.
const [expandedIds, setExpandedIds] = useState(() => new Set());
@@ -305,7 +306,7 @@ export default function WorkflowTab({ apps, providers }) {
return () => { fetchGeneration.current += 1; };
}, [fetchGraph]);
- // Per-app override mutations are shared with ScheduleTab; refetch the graph so
+ // Per-app option mutations are shared with ScheduleTab; refetch the graph so
// the enabled-app counts and inherited defaults stay in sync after each change.
const { handleUpdateOverride, handleBulkToggleOverride } = useAppOverrideActions(apps, fetchGraph);
@@ -414,6 +415,7 @@ export default function WorkflowTab({ apps, providers }) {
selected={selectedId === node.id}
apps={apps}
providers={providers}
+ providersLoaded={providersLoaded}
expanded={expandedIds.has(node.id)}
onSelect={setSelectedId}
onToggleExpand={toggleExpand}
@@ -445,7 +447,7 @@ export default function WorkflowTab({ apps, providers }) {
type="button"
onClick={() => toggleExpand(node.id)}
aria-expanded={expandedIds.has(node.id)}
- aria-label={`${expandedIds.has(node.id) ? 'Hide' : 'Show'} app overrides for ${node.label}`}
+ aria-label={`${expandedIds.has(node.id) ? 'Hide' : 'Show'} per-app options for ${node.label}`}
title={`${node.enabledAppCount || 0} of ${node.totalAppCount} apps enabled`}
className="flex h-full items-center border-l border-port-border/60 px-1.5 text-gray-500 hover:bg-white/10 hover:text-gray-300"
>
@@ -458,8 +460,8 @@ export default function WorkflowTab({ apps, providers }) {
diff --git a/client/src/components/cos/tabs/schedule/AppOverrideRow.test.jsx b/client/src/components/cos/tabs/schedule/AppOverrideRow.test.jsx
index 3957ec0783..ac319df612 100644
--- a/client/src/components/cos/tabs/schedule/AppOverrideRow.test.jsx
+++ b/client/src/components/cos/tabs/schedule/AppOverrideRow.test.jsx
@@ -277,3 +277,31 @@ describe('AppOverrideRow — per-app provider pin', () => {
expect(onUpdate).toHaveBeenCalledWith('app-1', 'ux', { providerId: 'opencode-llama-tui', model: null });
});
});
+
+describe('AppOverrideRow — enabled toggle', () => {
+ // The switch is what turns the scheduled task on for the app; it is NOT an
+ // "apply my overrides" flag. It carries no visible on/off text, so the
+ // accessible name has to say which task, which app, and the current state.
+ // The row renders the switch twice (a mobile slot and a desktop one), so both
+ // are asserted rather than indexing into the list.
+ it('names the task, the app, and the current state on every slot', () => {
+ renderRow({ taskType: 'feature-ideas' });
+ const off = screen.getAllByRole('switch', { name: 'feature-ideas enabled for Acme: off' });
+ expect(off).toHaveLength(2);
+ off.forEach(sw => expect(sw).toHaveAttribute('aria-checked', 'false'));
+
+ cleanup();
+ renderRow({ taskType: 'feature-ideas', override: { enabled: true } });
+ const on = screen.getAllByRole('switch', { name: 'feature-ideas enabled for Acme: on' });
+ expect(on).toHaveLength(2);
+ on.forEach(sw => expect(sw).toHaveAttribute('aria-checked', 'true'));
+ });
+
+ it('enables the task for the app while preserving its interval override', async () => {
+ const onUpdate = renderRow({ override: { enabled: false, interval: 'on-demand' } });
+ await act(async () => {
+ fireEvent.click(screen.getAllByRole('switch', { name: 'feature-ideas enabled for Acme: off' })[0]);
+ });
+ expect(onUpdate).toHaveBeenCalledWith('app-1', 'feature-ideas', { enabled: true, interval: 'on-demand' });
+ });
+});
diff --git a/client/src/components/cos/tabs/schedule/AppTaskCard.jsx b/client/src/components/cos/tabs/schedule/AppTaskCard.jsx
index 8f0660309a..9fc526f181 100644
--- a/client/src/components/cos/tabs/schedule/AppTaskCard.jsx
+++ b/client/src/components/cos/tabs/schedule/AppTaskCard.jsx
@@ -9,7 +9,7 @@ import TaskModelQuickControls from './TaskModelQuickControls';
// One scheduled task rendered as a status-rich card. Browsing plus the common
// "retarget the model and run it" loop happen here; the rest of the
// configuration lives in the slide-over drawer (opened via Configure).
-export default function AppTaskCard({ taskType, config, apps, onTrigger, onConfigure, onUpdate, providers, activeProviderId, improvementDisabled }) {
+export default function AppTaskCard({ taskType, config, apps, onTrigger, onConfigure, onUpdate, providers, providersLoaded = true, activeProviderId, improvementDisabled }) {
// Owned here, not in the controls, so Run can gate on the same `saving` flag —
// it reads the server-side config, so a run fired mid-write uses the old pins.
const pins = useTaskModelPins({ taskType, config, providers, activeProviderId, onUpdate });
@@ -80,7 +80,7 @@ export default function AppTaskCard({ taskType, config, apps, onTrigger, onConfi
Provider/model is set per stage ({stageCount}) — configure
) : (
-
+
))}
{/* Footer actions */}
diff --git a/client/src/components/cos/tabs/schedule/AppTaskCard.test.jsx b/client/src/components/cos/tabs/schedule/AppTaskCard.test.jsx
index 28332f0cbf..06713f558e 100644
--- a/client/src/components/cos/tabs/schedule/AppTaskCard.test.jsx
+++ b/client/src/components/cos/tabs/schedule/AppTaskCard.test.jsx
@@ -219,6 +219,15 @@ describe('AppTaskCard', () => {
expect(screen.queryByLabelText('Thinking effort')).toBeNull();
});
+ it('says the provider list is still loading instead of offering a lone bare Default', () => {
+ // Proves the card actually threads the flag; the label/disable rule itself
+ // is ProviderModelSelector's (see its own suite).
+ renderCardWithPins({}, { providers: [], providersLoaded: false });
+ const provider = screen.getByLabelText('Provider');
+ expect(within(provider).getByRole('option', { name: 'Loading providers…' })).toBeTruthy();
+ expect(provider.disabled).toBe(true);
+ });
+
it('hides a disabled provider from the picker unless the task is pinned to it', () => {
const withDisabled = [...providers, { id: 'retired', name: 'Retired CLI', enabled: false }];
renderCardWithPins({}, { providers: withDisabled });
diff --git a/client/src/components/cos/tabs/schedule/AppTaskTypeSection.jsx b/client/src/components/cos/tabs/schedule/AppTaskTypeSection.jsx
index b0fc9e16ec..ec58e2b597 100644
--- a/client/src/components/cos/tabs/schedule/AppTaskTypeSection.jsx
+++ b/client/src/components/cos/tabs/schedule/AppTaskTypeSection.jsx
@@ -3,7 +3,7 @@ import { Search, X } from 'lucide-react';
import AppTaskCard from './AppTaskCard';
import { TASK_FILTERS, DEFAULT_FILTER_ID, taskSortKey } from './scheduleConstants';
-export default function AppTaskTypeSection({ tasks, apps, providers, activeProviderId, onTrigger, onUpdate, onSelectTask, improvementDisabled, filter, onFilterChange }) {
+export default function AppTaskTypeSection({ tasks, apps, providers, providersLoaded, activeProviderId, onTrigger, onUpdate, onSelectTask, improvementDisabled, filter, onFilterChange }) {
const [search, setSearch] = useState('');
const taskEntries = Object.entries(tasks || {});
@@ -47,7 +47,7 @@ export default function AppTaskTypeSection({ tasks, apps, providers, activeProvi
- Tasks that analyze and improve PortOS and managed apps. Click a card to configure schedule and per-app overrides.
+ Tasks that analyze and improve PortOS and managed apps. Click a card to configure its schedule and to turn it on or off per app.
@@ -65,7 +65,7 @@ export default function AppTaskTypeSection({ tasks, apps, providers, activeProvi
type="button"
onClick={() => setSearch('')}
aria-label="Clear filter"
- className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-500 hover:text-white"
+ className="min-h-[44px] min-w-[44px] inline-flex items-center justify-center absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-500 hover:text-white"
>
@@ -90,6 +90,7 @@ export default function AppTaskTypeSection({ tasks, apps, providers, activeProvi
config={config}
apps={apps}
providers={providers}
+ providersLoaded={providersLoaded}
activeProviderId={activeProviderId}
onTrigger={onTrigger}
onUpdate={onUpdate}
diff --git a/client/src/components/cos/tabs/schedule/GlobalConfigControls.jsx b/client/src/components/cos/tabs/schedule/GlobalConfigControls.jsx
index afaae9d64a..a81269b5d7 100644
--- a/client/src/components/cos/tabs/schedule/GlobalConfigControls.jsx
+++ b/client/src/components/cos/tabs/schedule/GlobalConfigControls.jsx
@@ -2,7 +2,7 @@ import { useState, useEffect, useMemo } from 'react';
import useFieldDraft from '../../../../hooks/useFieldDraft';
import { RotateCcw, AlertCircle } from 'lucide-react';
import CronInput from '../../../CronInput';
-import { AGENT_OPTIONS, BRANCHES_PER_AGENT_DEFAULT, BRANCHES_PER_AGENT_OPTIONS, BRANCHES_PER_AGENT_TASK_TYPES, DEFAULT_REVIEW_STOP_MODE, IMPLICIT_PR_COMPLETION, PR_AUTHOR_FILTER_OPTIONS, PR_COMPLETION_OPTIONS, pinnedPrCompletion, prCompletionOption, ISSUE_AUTHOR_FILTER_OPTIONS, ISSUE_AUTHOR_FILTER_TASK_TYPES, SWARM_COUNT_OPTIONS, SWARM_TASK_TYPES } from '../../constants';
+import { AGENT_OPTIONS, BRANCHES_PER_AGENT_DEFAULT, BRANCHES_PER_AGENT_OPTIONS, BRANCHES_PER_AGENT_TASK_TYPES, DEFAULT_REVIEW_STOP_MODE, REVIEWER_OVERRIDE_KEYS as REVIEW_CONFIG_KEYS, IMPLICIT_PR_COMPLETION, PR_AUTHOR_FILTER_OPTIONS, PR_COMPLETION_OPTIONS, pinnedPrCompletion, prCompletionOption, ISSUE_AUTHOR_FILTER_OPTIONS, ISSUE_AUTHOR_FILTER_TASK_TYPES, SWARM_COUNT_OPTIONS, SWARM_TASK_TYPES } from '../../constants';
import ReviewerPicker from '../../ReviewerPicker';
import Banner from '../../../ui/Banner';
import InfoTooltip from '../../../ui/InfoTooltip';
@@ -25,22 +25,16 @@ import { INTERVAL_DESCRIPTIONS, PERPETUAL_DESCRIPTION, toggleMetadataField, pipe
// runs (no app) land on the server-side fallback.
const PR_COMPLETION_INHERIT_HINT = `Uses the target app's "After opening PR" default (Apps → Edit App), or "${prCompletionOption(IMPLICIT_PR_COMPLETION)?.label}" when it has none.`;
-// These fields are the task-local reviewer-loop override. Removing them lets
-// the picker and server resolver fall back to the install-wide Code Review
-// Defaults without changing the task's PR policy or other agent options.
-const REVIEW_CONFIG_KEYS = [
- 'reviewer',
- 'reviewers',
- 'usernames',
- 'optionalReviewers',
- 'reviewerMaxRounds',
- 'reviewerModels',
- 'reviewerEfforts',
- 'reviewStopMode',
- 'reviewerApplies',
-];
-
-export default function GlobalConfigControls({ taskType, config, onUpdate, onTrigger, category: _category, providers, activeProviderId, apps, updating, setUpdating, allTaskTypes, improvementDisabled, dataInputCatalog }) {
+// The task-local reviewer-loop override is REVIEWER_OVERRIDE_KEYS (imported as
+// REVIEW_CONFIG_KEYS above). Removing those keys lets the picker and the server
+// resolver fall back to the install-wide Code Review Defaults without changing
+// the task's PR policy or other agent options.
+//
+// Deliberately the WIDE roster, not `hasReviewerOverride`'s list-bearing subset:
+// the reset clears the two run flags too, so gating its visibility on the subset
+// would leave a stop-mode-only override on screen with no control that removes it.
+
+export default function GlobalConfigControls({ taskType, config, onUpdate, onTrigger, category: _category, providers, providersLoaded = true, activeProviderId, apps, updating, setUpdating, allTaskTypes, improvementDisabled, dataInputCatalog }) {
const reviewDefaults = useCodeReviewDefaults();
// Resolved model lists for the reviewer table's Model column (the picker itself
// never fetches — see its `modelOptions` prop).
@@ -230,9 +224,18 @@ export default function GlobalConfigControls({ taskType, config, onUpdate, onTri
// Reviewers only run under review-then-merge, so the picker hides for the two
// policies that never reach them — but an unpinned ('') task may still inherit
// review-then-merge from its app, so that keeps it.
- const reviewersApply = config.taskMetadata?.openPR
- ? prCompletion === '' || prCompletion === 'review-then-merge'
- : !!config.taskMetadata?.reviewLoop;
+ //
+ // A claimFlow task is unconditional: its PROMPT opens and merges its own PR and
+ // runs the reviewers itself, so the resolved list is operative no matter what
+ // `openPR` / `reviewLoop` say (both are false in the shipped claim metadata).
+ // Without this the picker — and the "Use system Code Review Defaults" reset
+ // beside it — never render for claim-work, leaving a reviewer override that
+ // every claim obeys with no control anywhere that can clear it.
+ const reviewersApply = config.taskMetadata?.claimFlow
+ ? true
+ : config.taskMetadata?.openPR
+ ? prCompletion === '' || prCompletion === 'review-then-merge'
+ : !!config.taskMetadata?.reviewLoop;
// `selectedProvider` / `availableModels` come from useTaskModelPins above — it
// resolves the pin against the active provider, lists Antigravity's BASE models
@@ -389,10 +392,12 @@ export default function GlobalConfigControls({ taskType, config, onUpdate, onTri
requestDelete(entry.id)}
- className="p-0.5 text-gray-700 hover:text-red-400 opacity-40 sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100 shrink-0"
+ className="min-h-[44px] min-w-[44px] inline-flex items-center justify-center p-0.5 text-gray-700 hover:text-red-400 opacity-40 sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100 shrink-0"
title="Delete" aria-label="Delete"
>
diff --git a/client/src/components/goals/GoalTodoList.jsx b/client/src/components/goals/GoalTodoList.jsx
index 8834b809db..4c8b2c0c34 100644
--- a/client/src/components/goals/GoalTodoList.jsx
+++ b/client/src/components/goals/GoalTodoList.jsx
@@ -46,7 +46,7 @@ export default function GoalTodoList({
)}
requestDelete(todo.id)}
- className="p-0.5 text-gray-700 hover:text-red-400 opacity-40 sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100 shrink-0"
+ className="min-h-[44px] min-w-[44px] inline-flex items-center justify-center p-0.5 text-gray-700 hover:text-red-400 opacity-40 sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100 shrink-0"
title="Delete" aria-label="Delete"
>
diff --git a/client/src/components/goals/GoalsListView.jsx b/client/src/components/goals/GoalsListView.jsx
index d638dea200..47df641cc0 100644
--- a/client/src/components/goals/GoalsListView.jsx
+++ b/client/src/components/goals/GoalsListView.jsx
@@ -88,7 +88,7 @@ function GoalRow({ goal, depth, expandedIds, onToggle, onSelect, selectedId, onA
{hasChildren ? (
{ e.stopPropagation(); onToggle(goal.id); }}
- className="p-0.5 text-gray-500 hover:text-white shrink-0"
+ className="min-h-[44px] min-w-[44px] inline-flex items-center justify-center p-0.5 text-gray-500 hover:text-white shrink-0"
title={expanded ? 'Collapse' : 'Expand'} aria-label={expanded ? 'Collapse' : 'Expand'}
>
{expanded ? : }
@@ -153,7 +153,7 @@ function GoalRow({ goal, depth, expandedIds, onToggle, onSelect, selectedId, onA
{ e.stopPropagation(); onAddChild(goal.id); }}
- className="p-1 text-gray-600 hover:text-port-accent shrink-0"
+ className="min-h-[44px] min-w-[44px] inline-flex items-center justify-center p-1 text-gray-600 hover:text-port-accent shrink-0"
title="Add sub-goal" aria-label="Add sub-goal"
>
diff --git a/client/src/components/install/QueueInstallInvestigationButton.jsx b/client/src/components/install/QueueInstallInvestigationButton.jsx
index 8450707cc9..d18b572b1e 100644
--- a/client/src/components/install/QueueInstallInvestigationButton.jsx
+++ b/client/src/components/install/QueueInstallInvestigationButton.jsx
@@ -19,13 +19,13 @@ import { buildInstallFailureTask } from '../../lib/installFailureTask';
import { addCosTask } from '../../services/api';
import toast from '../ui/Toast';
-// Unattended repair work, same posture as `INVESTIGATION_TASK_DELIVERY`
-// (`server/lib/investigationTasks.js`): keep it out of the user's checkout and
-// send it through the PR gate. It deliberately stops short of that constant's
-// `prCompletion: merge-on-green`, and carries no `isInvestigation` marker —
-// `createCosTaskSchema` has no such field, so a client-queued task sits outside
-// the investigation dedup/circuit-breaker machinery. Tracked in #6043.
-const INSTALL_INVESTIGATION_DELIVERY = { useWorktree: true, openPR: true };
+// The task's identity as an investigation (#6043) is the only posture flag sent:
+// the server recognizes `isInvestigation`, derives the dedup fingerprint itself,
+// and applies `CLIENT_INVESTIGATION_DELIVERY` (`server/lib/investigationTasks.js`)
+// — worktree-isolated, PR-gated, reviewed rather than merged on green because a
+// human asked for this one. Keeping the delivery server-side is what stops this
+// button from drifting out of step with the auto-filed posture again.
+const INSTALL_INVESTIGATION_MARKER = { isInvestigation: true };
export default function QueueInstallInvestigationButton({
label,
@@ -47,7 +47,7 @@ export default function QueueInstallInvestigationButton({
// The description is deterministic per installer + stage, so retrying the
// same failing install and clicking again hits the store's duplicate guard
// (409 DUPLICATE_TASK). A task already exists — not a failure to report.
- const duplicateMessage = await addCosTask({ ...task, ...INSTALL_INVESTIGATION_DELIVERY }, { silent: true })
+ const duplicateMessage = await addCosTask({ ...task, ...INSTALL_INVESTIGATION_MARKER }, { silent: true })
.then(() => null)
.catch((err) => {
// The server names the existing task's status ("already pending" /
diff --git a/client/src/components/install/RuntimeInstallModal.test.jsx b/client/src/components/install/RuntimeInstallModal.test.jsx
index bafab6bd5c..a888fa7606 100644
--- a/client/src/components/install/RuntimeInstallModal.test.jsx
+++ b/client/src/components/install/RuntimeInstallModal.test.jsx
@@ -67,7 +67,13 @@ describe('RuntimeInstallModal failure footer', () => {
expect(task.prompt).toContain('fatal: could not build');
// No `app` — the installer code lives in PortOS, which is the server default.
expect(task.app).toBeUndefined();
- expect(task).toMatchObject({ useWorktree: true, openPR: true });
+ // The investigation marker (#6043) is the ONLY posture the client sends: the
+ // server recognizes it, derives the dedup fingerprint, and applies the
+ // worktree/PR delivery, so this button can no longer drift out of step with
+ // the auto-filed investigation posture.
+ expect(task).toMatchObject({ isInvestigation: true });
+ expect(task).not.toHaveProperty('useWorktree');
+ expect(task).not.toHaveProperty('openPR');
// useAsyncAction owns the failure toast, so the request must not toast too.
expect(options).toMatchObject({ silent: true });
diff --git a/client/src/components/meatspace/post/PostCognitiveDrillRunner.jsx b/client/src/components/meatspace/post/PostCognitiveDrillRunner.jsx
index 04d5584e49..c2830c18a6 100644
--- a/client/src/components/meatspace/post/PostCognitiveDrillRunner.jsx
+++ b/client/src/components/meatspace/post/PostCognitiveDrillRunner.jsx
@@ -669,13 +669,13 @@ function DigitSpanRunner({ drill, drillIndex, drillCount, onComplete, isTraining
{direction === 'backward' ? 'in reverse' : 'in order'}.
-
+
{phase === 'show' ? (
// Reserve the slot with a non-breaking space in the blank gaps so the
// digit's line box never collapses — otherwise the flex container
// shrinks between flashes and the form below jumps up (issue: digit
// flashing shifts layout).
-