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 ` - {(isOperating || operationError || operationCompleted) && ( + {(isOperating || restarting || operationError || operationCompleted) && (
{/* 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 && (
-
+
-
+
{(activeType === 'projects' || activeType === 'ideas' || activeType === 'admin') && record.status !== 'done' && ( @@ -512,7 +512,7 @@ export default function NotesTab() { )} diff --git a/client/src/components/calendar/ReviewTab.jsx b/client/src/components/calendar/ReviewTab.jsx index 39ad3569a1..4a9339e2f8 100644 --- a/client/src/components/calendar/ReviewTab.jsx +++ b/client/src/components/calendar/ReviewTab.jsx @@ -111,7 +111,7 @@ export default function ReviewTab() { {/* Date Navigation */}
- - {!isToday && ( @@ -240,11 +240,11 @@ export default function ReviewTab() {
{!isReviewed && !isEditing && ( -
+
-
+
{reviewerLabel(value)} - {renderInstalledBadge(value)} + {renderUnavailableBadge(value)} Model
{renderModelCell(value)}
@@ -630,10 +671,10 @@ export default function ReviewerPicker({ )} - {available.length > 0 && ( + {addable.length > 0 && (
Add: - {available.map(opt => ( + {addOptions.map(opt => ( ))} + {hiddenAddable.length > 0 && ( + + )}
)} diff --git a/client/src/components/cos/ReviewerPicker.test.jsx b/client/src/components/cos/ReviewerPicker.test.jsx index c40ab85c2b..427144c5be 100644 --- a/client/src/components/cos/ReviewerPicker.test.jsx +++ b/client/src/components/cos/ReviewerPicker.test.jsx @@ -32,10 +32,66 @@ describe('ReviewerPicker', () => { expect(screen.queryByText('not installed')).not.toBeInTheDocument(); }); - it('flags an unselected reviewer in the Add row too', () => { + it('flags an unselected reviewer once the Add row reveals it', async () => { + const user = userEvent.setup(); render( {}} />); - const addButton = screen.getByRole('button', { name: /Antigravity/ }); - expect(addButton).toHaveTextContent('not installed'); + await user.click(screen.getByRole('button', { name: /1 unavailable/ })); + expect(screen.getByRole('button', { name: /Antigravity/ })).toHaveTextContent('not installed'); + }); + }); + + // The Add row lists what this machine can actually run. Hidden, not dropped: + // both signals are local-machine-only and the reviewer list is + // federation-wide config, so a peer's reviewer stays configurable from here. + describe('unavailable reviewers in the Add row', () => { + const modelOptions = { providerDisabled: { kimi: true, cursor: true } }; + + it('hides a missing CLI and an all-off provider behind one count', () => { + render( + {}} + /> + ); + expect(screen.getByRole('button', { name: /3 unavailable/ })).toBeInTheDocument(); + for (const hidden of [/Antigravity/, /Kimi/, /Cursor Agent/]) { + expect(screen.queryByRole('button', { name: hidden })).not.toBeInTheDocument(); + } + // An available reviewer is still offered up front. + expect(screen.getByRole('button', { name: /Codex/ })).toBeInTheDocument(); + }); + + it('reveals them, badged with which signal fired, and adds them normally', async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render( + + ); + await user.click(screen.getByRole('button', { name: /3 unavailable/ })); + expect(screen.getByRole('button', { name: /Kimi/ })).toHaveTextContent('disabled'); + expect(screen.getByRole('button', { name: /Antigravity/ })).toHaveTextContent('not installed'); + await user.click(screen.getByRole('button', { name: /Kimi/ })); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ reviewers: ['copilot', 'kimi'] })); + }); + + it('keeps an already-selected unavailable reviewer visible, badged', () => { + render( + {}} /> + ); + expect(screen.getByText('Kimi').parentElement).toHaveTextContent('disabled'); + }); + + it('offers the whole roster when neither signal was fetched', () => { + render( {}} />); + expect(screen.queryByRole('button', { name: /unavailable/ })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Kimi/ })).toBeInTheDocument(); }); }); diff --git a/client/src/components/cos/TaskAddForm.jsx b/client/src/components/cos/TaskAddForm.jsx index 10913a19d9..4e16be46be 100644 --- a/client/src/components/cos/TaskAddForm.jsx +++ b/client/src/components/cos/TaskAddForm.jsx @@ -37,7 +37,7 @@ const readTaskDescriptionDraft = (defaultApp) => { }; }; -export default function TaskAddForm({ providers, apps, onTaskAdded, compact = false, defaultExpanded = false, defaultApp = '' }) { +export default function TaskAddForm({ providers, providersLoaded = true, apps, onTaskAdded, compact = false, defaultExpanded = false, defaultApp = '' }) { const [initialDraft] = useState(() => readTaskDescriptionDraft(defaultApp)); const [newTask, setNewTask] = useState(() => { return { @@ -180,12 +180,16 @@ export default function TaskAddForm({ providers, apps, onTaskAdded, compact = fa // If the pinned provider isn't a valid coding option (e.g. a saved template // pinned an `api` provider that's now filtered out of the dropdown), reset to - // "Auto" so the visible select and the submitted value can't diverge. + // "Auto" so the visible select and the submitted value can't diverge. Gated on + // `providersLoaded`: mid-fetch, `enabledProviders` is always empty, so without + // the gate this would wipe out a legitimately pinned provider (a draft/template + // restored before the list has arrived) before it ever gets a chance to match. useEffect(() => { + if (!providersLoaded) return; if (newTask.provider && !enabledProviders.some(p => p.id === newTask.provider)) { setNewTask(t => ({ ...t, provider: '', model: '', effort: '', temperature: '', thinking: '' })); } - }, [enabledProviders, newTask.provider]); + }, [enabledProviders, newTask.provider, providersLoaded]); // Check if selected app has JIRA configured const selectedApp = useMemo(() => @@ -948,9 +952,12 @@ export default function TaskAddForm({ providers, apps, onTaskAdded, compact = fa value={newTask.provider} onChange={e => setNewTask(t => ({ ...t, provider: e.target.value, model: '', effort: '', temperature: '', thinking: '' }))} className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm min-h-[44px]" + disabled={!providersLoaded} > - - {enabledProviders.map(p => ( + {providersLoaded + ? + : } + {providersLoaded && enabledProviders.map(p => ( ))} @@ -1104,7 +1111,7 @@ export default function TaskAddForm({ providers, apps, onTaskAdded, compact = fa
)} + {agent.result?.goalFidelity && } + {completed && (agent.metadata?.taskSummary || agent.metadata?.malwareScan?.reportUrl) && (
@@ -879,7 +985,7 @@ export default function AgentCard({ agent, onPause, onKill, onDelete, onResume,
- ({ value, label }))} onChange={(value) => setFormData((current) => ({ ...current, avatarStyle: value }))} /> + setFormData((current) => ({ ...current, avatarStyle: value }))} /> setFormData((current) => ({ ...current, dynamicAvatar: value }))} />
+ {stagedRiggedNote && ( +

{stagedRiggedNote}

+ )}
diff --git a/client/src/components/cos/tabs/ConfigTab.test.jsx b/client/src/components/cos/tabs/ConfigTab.test.jsx index 2bb0ccdcd2..82db9c10a5 100644 --- a/client/src/components/cos/tabs/ConfigTab.test.jsx +++ b/client/src/components/cos/tabs/ConfigTab.test.jsx @@ -267,6 +267,65 @@ describe('Default Avatar Style dropdown', () => { }); }); +describe('Rigged avatar records in the Default Avatar dropdown', () => { + const riggedAvatars = [{ + id: 'image3d-1', + name: 'Example Dancer', + variant: 'rigged-image3d-1', + assetUrl: '/api/avatar/model.glb?variant=rigged-image3d-1', + clip: 'Dance', + coverage: { + availableClips: ['Dance'], + coverageByState: { + thinking: { covered: false, clip: null }, + ideating: { covered: true, clip: 'Dance' }, + }, + coveredStates: ['ideating'], + missingStates: ['thinking'], + complete: false, + }, + }]; + + it('offers verified animated records alongside the built-in styles', async () => { + renderConfig({ config: { ...config, avatarStyle: 'svg' }, riggedAvatars }); + await screen.findByText('Waiting for the next wake'); + + fireEvent.click(screen.getByRole('button', { name: /Edit/i })); + + const select = screen.getByRole('combobox', { name: 'Default avatar' }); + const labels = [...select.options].map((option) => option.text); + expect(labels).toContain('Digital (SVG)'); + expect(labels.some((label) => label.includes('Example Dancer') && label.includes('rigged 3D'))).toBe(true); + }); + + it('shows the coverage note when a rigged record is staged', async () => { + renderConfig({ config: { ...config, avatarStyle: 'svg' }, riggedAvatars }); + await screen.findByText('Waiting for the next wake'); + + fireEvent.click(screen.getByRole('button', { name: /Edit/i })); + + const select = screen.getByRole('combobox', { name: 'Default avatar' }); + fireEvent.change(select, { target: { value: 'rigged-image3d-1' } }); + + expect(await screen.findByText(/Covered: ideating/)).toBeInTheDocument(); + expect(screen.getByText(/Other states play Dance/)).toBeInTheDocument(); + + api.updateCosConfig.mockResolvedValue({ success: true }); + fireEvent.click(screen.getByRole('button', { name: /Save/i })); + await waitFor(() => expect(api.updateCosConfig).toHaveBeenCalledWith( + expect.objectContaining({ avatarStyle: 'rigged-image3d-1' }), + { silent: true }, + )); + }); + + it('warns when the saved rigged record is no longer offered', async () => { + renderConfig({ config: { ...config, avatarStyle: 'rigged-image3d-gone' }, riggedAvatars }); + await screen.findByText('Waiting for the next wake'); + + expect(screen.getByText(/no longer available/)).toBeInTheDocument(); + }); +}); + describe('persistent mind status', () => { it('shows the live supervisor state and links to the full mind workspace', async () => { renderConfig(); diff --git a/client/src/components/cos/tabs/MemoryEditModal.jsx b/client/src/components/cos/tabs/MemoryEditModal.jsx index a10b6065b6..c6ca333850 100644 --- a/client/src/components/cos/tabs/MemoryEditModal.jsx +++ b/client/src/components/cos/tabs/MemoryEditModal.jsx @@ -217,7 +217,7 @@ export default function MemoryEditModal({ memory, apps, onSave, onClose }) { type="button" onClick={() => handleRemoveTag(tag)} aria-label="Delete" - className="p-1 min-w-[24px] min-h-[24px] flex items-center justify-center text-gray-500 hover:text-port-error transition-colors" + className="min-h-[44px] min-w-[44px] flex items-center justify-center p-1 text-gray-500 hover:text-port-error transition-colors" > diff --git a/client/src/components/cos/tabs/MindTab.jsx b/client/src/components/cos/tabs/MindTab.jsx index 3066f99667..cc8e34b4d0 100644 --- a/client/src/components/cos/tabs/MindTab.jsx +++ b/client/src/components/cos/tabs/MindTab.jsx @@ -706,7 +706,7 @@ export default function MindTab() { {messageImages.map((image) => (
  • -
  • 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 && (
    {/* Add Task Form */} - + {/* User Tasks Sections */} {pendingUserTasksLocal.length === 0 && activeUserTasksLocal.length === 0 && blockedUserTasksLocal.length === 0 && completedUserTasksLocal.length === 0 ? ( @@ -262,7 +262,7 @@ export default function TasksTab({ tasks, agents = [], onRefresh, onTaskAdded, o >
    {pendingUserTasksLocal.map(task => ( - + ))}
    @@ -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 }) { {model.flexible.filter(node => expandedIds.has(node.id) && node.kind === 'task' && (node.totalAppCount || 0) > 0).map(node => (
    -
    {node.label} · app overrides
    - +
    {node.label} · per-app options
    +
    ))} diff --git a/client/src/components/cos/tabs/WorkflowTab.providers.test.jsx b/client/src/components/cos/tabs/WorkflowTab.providers.test.jsx index 10f6522b3b..50631014d0 100644 --- a/client/src/components/cos/tabs/WorkflowTab.providers.test.jsx +++ b/client/src/components/cos/tabs/WorkflowTab.providers.test.jsx @@ -61,7 +61,7 @@ const renderTab = async (providers) => { await act(async () => { render(); }); - const expand = await screen.findByRole('button', { name: /show app overrides for ux/i }); + const expand = await screen.findByRole('button', { name: /show per-app options for ux/i }); fireEvent.click(expand); }; diff --git a/client/src/components/cos/tabs/schedule/AppOverrideRow.jsx b/client/src/components/cos/tabs/schedule/AppOverrideRow.jsx index 1b168aeb84..9963c95919 100644 --- a/client/src/components/cos/tabs/schedule/AppOverrideRow.jsx +++ b/client/src/components/cos/tabs/schedule/AppOverrideRow.jsx @@ -8,10 +8,18 @@ import ToggleSwitch from '../../../ToggleSwitch'; import useFieldDraft from '../../../../hooks/useFieldDraft'; import { INTERVAL_LABELS, setMetadataOverride } from './scheduleConstants'; -const AppOverrideRow = memo(function AppOverrideRow({ app, taskType, globalIntervalType, globalTaskMetadata, managedAgentOptions, fileIssuesCapable, defaultFileIssues, doWorkRequiresWorktree, inheritedProviderText, providers, override, onUpdate }) { +const AppOverrideRow = memo(function AppOverrideRow({ app, taskType, globalIntervalType, globalTaskMetadata, managedAgentOptions, fileIssuesCapable, defaultFileIssues, doWorkRequiresWorktree, inheritedProviderText, providers, providersLoaded = true, override, onUpdate }) { const [updating, setUpdating] = useState(false); const [cronEditing, setCronEditing] = useState(false); const isEnabled = override?.enabled === true; + // The row's toggle is the app's ON/OFF switch for this scheduled task, not a + // "use my overrides" flag — every other control on the row is inert until it + // is on. Say so in the accessible name and the tooltip, since the switch + // itself carries no visible state text. + const enabledToggleLabel = `${taskType} enabled for ${app.name}: ${isEnabled ? 'on' : 'off'}`; + const enabledToggleTitle = isEnabled + ? `${taskType} runs for ${app.name} on the schedule set here. Turn off to stop scheduling it for this app.` + : `${taskType} does not run for ${app.name}. Turn on to schedule it for this app.`; const currentInterval = override?.interval || null; const hasCron = isCronExpression(currentInterval); // Same effective-value rule the AGENT_OPTIONS buttons use: this app's override @@ -105,20 +113,25 @@ const AppOverrideRow = memo(function AppOverrideRow({ app, taskType, globalInter } ); + const enabledToggle = ( + <> + Enabled + + + ); + return (
    {app.name} -
    - -
    +
    {enabledToggle}
    @@ -202,6 +215,7 @@ const AppOverrideRow = memo(function AppOverrideRow({ app, taskType, globalInter
    )} -
    - -
    +
    {enabledToggle}
    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 handleModelChange(e.target.value)} - disabled={updating} + disabled={updating || !providersLoaded} className="w-full bg-port-card border border-port-border rounded px-3 py-2 text-white text-sm" > {/* `availableModels` already carries a pin the provider no longer diff --git a/client/src/components/cos/tabs/schedule/GlobalConfigControls.test.jsx b/client/src/components/cos/tabs/schedule/GlobalConfigControls.test.jsx index 0ece7f2585..82d13bf51a 100644 --- a/client/src/components/cos/tabs/schedule/GlobalConfigControls.test.jsx +++ b/client/src/components/cos/tabs/schedule/GlobalConfigControls.test.jsx @@ -111,6 +111,17 @@ describe('GlobalConfigControls — After opening PR', () => { expect(screen.getByTestId('reviewer-picker')).toBeInTheDocument(); }); + it('keeps the reviewer picker for a claim flow, whose shipped metadata sets neither flag', () => { + // A claim PROMPT opens and merges its own PR and runs the reviewers itself, + // so the resolved list is operative even though `openPR` and `reviewLoop` are + // both false — which is exactly the shipped `claim-work` metadata. Hiding the + // picker here leaves a reviewer override that every claim obeys with no + // control anywhere that can clear it, while the claim surfaces tell the user + // to come here and do precisely that. + renderControls({ taskType: 'claim-work', taskMetadata: { useWorktree: false, openPR: false, claimFlow: true } }); + expect(screen.getByTestId('reviewer-picker')).toBeInTheDocument(); + }); + it('resets the task review override while preserving unrelated task metadata', () => { const onUpdate = renderControls({ taskMetadata: { diff --git a/client/src/components/cos/tabs/schedule/PerAppOverrideList.jsx b/client/src/components/cos/tabs/schedule/PerAppOverrideList.jsx index b82d9e2bc5..d1df669284 100644 --- a/client/src/components/cos/tabs/schedule/PerAppOverrideList.jsx +++ b/client/src/components/cos/tabs/schedule/PerAppOverrideList.jsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { providerModelLabel } from '../../../../utils/providers'; import AppOverrideRow from './AppOverrideRow'; -export default function PerAppOverrideList({ taskType, config, apps, providers, onUpdateOverride, onBulkToggleOverride }) { +export default function PerAppOverrideList({ taskType, config, apps, providers, providersLoaded = true, onUpdateOverride, onBulkToggleOverride }) { const [bulkUpdating, setBulkUpdating] = useState(false); const activeApps = apps?.filter(app => !app.archived) || []; const appOverrides = config.appOverrides || {}; @@ -26,12 +26,20 @@ export default function PerAppOverrideList({ taskType, config, apps, providers, return (
    -
    -

    Per-App Overrides

    +
    +
    +

    Per-App Options

    +

    + Each app's toggle turns {taskType} on or off for that app — + an app stays off until you switch it on here, whatever the rest of the row says. The other controls are + optional: leave one on Inherit and it follows the global defaults. +

    +
    @@ -58,6 +66,7 @@ export default function PerAppOverrideList({ taskType, config, apps, providers, doWorkRequiresWorktree={config.doWorkRequiresWorktree} inheritedProviderText={inheritedProviderText} providers={providers} + providersLoaded={providersLoaded} override={appOverrides[app.id]} onUpdate={onUpdateOverride} /> diff --git a/client/src/components/cos/tabs/schedule/PipelineStageConfig.jsx b/client/src/components/cos/tabs/schedule/PipelineStageConfig.jsx index c234e2c48b..260659c0ac 100644 --- a/client/src/components/cos/tabs/schedule/PipelineStageConfig.jsx +++ b/client/src/components/cos/tabs/schedule/PipelineStageConfig.jsx @@ -23,14 +23,17 @@ import { // Which providers on THIS install the stage's picker actually offers. The // posture half is server-published (`publicReviewPostures`, derived from the // vendor rows — no vendor names on the client); the rest is the picker's own -// visibility rule, reused so the "eligible" note can never list a provider -// the dropdown hides (switched off, hardware-incompatible), which is what left -// Stage 3 looking unconfigurable. +// visibility rule, reused so the notes below can never name a provider the +// dropdown hides (switched off, hardware-incompatible). The eligible set is +// not re-listed in the copy — the dropdown already IS that list. const eligibleProvidersFor = (providers, policy) => selectableProviders(providers, { allowed: policy.provider }); const providerNames = (providers) => providers.map((p) => p.name || p.id).join(', '); +// Constant now that the eligible set is left to the dropdown. +const NO_TOOL_STAGE_NOTE = "Tool-free stage. A local model must additionally report no tool-calling capability; a cloud model is held tool-free by the provider's own enforced flags. Leave the provider unset to use the first eligible one. It returns only a binary allowlist; the final stage never receives rejected content."; + // Every enabled CLI/TUI provider can run the actions stage; the note says which // of them the server additionally wraps in the vendor's own OS sandbox, so a // choice that relies on the disposable worktree alone is a visible one. @@ -39,23 +42,27 @@ const actionsStageNote = (eligibleProviders) => { const sandboxed = eligibleProviders.filter(isSandboxed); const worktreeOnly = eligibleProviders.filter((p) => !isSandboxed(p)); const isolation = worktreeOnly.length === 0 - ? ['Each runs headless inside its vendor\'s maintained OS sandbox.'] + ? ['Every selectable provider runs headless inside its vendor\'s maintained OS sandbox.'] : [ sandboxed.length > 0 && `OS-sandboxed by the vendor's own recipe: ${providerNames(sandboxed)}.`, `Headless with standard permissions, isolated by the disposable worktree only: ${providerNames(worktreeOnly)}.`, ].filter(Boolean); return [ - `Sandboxed stage. Eligible on this install: ${providerNames(eligibleProviders)}.`, + 'Sandboxed stage.', ...isolation, 'PortOS passes the selected provider, model, and thinking effort through, with no forge credential or configuration overlays; the deterministic coordinator owns comments, issue filing, CI triggers, and merges.', ].join(' '); }; -export default function PipelineStageConfig({ taskType, config, providers, onUpdate, updating, setUpdating }) { +export default function PipelineStageConfig({ taskType, config, providers, providersLoaded = true, onUpdate, updating, setUpdating }) { const stages = pipelineStages(config); const needsSecurityModelPolicy = taskType === 'pr-reviewer'; + // Every public-review stage — not just the tool-free gate — resolves a local + // provider's models from the daemon, so the fetch follows the postures rather + // than the task type. + const hasPublicReviewStage = stages.some((stage) => stagePublicReviewPosture(stage)); const { ollama, lmstudio, capabilitiesByBackend, loading: localModelsLoading } = useLocalModels({ - enabled: needsSecurityModelPolicy, + enabled: needsSecurityModelPolicy || hasPublicReviewStage, }); // One policy per posture. The provider half is server-derived; the model half // adds the authoritative no-tool capability check only for a local runtime, @@ -157,15 +164,29 @@ export default function PipelineStageConfig({ taskType, config, providers, onUpd const localBackend = localBackendForProvider(stageProvider); const localModelIds = localBackend === 'ollama' ? ollama : localBackend === 'lmstudio' ? lmstudio : []; // A LOCAL provider's installed-model list is the source of truth (its - // stored catalog is stale, and only an installed model has a probeable - // capability report). Every other provider uses its own catalog, so a - // cloud CLI stage can pick any model that provider offers. - const stageModels = isNoToolStage && localBackend - ? localModelIds.map(id => ({ - id, - name: id, - capabilities: capabilitiesByBackend?.[localBackend]?.[id], - })) + // stored catalog is a cached snapshot, and only an installed model has + // a probeable capability report). That holds for the sandboxed actions + // stage as much as the tool-free gate — offering the stale catalog + // there let a stage be pinned to a model the daemon no longer serves, + // and hid one that had just been pulled. Every other provider uses its + // own catalog, so a cloud CLI stage can pick any model it offers. + // + // `useLocalModels` reports BOTH "not fetched yet" and "daemon said + // nothing" as `[]`, so an empty list is not evidence the daemon serves + // no models. The two stages part ways on what to do about that. The + // tool-free gate must stay strict: its policy needs a probeable + // capability report, and a model with none is not selectable at all, + // so an empty list correctly offers nothing. The actions stage has no + // such gate, so it falls back to the record's catalog — otherwise a + // stopped daemon (or the in-flight window) renders an empty picker and + // drops the stage's own saved pin out of the dropdown. + const localStageModels = localModelIds.map(id => ({ + id, + name: id, + capabilities: capabilitiesByBackend?.[localBackend]?.[id], + })); + const stageModels = posture && localBackend && (isNoToolStage || localStageModels.length > 0) + ? localStageModels : effortAwareModelOptions(stageProvider, stage.model); const selectionPolicy = posture ? selectionPolicies[posture] : undefined; const stageProviderId = stage.providerId || ''; @@ -203,6 +224,7 @@ export default function PipelineStageConfig({ taskType, config, providers, onUpd )} {!isSecurityStage && ( {localModelsLoading ? 'Loading installed local model capability reports…' - : `Tool-free stage. Eligible on this install: ${eligibleProviders.map((p) => p.name || p.id).join(', ')}. A local model must additionally report no tool-calling capability; a cloud model is held tool-free by the provider's own enforced flags. Leave the provider unset to use the first eligible one. It returns only a binary allowlist; the final stage never receives rejected content.`} + : NO_TOOL_STAGE_NOTE}

    )} {isActionsStage && eligibleProviders?.length > 0 && (

    - {actionsStageNote(eligibleProviders)} + {/* On a local provider this stage's model list comes from the + daemon too, so say so while it loads rather than leaving an + empty dropdown with a note that reads as if it were ready. */} + {localBackend && localModelsLoading + ? 'Loading installed local models…' + : actionsStageNote(eligibleProviders)}

    )}
    diff --git a/client/src/components/cos/tabs/schedule/PipelineStageConfig.test.jsx b/client/src/components/cos/tabs/schedule/PipelineStageConfig.test.jsx index 0b0c8d9dcc..fbadc00fe2 100644 --- a/client/src/components/cos/tabs/schedule/PipelineStageConfig.test.jsx +++ b/client/src/components/cos/tabs/schedule/PipelineStageConfig.test.jsx @@ -1,21 +1,30 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; -vi.mock('../../../../hooks/useLocalModels', () => ({ - default: () => ({ - ollama: ['safe-model', 'tool-model'], - lmstudio: [], - capabilitiesByBackend: { - ollama: { - 'safe-model': ['chat'], - 'tool-model': ['chat', 'tools'], - }, +// The installed-local-model report, mutable so a test can model a daemon that +// is stopped or still loading (both of which `useLocalModels` reports as `[]`). +const INSTALLED_LOCAL_MODELS = { + ollama: ['safe-model', 'tool-model'], + lmstudio: [], + capabilitiesByBackend: { + ollama: { + 'safe-model': ['chat'], + 'tool-model': ['chat', 'tools'], }, - loading: false, - }), + }, + loading: false, +}; +let localModels = { ...INSTALLED_LOCAL_MODELS }; + +vi.mock('../../../../hooks/useLocalModels', () => ({ + default: () => localModels, })); +beforeEach(() => { + localModels = { ...INSTALLED_LOCAL_MODELS }; +}); + import PipelineStageConfig from './PipelineStageConfig'; const STAGES = [ @@ -174,14 +183,16 @@ describe('PipelineStageConfig — posture-driven eligibility', () => { expect([...providerSelects[1].options].map((o) => o.value)).toEqual(['', 'grok-cli']); // A non-local provider's own catalog is selectable — the installed-local // model list only applies where PortOS can probe capabilities. - expect(screen.getAllByText(/Eligible on this install: Grok/).length).toBe(2); + expect(screen.getByText(/^Tool-free stage\./)).toBeInTheDocument(); + expect(screen.getByText(/^Sandboxed stage\./)).toBeInTheDocument(); }); // The bug behind #5906's blocked run: the CLI records were disabled and the // TUI records enabled, so the note listed three "eligible" providers while // the dropdown offered only the placeholder. Eligibility now follows what the - // server publishes for the ENABLED records — TUI siblings included. - it('lists only enabled providers as eligible, matching what the dropdown offers', () => { + // server publishes for the ENABLED records — TUI siblings included — and the + // dropdown is the only place that list is rendered. + it('offers only enabled providers in the dropdown, and names none of them in the note', () => { renderWith([ { id: 'codex', name: 'Codex CLI', type: 'cli', command: 'codex', enabled: false, models: ['gpt-5.6'], publicReviewPostures: ['no-tool', 'sandboxed-actions'] }, { id: 'codex-tui', name: 'Codex TUI', type: 'tui', command: 'codex', enabled: true, models: ['gpt-5.6'], publicReviewPostures: ['no-tool', 'sandboxed-actions'] }, @@ -189,9 +200,11 @@ describe('PipelineStageConfig — posture-driven eligibility', () => { ]); const providerSelects = screen.getAllByLabelText('Provider'); expect([...providerSelects[1].options].map((o) => o.value)).toEqual(['', 'codex-tui']); - const note = screen.getByText(/Sandboxed stage\. Eligible on this install:/); - expect(note.textContent).toContain('Eligible on this install: Codex TUI.'); + // The dropdown IS the eligible list; the note must not re-name providers, + // least of all the disabled ones. + const note = screen.getByText(/^Sandboxed stage\./); expect(note.textContent).not.toContain('Grok Build CLI'); + expect(note.textContent).not.toContain('Codex CLI'); }); it('warns instead of silently offering nothing when a stage has no eligible provider', () => { @@ -212,9 +225,96 @@ describe('PipelineStageConfig — posture-driven eligibility', () => { const providerSelects = screen.getAllByLabelText('Provider'); expect([...providerSelects[0].options].map((o) => o.value)).toEqual(['', 'codex-tui']); expect([...providerSelects[1].options].map((o) => o.value)).toEqual(['', 'codex-tui', 'opencode-tui']); - const note = screen.getByText(/Sandboxed stage\. Eligible on this install:/); - expect(note.textContent).toContain('Eligible on this install: Codex TUI, OpenCode TUI.'); + const note = screen.getByText(/^Sandboxed stage\./); + // The eligible set is the dropdown's job; the note only separates the + // vendor-sandboxed providers from the worktree-only ones. + expect(note.textContent).not.toContain('Eligible on this install'); expect(note.textContent).toContain("OS-sandboxed by the vendor's own recipe: Codex TUI."); expect(note.textContent).toContain('isolated by the disposable worktree only: OpenCode TUI.'); }); + + // A local runtime's daemon is the authority on what it serves; the provider + // record's `models` array is a cached snapshot. The tool-free gate already + // read the daemon, but the sandboxed actions stage read the snapshot — so a + // stage on a local provider could only be pinned to models that had since + // been removed, and never to one just pulled. + it('offers the installed local models for a local-backed ACTIONS stage', () => { + const localProvider = { + id: 'opencode-ollama-tui', + name: 'OpenCode Ollama TUI', + type: 'tui', + command: 'opencode', + models: ['stale-cached-model'], + publicReviewPostures: ['no-tool', 'sandboxed-actions'], + publicReviewEnforcedPostures: ['no-tool'], + }; + render( + + {}} + /> + , + ); + + const modelSelects = screen.getAllByLabelText('Model'); + // The daemon's installed models, NOT the record's `stale-cached-model`. + expect([...modelSelects[1].options].map((o) => o.value)).toEqual(['', 'safe-model', 'tool-model']); + }); +}); + +// `useLocalModels` reports "not fetched yet" and "the daemon listed nothing" +// identically, as `[]` — so an empty list is not evidence the daemon serves no +// models. The actions stage has no capability gate, so it must fall back to the +// record's catalog rather than render an empty picker that also drops the +// stage's own saved pin. The tool-free gate deliberately does NOT: a model with +// no probeable capability report is not selectable there at all. +describe('PipelineStageConfig — local daemon unreachable', () => { + const LOCAL = { + id: 'opencode-ollama-tui', + name: 'OpenCode Ollama TUI', + type: 'tui', + command: 'opencode', + models: ['cached-a', 'cached-b'], + publicReviewPostures: ['no-tool', 'sandboxed-actions'], + publicReviewEnforcedPostures: ['no-tool'], + }; + + it('falls back to the record catalog for the actions stage, but not for the gate', () => { + localModels = { ollama: [], lmstudio: [], capabilitiesByBackend: {}, loading: false }; + render( + + {}} + /> + , + ); + const modelSelects = screen.getAllByLabelText('Model'); + // The gate offers nothing from the catalog — `cached-a` is present only + // because ProviderModelSelector keeps a disallowed *selected* value visible + // rather than blanking the control, and `cached-b` proves the list itself + // was not consulted. + expect([...modelSelects[0].options].map((o) => o.value)).toEqual(['', 'cached-a']); + // The actions stage gets the whole record catalog instead of an empty + // picker that would also drop its own saved pin (nothing re-adds it there: + // its policy allows every model, so the disallowed affordance never fires). + expect([...modelSelects[1].options].map((o) => o.value)).toEqual(['', 'cached-a', 'cached-b']); + }); }); diff --git a/client/src/components/cos/tabs/schedule/TaskConfigDrawer.jsx b/client/src/components/cos/tabs/schedule/TaskConfigDrawer.jsx index 3ff076578f..9626905092 100644 --- a/client/src/components/cos/tabs/schedule/TaskConfigDrawer.jsx +++ b/client/src/components/cos/tabs/schedule/TaskConfigDrawer.jsx @@ -16,8 +16,9 @@ import PerAppOverrideList from './PerAppOverrideList'; // - Stage config — per-stage provider/model (only when the task has a // pipeline; count = number of stages) // - Global defaults — schedule/provider/prompt controls (always present) -// - Per-app overrides — per-app enablement (only when there are active apps; -// count = number of active apps) +// - Per-app options — turns the task on/off per app, plus that app's +// optional per-app settings (only when there are +// active apps; count = number of active apps) // The active tab lives in the `taskTab` URL param so it survives reload and is // shareable. TaskHeader (identity + badges) stays at the top of every tab. export default function TaskConfigDrawer({ @@ -28,6 +29,7 @@ export default function TaskConfigDrawer({ onUpdate, onTrigger, providers, + providersLoaded, activeProviderId, apps, onUpdateOverride, @@ -44,11 +46,11 @@ export default function TaskConfigDrawer({ const hasOverrides = activeApps.length > 0; // Tabs are dynamic: a task without a pipeline hides Stage config, and an - // install with no active apps hides Per-app overrides — never an empty tab. + // install with no active apps hides Per-app options — never an empty tab. const tabs = [ hasStages && { id: 'stages', label: 'Stage config', count: stages.length }, { id: 'global', label: 'Global defaults' }, - hasOverrides && { id: 'overrides', label: 'Per-app overrides', count: activeApps.length }, + hasOverrides && { id: 'overrides', label: 'Per-app options', count: activeApps.length }, ].filter(Boolean); const tabIds = tabs.map(t => t.id); const defaultTab = hasStages ? 'stages' : 'global'; @@ -75,6 +77,7 @@ export default function TaskConfigDrawer({ taskType={taskType} config={config} providers={providers} + providersLoaded={providersLoaded} onUpdate={onUpdate} updating={updating} setUpdating={setUpdating} @@ -89,6 +92,7 @@ export default function TaskConfigDrawer({ onTrigger={onTrigger} category="appImprovement" providers={providers} + providersLoaded={providersLoaded} activeProviderId={activeProviderId} apps={apps} updating={updating} @@ -105,6 +109,7 @@ export default function TaskConfigDrawer({ config={config} apps={apps} providers={providers} + providersLoaded={providersLoaded} onUpdateOverride={onUpdateOverride} onBulkToggleOverride={onBulkToggleOverride} /> diff --git a/client/src/components/cos/tabs/schedule/TaskConfigDrawer.test.jsx b/client/src/components/cos/tabs/schedule/TaskConfigDrawer.test.jsx index 2c6f0f9621..a8a9e09608 100644 --- a/client/src/components/cos/tabs/schedule/TaskConfigDrawer.test.jsx +++ b/client/src/components/cos/tabs/schedule/TaskConfigDrawer.test.jsx @@ -65,10 +65,10 @@ describe('TaskConfigDrawer tabbed layout', () => { renderDrawer(); expect(screen.getByRole('tab', { name: /Stage config/ })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: /Global defaults/ })).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: /Per-app overrides/ })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /Per-app options/ })).toBeInTheDocument(); // Counts surface: 2 stages, 1 active (non-archived) app. expect(screen.getByRole('tab', { name: /Stage config/ })).toHaveTextContent('2'); - expect(screen.getByRole('tab', { name: /Per-app overrides/ })).toHaveTextContent('1'); + expect(screen.getByRole('tab', { name: /Per-app options/ })).toHaveTextContent('1'); }); it('opens on Stage config and mounts only the active tab section', () => { @@ -78,13 +78,13 @@ describe('TaskConfigDrawer tabbed layout', () => { expect(screen.queryByTestId('override-list')).not.toBeInTheDocument(); }); - it('switches to Global defaults and Per-app overrides on tab click', () => { + it('switches to Global defaults and Per-app options on tab click', () => { renderDrawer(); fireEvent.click(screen.getByRole('tab', { name: /Global defaults/ })); expect(screen.getByTestId('global-config')).toHaveTextContent('global:do:next'); expect(screen.queryByTestId('stage-config')).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('tab', { name: /Per-app overrides/ })); + fireEvent.click(screen.getByRole('tab', { name: /Per-app options/ })); expect(screen.getByTestId('override-list')).toHaveTextContent('overrides:do:next'); expect(screen.queryByTestId('global-config')).not.toBeInTheDocument(); }); @@ -106,9 +106,9 @@ describe('TaskConfigDrawer tabbed layout', () => { expect(screen.getByTestId('global-config')).toBeInTheDocument(); }); - it('hides the Per-app overrides tab when there are no active apps', () => { + it('hides the Per-app options tab when there are no active apps', () => { renderDrawer({ apps: [{ id: 'a', name: 'A', archived: true }] }); - expect(screen.queryByRole('tab', { name: /Per-app overrides/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: /Per-app options/ })).not.toBeInTheDocument(); }); it('renders nothing when config is not yet loaded', () => { diff --git a/client/src/components/cos/tabs/schedule/TaskModelQuickControls.jsx b/client/src/components/cos/tabs/schedule/TaskModelQuickControls.jsx index 673fde41a8..39cea3fe21 100644 --- a/client/src/components/cos/tabs/schedule/TaskModelQuickControls.jsx +++ b/client/src/components/cos/tabs/schedule/TaskModelQuickControls.jsx @@ -7,7 +7,7 @@ import ProviderModelSelector from '../../../ProviderModelSelector'; // // `highlightToolUse` is on because a scheduled task IS an agent run: a task // pinned to a local model that can't call tools narrates instead of working. -export default function TaskModelQuickControls({ pins, providers, disabled = false }) { +export default function TaskModelQuickControls({ pins, providers, loading = false, disabled = false }) { const { providerId, model, effort, effectiveProviderId, defaultProviderLabel, availableModels, saving, changeProvider, changeModel, changeEffort, @@ -30,6 +30,7 @@ export default function TaskModelQuickControls({ pins, providers, disabled = fal alwaysShowModel compact highlightToolUse + loading={loading} disabled={disabled || saving} />
    diff --git a/client/src/components/dashboard/LayoutEditor.jsx b/client/src/components/dashboard/LayoutEditor.jsx index 0adb2ba0db..0043877561 100644 --- a/client/src/components/dashboard/LayoutEditor.jsx +++ b/client/src/components/dashboard/LayoutEditor.jsx @@ -327,9 +327,9 @@ export default function LayoutEditor({ layouts, activeLayoutId, limits, onClose, {meta?.label ?? id} {!meta && (unknown — skipped)} - - - + + + ); })} diff --git a/client/src/components/dashboard/builtins/DailyDriverWidget.jsx b/client/src/components/dashboard/builtins/DailyDriverWidget.jsx index 553e627ae8..a9e87ea96d 100644 --- a/client/src/components/dashboard/builtins/DailyDriverWidget.jsx +++ b/client/src/components/dashboard/builtins/DailyDriverWidget.jsx @@ -118,7 +118,7 @@ export default function DailyDriverWidget({ dashboardState }) { disabled={dismissing} aria-label="Dismiss for today" title="Dismiss for today" - className="ml-auto p-1 rounded text-gray-500 hover:text-white hover:bg-port-border/60 disabled:opacity-50" + className="min-h-[44px] min-w-[44px] inline-flex items-center justify-center ml-auto p-1 rounded text-gray-500 hover:text-white hover:bg-port-border/60 disabled:opacity-50" > diff --git a/client/src/components/digital-twin/ListEnrichment.jsx b/client/src/components/digital-twin/ListEnrichment.jsx index a812b0cefc..5a6afc2038 100644 --- a/client/src/components/digital-twin/ListEnrichment.jsx +++ b/client/src/components/digital-twin/ListEnrichment.jsx @@ -194,7 +194,7 @@ export default function ListEnrichment({ {items.length > 1 && ( diff --git a/client/src/components/digital-twin/tabs/AutobiographyTab.jsx b/client/src/components/digital-twin/tabs/AutobiographyTab.jsx index 15a290b81b..1bdb270e15 100644 --- a/client/src/components/digital-twin/tabs/AutobiographyTab.jsx +++ b/client/src/components/digital-twin/tabs/AutobiographyTab.jsx @@ -559,7 +559,7 @@ export default function AutobiographyTab({ onRefresh }) {
    -
    @@ -373,6 +373,7 @@ export default function LoomEpisodeOutlinePlanner({ layout="stacked" disabled={busy || generating || saving || validating || reviewing || expanding} modelDisabled={busy || generating || saving || validating || reviewing || expanding} + loading={providersLoading} emptyProviderOption="Default (outline stage or active provider)" emptyModelOption="Default model" alwaysShowModel={!!route.providerId} diff --git a/client/src/components/fableloom/LoomHostedSessionModal.jsx b/client/src/components/fableloom/LoomHostedSessionModal.jsx index 6e9d1a6349..7e3f7f417e 100644 --- a/client/src/components/fableloom/LoomHostedSessionModal.jsx +++ b/client/src/components/fableloom/LoomHostedSessionModal.jsx @@ -219,7 +219,7 @@ export default function LoomHostedSessionModal({ - - + + +