From 088102386608254c6d8bda6dcceb13ad7bc946bd Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:14:41 -0700 Subject: [PATCH] feat(orchestration): chat rework, Agent graph, global Kanban, presence (#4722) Co-authored-by: github-actions[bot] --- .github/workflows/android-compile.yml | 18 +- .github/workflows/build-ci-image.yml | 6 +- .github/workflows/build-desktop.yml | 25 +- .github/workflows/ci-full.yml | 28 +- .github/workflows/ci-lite.yml | 42 +- .github/workflows/e2e-agent-review.yml | 14 +- .github/workflows/e2e-playwright.yml | 6 +- .github/workflows/e2e-reusable.yml | 111 +-- .github/workflows/ios-appstore.yml | 12 +- .github/workflows/ios-compile.yml | 8 +- .github/workflows/pr-quality.yml | 8 +- .github/workflows/promote-main-to-release.yml | 2 +- .github/workflows/release-notes-preview.yml | 8 +- .github/workflows/release-packages.yml | 23 +- .github/workflows/release-production.yml | 38 +- .github/workflows/release-staging.yml | 16 +- .github/workflows/tauri-cef-pin-guard.yml | 2 +- .github/workflows/test-reusable.yml | 53 +- Cargo.lock | 6 +- Cargo.toml | 2 +- app/package.json | 2 +- app/scripts/e2e-run-all-flows.sh | 31 +- app/src-tauri/Cargo.lock | 34 +- app/src-tauri/Cargo.toml | 2 +- app/src-tauri/tauri.conf.json | 2 +- .../components/intelligence/MemoryGraph.tsx | 95 ++- app/src/components/intelligence/PixiGraph.tsx | 28 +- .../intelligence/memoryGraphLayout.ts | 8 +- .../intelligence/pixiGraphRenderer.ts | 84 ++- app/src/components/layout/TwoPaneNav.tsx | 11 +- .../orchestration/ActiveSubagentsRail.tsx | 188 +++++ .../orchestration/AgentChatPanel.tsx | 710 +++++++++++------- .../orchestration/ConnectionsPanel.tsx | 25 +- .../orchestration/OrchestratorTaskBoard.tsx | 191 +++++ .../orchestration/OverviewPanel.tsx | 110 +++ .../__tests__/AgentChatPanel.test.tsx | 17 +- .../components/TaskKanbanBoard.tsx | 41 +- .../human/Mascot/ManifestRiveMascot.test.tsx | 11 +- app/src/lib/i18n/ar.ts | 23 + app/src/lib/i18n/bn.ts | 23 + app/src/lib/i18n/de.ts | 23 + app/src/lib/i18n/en.ts | 23 + app/src/lib/i18n/es.ts | 23 + app/src/lib/i18n/fr.ts | 23 + app/src/lib/i18n/hi.ts | 23 + app/src/lib/i18n/id.ts | 23 + app/src/lib/i18n/it.ts | 23 + app/src/lib/i18n/ko.ts | 23 + app/src/lib/i18n/pl.ts | 23 + app/src/lib/i18n/pt.ts | 23 + app/src/lib/i18n/ru.ts | 23 + app/src/lib/i18n/zh-CN.ts | 23 + .../lib/orchestration/orchestrationClient.ts | 8 + app/src/pages/OrchestrationPage.tsx | 229 ++++-- app/src/services/api/todosApi.ts | 8 + app/src/utils/tauriCommands/memoryTree.ts | 6 + app/test/e2e/helpers/telegram.ts | 38 +- .../e2e/specs/accounts-provider-modal.spec.ts | 10 +- .../specs/chat-harness-wallet-flow.spec.ts | 15 +- .../specs/harness-channel-bridge-flow.spec.ts | 89 +-- .../specs/harness-composio-tool-flow.spec.ts | 5 + .../specs/harness-cron-prompt-flow.spec.ts | 4 + .../specs/harness-search-tool-flow.spec.ts | 7 +- app/test/e2e/specs/skill-lifecycle.spec.ts | 6 +- .../specs/harness-search-tool-flow.spec.ts | 2 +- .../playwright/specs/skill-lifecycle.spec.ts | 10 +- app/test/wdio.conf.ts | 4 + .../routes/__tests__/stateful.test.mjs | 33 + scripts/mock-api/routes/integrations.mjs | 33 + src/openhuman/agent/tools/todo.rs | 9 + .../channels/runtime/test_support.rs | 1 - src/openhuman/flows/builder_tools.rs | 6 +- src/openhuman/memory_store/content/raw.rs | 2 +- src/openhuman/orchestration/ingest.rs | 4 + src/openhuman/orchestration/mod.rs | 1 + src/openhuman/orchestration/presence.rs | 99 +++ src/openhuman/orchestration/schemas.rs | 38 +- src/openhuman/orchestration/store.rs | 71 ++ src/openhuman/tinyplace/agent_tools/help.rs | 2 +- src/openhuman/todos/ops.rs | 6 + src/openhuman/tools/impl/system/shell.rs | 2 +- ...agent_prompts_subagent_raw_coverage_e2e.rs | 8 +- vendor/tinyplace | 2 +- 83 files changed, 2422 insertions(+), 676 deletions(-) create mode 100644 app/src/components/orchestration/ActiveSubagentsRail.tsx create mode 100644 app/src/components/orchestration/OrchestratorTaskBoard.tsx create mode 100644 app/src/components/orchestration/OverviewPanel.tsx create mode 100644 src/openhuman/orchestration/presence.rs diff --git a/.github/workflows/android-compile.yml b/.github/workflows/android-compile.yml index 7eff50674..e6989f0bf 100644 --- a/.github/workflows/android-compile.yml +++ b/.github/workflows/android-compile.yml @@ -90,7 +90,7 @@ jobs: version_code: ${{ steps.version.outputs.version_code }} steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ inputs.build_ref || github.ref }} fetch-depth: 1 @@ -118,7 +118,7 @@ jobs: java-version: "17" - name: Set up Android SDK - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@v4 - name: Install Android SDK packages shell: bash @@ -130,10 +130,12 @@ jobs: "ndk;27.0.12077973" - name: Set up pnpm - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@v6 + with: + version: 10.10.0 - name: Set up Node - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: "24" cache: "pnpm" @@ -188,7 +190,7 @@ jobs: echo "Android versionName=$version_name versionCode=$version_code" - name: Upload unsigned AAB artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: openhuman-android-aab-unsigned path: ${{ steps.aab.outputs.path }} @@ -204,7 +206,7 @@ jobs: environment: Production steps: - name: Download unsigned AAB - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: openhuman-android-aab-unsigned path: android-release @@ -250,7 +252,7 @@ jobs: echo "path=signed/openhuman-android-release.aab" >> "$GITHUB_OUTPUT" - name: Upload signed AAB artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: openhuman-android-aab-signed path: ${{ steps.sign.outputs.path }} @@ -258,7 +260,7 @@ jobs: retention-days: 30 - name: Upload to Google Play - uses: r0adkll/upload-google-play@v1.1.4 + uses: r0adkll/upload-google-play@v1.1.5 with: serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} packageName: com.openhuman.app diff --git a/.github/workflows/build-ci-image.yml b/.github/workflows/build-ci-image.yml index 753fe6f04..4b2700733 100644 --- a/.github/workflows/build-ci-image.yml +++ b/.github/workflows/build-ci-image.yml @@ -13,18 +13,18 @@ jobs: environment: Production steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: # vendored tauri-cef fork is compiled into the image submodules: recursive - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: .github/Dockerfile diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index b9492b33c..3420b76c9 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -133,7 +133,7 @@ jobs: VITE_TELEGRAM_BOT_USERNAME: ${{ inputs.telegram_bot_username }} steps: - name: Checkout build ref - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ inputs.build_ref }} fetch-depth: 1 @@ -144,15 +144,16 @@ jobs: echo "[build-desktop] tag=${{ inputs.tag }} build_ref=${{ inputs.build_ref }}" - name: Set Xcode version if: matrix.settings.platform == 'macos-latest' - uses: maxim-lobanov/setup-xcode@v1 + uses: maxim-lobanov/setup-xcode@v1.7.0 with: xcode-version: latest-stable - name: Setup pnpm - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@v6 with: + version: 10.10.0 cache: true - name: Setup Node.js 24.x - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: 24.x - name: Install Rust (rust-toolchain.toml) @@ -192,7 +193,7 @@ jobs: run: | echo "hash=$(tail -n +8 Cargo.lock | openssl dgst -sha256 | awk '{print $2}')" >> "$GITHUB_OUTPUT" - name: Cache Cargo registry and git sources - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: | ~/.cargo/registry @@ -206,7 +207,7 @@ jobs: # runs. Default path is `dirs::cache_dir()/tauri-cef`. - name: Cache CEF binary distribution (unix) if: matrix.settings.platform != 'windows-latest' - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: | ~/Library/Caches/tauri-cef @@ -216,7 +217,7 @@ jobs: cef-${{ matrix.settings.target }}- - name: Cache CEF binary distribution (windows) if: matrix.settings.platform == 'windows-latest' - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/AppData/Local/tauri-cef key: cef-${{ matrix.settings.target }}-${{ hashFiles('app/src-tauri/Cargo.toml') }} @@ -236,14 +237,14 @@ jobs: - name: Cache vendored tauri-cli binary (unix) if: matrix.settings.platform != 'windows-latest' id: tauri-cli-cache-unix - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.cargo/bin/cargo-tauri key: vendored-tauri-cli-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('app/src-tauri/vendor/tauri-cef/crates/tauri-cli/Cargo.toml') }} - name: Cache vendored tauri-cli binary (windows) if: matrix.settings.platform == 'windows-latest' id: tauri-cli-cache-windows - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.cargo/bin/cargo-tauri.exe key: vendored-tauri-cli-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('app/src-tauri/vendor/tauri-cef/crates/tauri-cli/Cargo.toml') }} @@ -299,7 +300,7 @@ jobs: # `KEYPAIR_ALIAS` (Windows DigiCert sign command). Pubkey + endpoint # come from the static `app/src-tauri/tauri.conf.json`. id: config-overrides - uses: actions/github-script@v8 + uses: actions/github-script@v9 env: BASE_URL: ${{ inputs.base_url }} WITH_UPDATER: ${{ inputs.with_updater && 'true' || 'false' }} @@ -753,7 +754,7 @@ jobs: # ---- Actions-artifact uploads (when not pushing to a GH Release) ------ - name: Upload desktop bundles as Actions artifact if: "!inputs.with_release_upload" - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v7 with: name: desktop-bundles-${{ matrix.settings.platform }}-${{ matrix.settings.artifact_suffix @@ -763,7 +764,7 @@ jobs: target/${{ matrix.settings.target }}/${{ inputs.build_profile }}/bundle/** - name: Upload standalone CLI artifact if: inputs.build_sidecar && !inputs.with_release_upload - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v7 with: name: standalone-bins-${{ matrix.settings.platform }}-${{ matrix.settings.artifact_suffix diff --git a/.github/workflows/ci-full.yml b/.github/workflows/ci-full.yml index df7871573..a11644b21 100644 --- a/.github/workflows/ci-full.yml +++ b/.github/workflows/ci-full.yml @@ -82,7 +82,7 @@ jobs: df -h /__w || true - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 1 persist-credentials: false @@ -90,7 +90,7 @@ jobs: - name: Cache pnpm store id: pnpm-cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: .pnpm-store key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -111,7 +111,7 @@ jobs: - name: Save pnpm store cache if: always() && steps.pnpm-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: .pnpm-store key: ${{ steps.pnpm-cache.outputs.cache-primary-key }} @@ -139,7 +139,7 @@ jobs: CARGO_INCREMENTAL: "0" steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 1 persist-credentials: false @@ -147,7 +147,7 @@ jobs: - name: Cache pnpm store id: pnpm-cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: .pnpm-store key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -163,7 +163,7 @@ jobs: - name: Restore cached Playwright E2E artifact id: playwright-artifact-cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: .ci/artifacts/e2e-playwright-linux.tar.gz key: e2e-playwright-linux-${{ hashFiles('src/**', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', 'app/src/**', 'app/public/**', 'app/index.html', 'app/vite.config.*', 'app/tailwind.config.*', 'app/postcss.config.*', 'app/package.json', 'pnpm-lock.yaml', 'app/scripts/e2e-web-build.sh') }} @@ -174,7 +174,7 @@ jobs: - name: Save pnpm store cache if: always() && steps.pnpm-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: .pnpm-store key: ${{ steps.pnpm-cache.outputs.cache-primary-key }} @@ -210,13 +210,13 @@ jobs: - name: Save Playwright E2E artifact cache if: always() && steps.playwright-artifact-cache.outputs.cache-hit != 'true' && hashFiles('.ci/artifacts/e2e-playwright-linux.tar.gz') != '' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: .ci/artifacts/e2e-playwright-linux.tar.gz key: ${{ steps.playwright-artifact-cache.outputs.cache-primary-key }} - name: Upload Playwright E2E artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: e2e-playwright-linux-${{ github.run_id }} path: .ci/artifacts/e2e-playwright-linux.tar.gz @@ -236,7 +236,7 @@ jobs: continue-on-error: true steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 1 persist-credentials: false @@ -244,7 +244,7 @@ jobs: - name: Cache pnpm store id: pnpm-cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: .pnpm-store key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -256,13 +256,13 @@ jobs: - name: Save pnpm store cache if: always() && steps.pnpm-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: .pnpm-store key: ${{ steps.pnpm-cache.outputs.cache-primary-key }} - name: Download Playwright E2E artifact - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: e2e-playwright-linux-${{ github.run_id }} path: . @@ -289,7 +289,7 @@ jobs: - name: Upload Playwright E2E failure artifacts if: failure() - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: e2e-playwright-failure-logs-${{ github.run_id }} path: | diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index d188b3a77..2686f8cf6 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -75,7 +75,7 @@ jobs: coverage: ${{ steps.filter.outputs.frontend == 'true' || steps.filter.outputs['rust-core'] == 'true' || steps.filter.outputs['rust-tauri'] == 'true' }} steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 - name: Detect changed paths id: filter @@ -193,10 +193,10 @@ jobs: image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0 steps: - name: Checkout code - # Pinned to the v5 commit SHA for supply-chain hardening (CodeRabbit + # Pinned to the v7 commit SHA for supply-chain hardening (CodeRabbit # review on #3892). The combined frontend lane keeps that hardening # from the former docs-drift job. - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: fetch-depth: 1 persist-credentials: false @@ -222,7 +222,7 @@ jobs: - name: Cache pnpm store id: pnpm-cache if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.i18n == 'true' - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: .pnpm-store key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -235,7 +235,7 @@ jobs: - name: Save pnpm store cache if: always() && (needs.changes.outputs.frontend == 'true' || needs.changes.outputs.i18n == 'true') && steps.pnpm-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: .pnpm-store key: ${{ steps.pnpm-cache.outputs.cache-primary-key }} @@ -290,7 +290,7 @@ jobs: - name: Upload frontend lcov if: needs.changes.outputs.frontend == 'true' - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: lcov-frontend path: app/coverage/lcov.info @@ -309,7 +309,7 @@ jobs: CARGO_INCREMENTAL: "0" steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 1 persist-credentials: false @@ -376,7 +376,7 @@ jobs: df -h /__w || true - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 1 persist-credentials: false @@ -420,7 +420,7 @@ jobs: df -h /__w || true - name: Upload core lcov - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: lcov-rust-core path: lcov-core.info @@ -462,7 +462,7 @@ jobs: df -h /__w || true - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 1 persist-credentials: false @@ -481,7 +481,7 @@ jobs: - name: Cache CEF binary distribution id: cef-cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: | ~/Library/Caches/tauri-cef @@ -503,7 +503,7 @@ jobs: - name: Save CEF binary distribution cache if: always() && steps.cef-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: | ~/Library/Caches/tauri-cef @@ -511,7 +511,7 @@ jobs: key: ${{ steps.cef-cache.outputs.cache-primary-key }} - name: Upload tauri lcov - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: lcov-rust-tauri path: lcov-tauri.info @@ -528,14 +528,14 @@ jobs: image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 1 persist-credentials: false - name: Cache pnpm store id: pnpm-cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: .pnpm-store key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -549,7 +549,7 @@ jobs: - name: Save pnpm store cache if: always() && steps.pnpm-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: .pnpm-store key: ${{ steps.pnpm-cache.outputs.cache-primary-key }} @@ -567,7 +567,7 @@ jobs: image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 1 persist-credentials: false @@ -588,7 +588,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 1 persist-credentials: false @@ -678,7 +678,7 @@ jobs: - name: Checkout code if: needs.changes.outputs.coverage == 'true' - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -708,7 +708,7 @@ jobs: - name: Download all lcov artifacts if: needs.changes.outputs.coverage == 'true' - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: path: lcov-artifacts pattern: lcov-* @@ -737,7 +737,7 @@ jobs: - name: Upload diff-cover report if: always() && needs.changes.outputs.coverage == 'true' - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: diff-coverage-report path: | diff --git a/.github/workflows/e2e-agent-review.yml b/.github/workflows/e2e-agent-review.yml index d49e74a65..9df17282f 100644 --- a/.github/workflows/e2e-agent-review.yml +++ b/.github/workflows/e2e-agent-review.yml @@ -30,7 +30,7 @@ jobs: timeout-minutes: 30 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 1 submodules: recursive @@ -45,12 +45,13 @@ jobs: fi - name: Setup pnpm if: steps.gate.outputs.present == 'true' - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@v6 with: + version: 10.10.0 cache: true - name: Setup Node.js 24.x if: steps.gate.outputs.present == 'true' - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: 24.x - name: Install Rust (rust-toolchain.toml) @@ -74,13 +75,14 @@ jobs: echo "hash=$(tail -n +8 Cargo.lock | openssl dgst -sha256 | awk '{print $2}')" >> "$GITHUB_OUTPUT" - name: Cache Cargo registry and build if: steps.gate.outputs.present == 'true' - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: | ~/.cargo/registry ~/.cargo/git target - key: ${{ runner.os }}-e2e-agentreview-cargo-${{ steps.cargo-lock-fingerprint.outputs.hash + key: + ${{ runner.os }}-e2e-agentreview-cargo-${{ steps.cargo-lock-fingerprint.outputs.hash }} restore-keys: | ${{ runner.os }}-e2e-agentreview-cargo- @@ -128,7 +130,7 @@ jobs: fi - name: Upload E2E artifacts if: always() && steps.gate.outputs.present == 'true' - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v7 with: name: e2e-agent-review-artifacts path: | diff --git a/.github/workflows/e2e-playwright.yml b/.github/workflows/e2e-playwright.yml index 6c80b6fdf..b99adf648 100644 --- a/.github/workflows/e2e-playwright.yml +++ b/.github/workflows/e2e-playwright.yml @@ -29,14 +29,14 @@ jobs: timeout-minutes: 30 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 1 persist-credentials: false submodules: recursive - name: Cache pnpm store - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.local/share/pnpm/store key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -75,7 +75,7 @@ jobs: - name: Upload Playwright E2E failure artifacts if: failure() - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v7 with: name: e2e-playwright-failure-logs-${{ github.run_id }} path: | diff --git a/.github/workflows/e2e-reusable.yml b/.github/workflows/e2e-reusable.yml index 8125bf6ec..e22ea485e 100644 --- a/.github/workflows/e2e-reusable.yml +++ b/.github/workflows/e2e-reusable.yml @@ -89,7 +89,7 @@ jobs: timeout-minutes: 30 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ inputs.ref }} fetch-depth: 1 @@ -97,7 +97,7 @@ jobs: submodules: recursive - name: Cache pnpm store - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.local/share/pnpm/store key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -117,7 +117,7 @@ jobs: # build-desktop.yml so a release build and an E2E run on the same # commit can share a single download. - name: Cache CEF binary distribution - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: | ~/.cache/tauri-cef @@ -126,7 +126,7 @@ jobs: cef-x86_64-unknown-linux-gnu- - name: Cache Appium global install - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: | ~/.appium @@ -165,7 +165,7 @@ jobs: - name: Upload E2E failure artifacts if: failure() - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v7 with: name: e2e-failure-logs-${{ runner.os }}-${{ github.run_id }} path: | @@ -202,14 +202,14 @@ jobs: timeout-minutes: 30 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ inputs.ref }} fetch-depth: 1 submodules: recursive - name: Cache pnpm store - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.local/share/pnpm/store key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -226,7 +226,7 @@ jobs: key: e2e-linux-unified - name: Cache CEF binary distribution - uses: actions/cache@v5 + uses: actions/cache@v6 with: # cef-dll-sys downloads into $CEF_PATH; ensure-tauri-cli.sh + # e2e-build.sh pin that to $HOME/Library/Caches/tauri-cef on @@ -264,7 +264,7 @@ jobs: ls -lh e2e-build-linux.tar.gz - name: Upload build artifact - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v7 with: name: e2e-build-linux-${{ github.run_id }} path: e2e-build-linux.tar.gz @@ -286,19 +286,20 @@ jobs: - { name: foundation, suites: "auth,navigation,system" } - { name: chat, suites: "chat,skills,journeys" } - { name: providers, suites: "providers,notifications" } + - { name: provider-web, suites: "provider-web" } - { name: webhooks, suites: "webhooks" } - { name: connectors, suites: "connectors" } - { name: commerce, suites: "payments,settings" } steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ inputs.ref }} fetch-depth: 1 submodules: recursive - name: Cache pnpm store - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.local/share/pnpm/store key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -306,7 +307,7 @@ jobs: pnpm-store-${{ runner.os }}- - name: Cache Appium global install - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: | ~/.appium @@ -325,7 +326,7 @@ jobs: bash scripts/ci-cancel-aware.sh appium driver install --source=npm appium-chromium-driver >/dev/null 2>&1 || true - name: Download build artifact - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v8 with: name: e2e-build-linux-${{ github.run_id }} path: . @@ -360,7 +361,7 @@ jobs: - name: Upload E2E failure artifacts if: failure() - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v7 with: name: e2e-failure-logs-${{ runner.os }}-${{ matrix.shard.name }}-${{ github.run_id }} path: | @@ -398,7 +399,7 @@ jobs: RUST_BACKTRACE: "1" steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ inputs.ref }} fetch-depth: 1 @@ -406,7 +407,7 @@ jobs: submodules: recursive - name: Cache pnpm store - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.local/share/pnpm/store key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -442,7 +443,7 @@ jobs: timeout-minutes: 30 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ inputs.ref }} fetch-depth: 1 @@ -450,10 +451,12 @@ jobs: submodules: recursive - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 + with: + version: 10.10.0 - name: Setup Node.js 24.x - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: 24.x cache: pnpm @@ -490,7 +493,7 @@ jobs: key: e2e-macos-unified-v2 - name: Cache CEF binary distribution - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: | ~/Library/Caches/tauri-cef @@ -499,7 +502,7 @@ jobs: cef-aarch64-apple-darwin- - name: Cache Appium global install - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: | ~/.appium @@ -555,7 +558,7 @@ jobs: timeout-minutes: 30 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ inputs.ref }} fetch-depth: 1 @@ -563,10 +566,12 @@ jobs: submodules: recursive - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 + with: + version: 10.10.0 - name: Setup Node.js 24.x - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: 24.x cache: pnpm @@ -584,7 +589,7 @@ jobs: key: e2e-windows-unified - name: Cache CEF binary distribution - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: | ~/AppData/Local/tauri-cef @@ -593,7 +598,7 @@ jobs: cef-x86_64-pc-windows-msvc- - name: Cache Appium global install - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: | ~/.appium @@ -649,17 +654,19 @@ jobs: timeout-minutes: 30 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ inputs.ref }} fetch-depth: 1 submodules: recursive - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 + with: + version: 10.10.0 - name: Setup Node.js 24.x - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: 24.x cache: pnpm @@ -697,7 +704,7 @@ jobs: key: e2e-macos-unified-v2 - name: Cache CEF binary distribution - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: | ~/Library/Caches/tauri-cef @@ -731,7 +738,7 @@ jobs: ls -lh e2e-build-macos.tar.gz - name: Upload build artifact - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v7 with: name: e2e-build-macos-${{ github.run_id }} path: e2e-build-macos.tar.gz @@ -751,28 +758,31 @@ jobs: - { name: foundation, suites: "auth,navigation,system" } - { name: chat, suites: "chat,skills,journeys" } - { name: providers, suites: "providers,notifications" } + - { name: provider-web, suites: "provider-web" } - { name: webhooks, suites: "webhooks" } - { name: connectors, suites: "connectors" } - { name: commerce, suites: "payments,settings" } steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ inputs.ref }} fetch-depth: 1 submodules: recursive - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 + with: + version: 10.10.0 - name: Setup Node.js 24.x - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: 24.x cache: pnpm - name: Cache Appium global install - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: | ~/.appium @@ -790,7 +800,7 @@ jobs: bash scripts/ci-cancel-aware.sh appium driver install --source=npm appium-chromium-driver >/dev/null 2>&1 || true - name: Download build artifact - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v8 with: name: e2e-build-macos-${{ github.run_id }} path: . @@ -831,7 +841,7 @@ jobs: - name: Upload E2E failure artifacts if: failure() - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v7 with: name: e2e-failure-logs-${{ runner.os }}-${{ matrix.shard.name }}-${{ github.run_id }} path: | @@ -866,17 +876,19 @@ jobs: timeout-minutes: 30 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ inputs.ref }} fetch-depth: 1 submodules: recursive - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 + with: + version: 10.10.0 - name: Setup Node.js 24.x - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: 24.x cache: pnpm @@ -894,7 +906,7 @@ jobs: key: e2e-windows-unified - name: Cache CEF binary distribution - uses: actions/cache@v5 + uses: actions/cache@v6 with: # ensure-tauri-cli.sh + e2e-build.sh export # CEF_PATH=$HOME/Library/Caches/tauri-cef regardless of OS; under Git @@ -934,7 +946,7 @@ jobs: ls -lh e2e-build-windows.tar.gz - name: Upload build artifact - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v7 with: name: e2e-build-windows-${{ github.run_id }} path: e2e-build-windows.tar.gz @@ -955,28 +967,31 @@ jobs: - { name: foundation, suites: "auth,navigation,system" } - { name: chat, suites: "chat,skills,journeys" } - { name: providers, suites: "providers,notifications" } + - { name: provider-web, suites: "provider-web" } - { name: webhooks, suites: "webhooks" } - { name: connectors, suites: "connectors" } - { name: commerce, suites: "payments,settings" } steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ inputs.ref }} fetch-depth: 1 submodules: recursive - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 + with: + version: 10.10.0 - name: Setup Node.js 24.x - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: 24.x cache: pnpm - name: Cache Appium global install - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: | ~/.appium @@ -995,7 +1010,7 @@ jobs: bash scripts/ci-cancel-aware.sh appium driver install --source=npm appium-chromium-driver >/dev/null 2>&1 || true - name: Download build artifact - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v8 with: name: e2e-build-windows-${{ github.run_id }} path: . @@ -1028,7 +1043,7 @@ jobs: - name: Upload E2E failure artifacts if: failure() - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v7 with: name: e2e-failure-logs-${{ runner.os }}-${{ matrix.shard.name }}-${{ github.run_id }} # e2e-run-session.sh writes its app log to `${RUNNER_TEMP:-${TMPDIR:-/tmp}}`. diff --git a/.github/workflows/ios-appstore.yml b/.github/workflows/ios-appstore.yml index 41ebaef8c..c088104c9 100644 --- a/.github/workflows/ios-appstore.yml +++ b/.github/workflows/ios-appstore.yml @@ -49,7 +49,7 @@ jobs: APPLE_DEVELOPMENT_TEAM: ${{ secrets.APPLE_TEAM_ID }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: ref: ${{ inputs.ref || github.ref }} fetch-depth: 1 @@ -71,10 +71,12 @@ jobs: cache-on-failure: true - name: Set up pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 + with: + version: 10.10.0 - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: "24" cache: "pnpm" @@ -294,7 +296,7 @@ jobs: - name: Upload IPA artifact if: always() && steps.export.outputs.ipa_path != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: openhuman-ios-ipa path: ${{ steps.export.outputs.ipa_path }} @@ -302,7 +304,7 @@ jobs: - name: Upload archive dSYMs if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: openhuman-ios-dsyms path: app/src-tauri-mobile/gen/apple/build/openhuman-mobile_iOS.xcarchive/dSYMs diff --git a/.github/workflows/ios-compile.yml b/.github/workflows/ios-compile.yml index f0b4bd356..8e28b1039 100644 --- a/.github/workflows/ios-compile.yml +++ b/.github/workflows/ios-compile.yml @@ -32,7 +32,7 @@ jobs: IPHONEOS_DEPLOYMENT_TARGET: "16.0" steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 1 # The mobile crate uses stock Tauri (no CEF), so we don't need @@ -57,10 +57,12 @@ jobs: cache-on-failure: true - name: Set up pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 + with: + version: 10.10.0 - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: "24" cache: "pnpm" diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml index 35b24d1dd..157c15e7b 100644 --- a/.github/workflows/pr-quality.yml +++ b/.github/workflows/pr-quality.yml @@ -25,7 +25,7 @@ jobs: if: ${{ !contains(github.event.pull_request.labels.*.name, 'docs') && !contains(github.event.pull_request.labels.*.name, 'chore') }} steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 1 - name: Verify Submission Checklist @@ -42,7 +42,7 @@ jobs: if: ${{ !contains(github.event.pull_request.labels.*.name, 'docs') && !contains(github.event.pull_request.labels.*.name, 'chore') }} steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 1 - name: Verify Coverage Matrix @@ -55,7 +55,7 @@ jobs: if: ${{ !contains(github.event.pull_request.labels.*.name, 'docs') && !contains(github.event.pull_request.labels.*.name, 'chore') }} steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 1 - name: Lychee link check @@ -84,7 +84,7 @@ jobs: if: ${{ !contains(github.event.pull_request.labels.*.name, 'docs') && !contains(github.event.pull_request.labels.*.name, 'chore') }} steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 1 - name: Install patchelf diff --git a/.github/workflows/promote-main-to-release.yml b/.github/workflows/promote-main-to-release.yml index 7ca0603e6..983fa9b7e 100644 --- a/.github/workflows/promote-main-to-release.yml +++ b/.github/workflows/promote-main-to-release.yml @@ -52,7 +52,7 @@ jobs: permission-contents: write - name: Checkout main - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: main fetch-depth: 0 diff --git a/.github/workflows/release-notes-preview.yml b/.github/workflows/release-notes-preview.yml index 8bfbf3fbb..681e613c5 100644 --- a/.github/workflows/release-notes-preview.yml +++ b/.github/workflows/release-notes-preview.yml @@ -45,14 +45,14 @@ jobs: environment: Production steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 persist-credentials: false - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 - name: Generate notes env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -79,7 +79,7 @@ jobs: node scripts/release/generate-release-notes.mjs "${ARGS[@]}" - name: Upload as artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: release-notes-preview path: release-notes-preview.md diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index 52942f5b2..9b1c1bb22 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -25,7 +25,6 @@ concurrency: group: release-packages-${{ github.event.release.tag_name }} cancel-in-progress: false jobs: - # ──────────────────────────────────────────────────────────────────────────── # 1. Build Linux arm64 CLI tarball (native runner) # Requires: ubuntu-24.04-arm GitHub-hosted runner (free for public repos). @@ -42,7 +41,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Checkout tag - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ github.event.release.tag_name }} fetch-depth: 1 @@ -103,12 +102,12 @@ jobs: needs: [build-cli-linux-arm64] steps: - name: Checkout main repo (for formula template) - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ github.event.release.tag_name }} path: src - name: Checkout Homebrew tap - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: repository: tinyhumansai/homebrew-openhuman token: ${{ secrets.HOMEBREW_TAP_TOKEN }} @@ -135,7 +134,7 @@ jobs: needs: [build-cli-linux-arm64] steps: - name: Checkout tag - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ github.event.release.tag_name }} fetch-depth: 1 @@ -151,7 +150,7 @@ jobs: echo "$APT_SIGNING_KEY" | gpg --batch --import gpg --list-secret-keys - name: Checkout gh-pages branch - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: gh-pages path: gh-pages @@ -175,14 +174,16 @@ jobs: timeout-minutes: 10 steps: - name: Checkout tag - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ github.event.release.tag_name }} fetch-depth: 1 - name: Setup pnpm - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@v6 + with: + version: 10.10.0 - name: Setup Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: 24.x registry-url: https://registry.npmjs.org @@ -257,7 +258,7 @@ jobs: os: [ubuntu-latest, macos-latest] steps: - name: Setup Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: 24.x - name: Wait for npm propagation, then install @@ -280,7 +281,7 @@ jobs: timeout-minutes: 5 steps: - name: Create issue if it doesn't exist - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: |- const { owner, repo } = context.repo; diff --git a/.github/workflows/release-production.yml b/.github/workflows/release-production.yml index 836b49c6d..c15c7a901 100644 --- a/.github/workflows/release-production.yml +++ b/.github/workflows/release-production.yml @@ -139,16 +139,18 @@ jobs: # identity in the ruleset bypass list, not from token scopes. permission-contents: write - name: Checkout release - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: release fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} submodules: recursive - name: Setup pnpm - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@v6 + with: + version: 10.10.0 - name: Setup Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: 24.x package-manager-cache: false @@ -281,7 +283,7 @@ jobs: echo "upload_url=" >> "$GITHUB_OUTPUT" - name: Checkout release ref if: ${{ inputs.create_release }} - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ needs.prepare-build.outputs.build_ref }} fetch-depth: 0 @@ -323,7 +325,7 @@ jobs: - name: Create draft release with generated notes if: ${{ inputs.create_release }} id: create - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const fs = require('fs'); @@ -414,7 +416,7 @@ jobs: IMAGE_NAME: tinyhumansai/openhuman-core steps: - name: Checkout build ref - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ needs.prepare-build.outputs.build_ref }} fetch-depth: 1 @@ -424,9 +426,9 @@ jobs: - name: Init vendored Rust submodules run: git submodule update --init vendor/tinyagents vendor/tinyflows vendor/tinycortex vendor/tinyjuice vendor/tinychannels vendor/tinyplace - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -448,7 +450,7 @@ jobs: echo "EOF" } >> "$GITHUB_OUTPUT" - name: Build and push image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: Dockerfile @@ -499,7 +501,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Checkout build ref - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ needs.prepare-build.outputs.build_ref }} fetch-depth: 1 @@ -561,7 +563,7 @@ jobs: environment: Production steps: - name: Checkout build ref - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ needs.prepare-build.outputs.build_ref }} fetch-depth: 1 @@ -599,7 +601,7 @@ jobs: && needs.publish-updater-manifest.result == 'success' steps: - name: Checkout validation scripts - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ needs.prepare-build.outputs.build_ref }} fetch-depth: 1 @@ -625,7 +627,7 @@ jobs: scripts/validate-release-assets.sh /tmp/openhuman-release.json /tmp/latest.json - name: Validate required installer assets exist - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const releaseId = Number('${{ needs.create-release.outputs.release_id }}'); @@ -670,7 +672,7 @@ jobs: core.info('All required installer assets are present.'); - name: Publish release - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const releaseId = Number('${{ needs.create-release.outputs.release_id }}'); @@ -706,9 +708,9 @@ jobs: IMAGE_NAME: tinyhumansai/openhuman-core steps: - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -794,7 +796,7 @@ jobs: steps: - name: Delete GitHub release if: ${{ inputs.create_release && needs.create-release.result == 'success' }} - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const owner = context.repo.owner; @@ -811,7 +813,7 @@ jobs: core.warning(`deleteRelease failed: ${e.message}`); } - name: Delete remote tag - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const owner = context.repo.owner; diff --git a/.github/workflows/release-staging.yml b/.github/workflows/release-staging.yml index 79a796ff6..053c97b5b 100644 --- a/.github/workflows/release-staging.yml +++ b/.github/workflows/release-staging.yml @@ -114,16 +114,18 @@ jobs: # identity in the ruleset bypass list, not from token scopes. permission-contents: write - name: Checkout release - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: release fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} submodules: recursive - name: Setup pnpm - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@v6 + with: + version: 10.10.0 - name: Setup Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: 24.x package-manager-cache: false @@ -309,7 +311,7 @@ jobs: environment: Production steps: - name: Checkout build ref - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ needs.prepare-build.outputs.build_ref }} fetch-depth: 1 @@ -319,9 +321,9 @@ jobs: - name: Init vendored Rust submodules run: git submodule update --init vendor/tinyagents vendor/tinyflows vendor/tinycortex vendor/tinyjuice vendor/tinychannels vendor/tinyplace - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build image (no push) - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: Dockerfile @@ -402,7 +404,7 @@ jobs: || needs.build-docker.result == 'failure' || needs.build-docker.result == 'cancelled') steps: - name: Delete remote staging tag - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const owner = context.repo.owner; diff --git a/.github/workflows/tauri-cef-pin-guard.yml b/.github/workflows/tauri-cef-pin-guard.yml index 0e1aa0789..7105dd835 100644 --- a/.github/workflows/tauri-cef-pin-guard.yml +++ b/.github/workflows/tauri-cef-pin-guard.yml @@ -27,7 +27,7 @@ jobs: timeout-minutes: 5 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: persist-credentials: false - name: Assert tauri-cef pin matches expected SHA diff --git a/.github/workflows/test-reusable.yml b/.github/workflows/test-reusable.yml index a29906d34..f1a4f18ae 100644 --- a/.github/workflows/test-reusable.yml +++ b/.github/workflows/test-reusable.yml @@ -53,12 +53,12 @@ jobs: image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ inputs.ref }} fetch-depth: 1 - name: Cache pnpm store - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.local/share/pnpm/store key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -78,7 +78,7 @@ jobs: image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0 steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ inputs.ref }} fetch-depth: 1 @@ -88,9 +88,11 @@ jobs: # `submodules: recursive`, which would also pull the large tauri-cef fork # this job never builds. - name: Init tinychannels submodule (drift-guard test reads its schemas) - run: git submodule update --init vendor/tinychannels + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + git submodule update --init vendor/tinychannels - name: Cache pnpm store - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.local/share/pnpm/store key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} @@ -104,7 +106,7 @@ jobs: NODE_ENV: test - name: Upload coverage reports if: always() - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v7 with: name: coverage-report path: coverage @@ -114,16 +116,29 @@ jobs: if: inputs.run_rust_core name: Rust Core Tests + Quality runs-on: ubuntu-22.04 - timeout-minutes: 20 + timeout-minutes: 60 container: image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0 env: CARGO_INCREMENTAL: "0" - RUSTC_WRAPPER: sccache - SCCACHE_GHA_ENABLED: "true" + # Keep the full core test binary link under hosted-runner memory/disk + # pressure. Tests do not need full DWARF; file/line backtraces remain. + CARGO_PROFILE_DEV_DEBUG: line-tables-only + CARGO_BUILD_JOBS: "1" + # Deep async agent-harness tests can overflow libtest's default ~2 MB + # per-test thread stack. Match ci-lite's core coverage stack setting. + RUST_MIN_STACK: "67108864" steps: + - name: Free disk space + run: | + # Reclaim space on the build partition before compiling/linking the + # full Rust core test binary. The hosted toolcache is bind-mounted at + # /__t inside the container. + rm -rf /__t/* || true + df -h /__w || true + - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ inputs.ref }} fetch-depth: 1 @@ -135,8 +150,6 @@ jobs: workspaces: . -> target cache-on-failure: true key: core - - name: Install sccache - uses: mozilla-actions/sccache-action@v0.0.9 - name: Test core crate (openhuman) run: bash scripts/ci-cancel-aware.sh cargo test -p openhuman @@ -144,14 +157,12 @@ jobs: if: inputs.run_rust_core name: Rust Core Tests (Windows — secrets ACL) runs-on: windows-latest - timeout-minutes: 30 + timeout-minutes: 60 env: - CARGO_INCREMENTAL: '0' - SCCACHE_GHA_ENABLED: 'true' - RUSTC_WRAPPER: sccache + CARGO_INCREMENTAL: "0" steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ inputs.ref }} fetch-depth: 1 @@ -163,8 +174,6 @@ jobs: workspaces: . -> target cache-on-failure: true key: core-windows - - name: Install sccache - uses: mozilla-actions/sccache-action@v0.0.9 - name: Run Windows-specific secrets tests # Runs the keyring::encrypted_store suite (keyring::encrypted_store::tests), # which contains the #[cfg(windows)] tests: @@ -190,7 +199,7 @@ jobs: SCCACHE_GHA_ENABLED: "true" steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: ref: ${{ inputs.ref }} fetch-depth: 1 @@ -206,13 +215,13 @@ jobs: cache-on-failure: true key: tauri - name: Cache CEF binary distribution - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.cache/tauri-cef key: cef-ubuntu-22.04-${{ hashFiles('app/src-tauri/Cargo.toml') }} restore-keys: | cef-ubuntu-22.04- - name: Install sccache - uses: mozilla-actions/sccache-action@v0.0.9 + uses: mozilla-actions/sccache-action@v0.0.10 - name: Test Tauri shell (OpenHuman) run: bash scripts/ci-cancel-aware.sh cargo test --manifest-path app/src-tauri/Cargo.toml diff --git a/Cargo.lock b/Cargo.lock index 0797052cc..2c5706f38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2863,7 +2863,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.57.0", ] [[package]] @@ -4341,7 +4341,7 @@ dependencies = [ [[package]] name = "openhuman" -version = "0.58.12" +version = "0.58.14" dependencies = [ "aes-gcm", "aho-corasick", @@ -6928,7 +6928,7 @@ dependencies = [ [[package]] name = "tinyplace" -version = "2.0.0" +version = "2.0.1" dependencies = [ "aes", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 3ba6fc8f5..8eb4a2195 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openhuman" -version = "0.58.12" +version = "0.58.14" edition = "2021" description = "OpenHuman core business logic and RPC server" autobins = false diff --git a/app/package.json b/app/package.json index d6335fbea..6e832d3db 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "openhuman-app", - "version": "0.58.12", + "version": "0.58.14", "type": "module", "engines": { "node": ">=24.0.0" diff --git a/app/scripts/e2e-run-all-flows.sh b/app/scripts/e2e-run-all-flows.sh index 9c1e676e5..ff473e248 100755 --- a/app/scripts/e2e-run-all-flows.sh +++ b/app/scripts/e2e-run-all-flows.sh @@ -8,8 +8,8 @@ # OPTIONS: # --suite=SUITE Run only one suite category. Valid values: # auth, navigation, chat, skills, notifications, -# webhooks, providers, payments, settings, system, -# journeys, all (default: all) +# webhooks, providers, provider-web, payments, settings, +# system, journeys, all (default: all) # --bail Stop after the first spec failure (default: run all) # --skip-preflight Skip the pre-flight environment check # @@ -57,7 +57,7 @@ for arg in "$@"; do esac done -VALID_SUITES="auth navigation chat skills notifications webhooks providers connectors payments settings system journeys all" +VALID_SUITES="auth navigation chat skills notifications webhooks providers provider-web connectors payments settings system journeys all" # Accept comma-separated suite lists, e.g. --suite=auth,navigation,system. # CI sharding passes one such list per matrix shard so a few parallel jobs @@ -325,17 +325,28 @@ if should_run_suite "providers"; then run "test/e2e/specs/telegram-channel-flow.spec.ts" "telegram-channel" "providers" run "test/e2e/specs/gmail-flow.spec.ts" "gmail" "providers" run "test/e2e/specs/accounts-provider-modal.spec.ts" "accounts-providers" "providers" + _mini_summary "providers" +fi + +# Split browser-heavy provider flows out of the core provider shard. Telegram, +# Gmail, and accounts already consume several minutes of Appium/CEF runtime on +# Windows; keeping WhatsApp/conversations in the same shared session makes the +# later specs prone to "session is either terminated or not started" teardown +# failures before their own assertions run. +if should_run_suite "provider-web"; then + echo "" + echo "## Running suite: provider-web" # slack-flow currently crashes the CEF session mid-spec on Linux (#1850-style # state issue); skip until investigated rather than nuke the rest of the # provider suite. - # run "test/e2e/specs/slack-flow.spec.ts" "slack" "providers" - run "test/e2e/specs/whatsapp-flow.spec.ts" "whatsapp" "providers" + # run "test/e2e/specs/slack-flow.spec.ts" "slack" "provider-web" + run "test/e2e/specs/whatsapp-flow.spec.ts" "whatsapp" "provider-web" # notion-flow.spec.ts was removed; skip to avoid "spec not found" failure. - # run "test/e2e/specs/notion-flow.spec.ts" "notion" "providers" - run "test/e2e/specs/conversations-web-channel-flow.spec.ts" "conversations" "providers" - run "test/e2e/specs/composio-triggers-flow.spec.ts" "composio-triggers" "providers" - run "test/e2e/specs/connectivity-state-differentiation.spec.ts" "connectivity-state" "providers" - _mini_summary "providers" + # run "test/e2e/specs/notion-flow.spec.ts" "notion" "provider-web" + run "test/e2e/specs/conversations-web-channel-flow.spec.ts" "conversations" "provider-web" + run "test/e2e/specs/composio-triggers-flow.spec.ts" "composio-triggers" "provider-web" + run "test/e2e/specs/connectivity-state-differentiation.spec.ts" "connectivity-state" "provider-web" + _mini_summary "provider-web" fi # --------------------------------------------------------------------------- diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 5d59126d9..24b462db9 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "OpenHuman" -version = "0.58.12" +version = "0.58.14" dependencies = [ "anyhow", "async-trait", @@ -205,7 +205,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -216,7 +216,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -250,7 +250,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.59.0", "x11rb", ] @@ -2093,7 +2093,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -2496,7 +2496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -3737,7 +3737,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.57.0", ] [[package]] @@ -4136,7 +4136,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -5030,7 +5030,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -5570,7 +5570,7 @@ dependencies = [ [[package]] name = "openhuman" -version = "0.58.12" +version = "0.58.14" dependencies = [ "aes-gcm", "aho-corasick", @@ -6727,7 +6727,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -7314,7 +7314,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -7373,7 +7373,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -8145,7 +8145,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8999,7 +8999,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -9273,7 +9273,7 @@ dependencies = [ [[package]] name = "tinyplace" -version = "2.0.0" +version = "2.0.1" dependencies = [ "aes", "async-trait", @@ -9806,7 +9806,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 7887fc879..12859c4c2 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "OpenHuman" -version = "0.58.12" +version = "0.58.14" description = "OpenHuman - AI-powered Super Assistant" authors = ["OpenHuman"] edition = "2021" diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index de38616b6..d1388d16b 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenHuman", - "version": "0.58.12", + "version": "0.58.14", "identifier": "com.openhuman.app", "build": { "beforeDevCommand": "pnpm run dev", diff --git a/app/src/components/intelligence/MemoryGraph.tsx b/app/src/components/intelligence/MemoryGraph.tsx index 60221f2f4..e2d7a23a4 100644 --- a/app/src/components/intelligence/MemoryGraph.tsx +++ b/app/src/components/intelligence/MemoryGraph.tsx @@ -104,6 +104,16 @@ interface MemoryGraphProps { mode: GraphMode; /** Optional override for the empty-state message. */ emptyHint?: string; + /** Optional label for the central hub node (WebGL path). Defaults to "Memory". */ + rootLabel?: string; + /** Fill the parent's height (full-screen) instead of the fixed 640px card. */ + fill?: boolean; + /** Initial auto-fit zoom. Lower = more zoomed out. Defaults to 0.17. */ + fitScale?: number; + /** Fit the whole graph tightly to the viewport instead of a fixed zoom. */ + fitToBounds?: boolean; + /** Draw an always-on text label under each node. */ + showLabels?: boolean; /** * Fired exactly once when the graph's force layout first settles (SVG * worker `end`, the synchronous relax fallback, or the Pixi sim cooling). @@ -195,7 +205,18 @@ function relaxLayout(nodes: SimNode[], edges: Array<[number, number]>, iteration } } -export function MemoryGraph({ nodes, edges, mode, emptyHint, onReady }: MemoryGraphProps) { +export function MemoryGraph({ + nodes, + edges, + mode, + emptyHint, + rootLabel, + fill, + fitScale, + fitToBounds, + showLabels, + onReady, +}: MemoryGraphProps) { const { t } = useT(); const themeMode = useAppSelector(state => state.theme?.mode ?? 'system') as ThemeMode; const isDark = resolveTheme(themeMode) === 'dark'; @@ -403,6 +424,7 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint, onReady }: MemoryGr // of re-rendering up to 10k elements through React every frame. const circleEls = useRef<(SVGCircleElement | null)[]>([]); const lineEls = useRef<(SVGLineElement | null)[]>([]); + const textEls = useRef<(SVGTextElement | null)[]>([]); // Progressive DOM mount for the SVG path: reveal nodes in per-frame batches // so a large graph never blocks building thousands of elements in one commit. @@ -418,6 +440,7 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint, onReady }: MemoryGr simIdRef.current = sim; circleEls.current = []; lineEls.current = []; + textEls.current = []; setSvgVisible(sim ? Math.min(sim.sim.length, FIRST_BATCH) : 0); } @@ -438,6 +461,11 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint, onReady }: MemoryGr el.setAttribute('cx', String(ns[i].x)); el.setAttribute('cy', String(ns[i].y)); } + const tel = textEls.current[i]; + if (tel) { + tel.setAttribute('x', String(ns[i].x)); + tel.setAttribute('y', String(ns[i].y + nodeRadius(ns[i]) + 12)); + } } for (let e = 0; e < s.edges.length; e++) { const [ai, bi] = s.edges[e]; @@ -462,12 +490,35 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint, onReady }: MemoryGr const ns = s.sim; if (ns.length === 0) return; // Center on the root node at a fixed comfortable zoom. + if (fitToBounds) { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const n of ns) { + const r = nodeRadius(n) + 8; + minX = Math.min(minX, n.x - r); + minY = Math.min(minY, n.y - r); + maxX = Math.max(maxX, n.x + r); + maxY = Math.max(maxY, n.y + r); + } + const w = Math.max(1, maxX - minX); + const h = Math.max(1, maxY - minY); + const scale = Math.min( + ZOOM_MAX, + Math.max(ZOOM_MIN, Math.min(VIEWPORT_W / w, VIEWPORT_H / h) * 0.92) + ); + const bx = (minX + maxX) / 2; + const by = (minY + maxY) / 2; + setView({ scale, tx: VIEWPORT_W / 2 - bx * scale, ty: VIEWPORT_H / 2 - by * scale }); + return; + } const root = ns.find(n => n.kind === 'root'); const cx = root?.x ?? 0; const cy = root?.y ?? 0; - const scale = 0.17; + const scale = fitScale ?? 0.17; setView({ scale, tx: VIEWPORT_W / 2 - cx * scale, ty: VIEWPORT_H / 2 - cy * scale }); - }, []); + }, [fitScale, fitToBounds]); fitRef.current = fitToView; // Coalesce worker ticks to one DOM write per frame. @@ -565,9 +616,11 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint, onReady }: MemoryGr return (
setHovered(null)}> -
+
{nodes.length} {t('graph.nodes')} @@ -606,6 +659,11 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint, onReady }: MemoryGr nodes={nodes} edges={edges} mode={mode} + rootLabel={rootLabel} + fill={fill} + fitScale={fitScale} + fitToBounds={fitToBounds} + showLabels={showLabels} dark={ typeof document !== 'undefined' && document.documentElement.classList.contains('dark') } @@ -621,9 +679,9 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint, onReady }: MemoryGr + {showLabels && ( + + {sim.sim.slice(0, svgVisible).map((n, i) => { + const label = (n.label ?? '').trim(); + const text = label.length > 22 ? `${label.slice(0, 21)}…` : label; + return ( + { + textEls.current[i] = el; + }} + x={n.x} + y={n.y + nodeRadius(n) + 12} + textAnchor="middle" + fontSize={13} + fill={isDark ? '#e2e8f0' : '#334155'} + style={{ pointerEvents: 'none', userSelect: 'none' }}> + {text} + + ); + })} + + )} )} diff --git a/app/src/components/intelligence/PixiGraph.tsx b/app/src/components/intelligence/PixiGraph.tsx index 2322b7cce..f3d9fcdc3 100644 --- a/app/src/components/intelligence/PixiGraph.tsx +++ b/app/src/components/intelligence/PixiGraph.tsx @@ -17,6 +17,16 @@ interface PixiGraphProps { edges: GraphEdge[]; mode: GraphMode; dark: boolean; + /** Optional label for the synthetic hub node (defaults to "Memory"). */ + rootLabel?: string; + /** Fill the parent's height instead of the fixed 640px cap. */ + fill?: boolean; + /** Initial auto-fit zoom (world scale). Defaults to 0.17. */ + fitScale?: number; + /** Fit the whole node cloud tightly to the viewport instead of a fixed zoom. */ + fitToBounds?: boolean; + /** Draw an always-on text label under each node. */ + showLabels?: boolean; /** Bump to recentre the view (Reset view button). */ resetSignal: number; onHover: (node: GraphNode | null) => void; @@ -33,6 +43,11 @@ export function PixiGraph({ edges, mode, dark, + rootLabel, + fill, + fitScale, + fitToBounds, + showLabels, resetSignal, onHover, onOpen, @@ -61,7 +76,7 @@ export function PixiGraph({ // Mode change requires full remount (different edge semantics). if (handleRef.current && mountedModeRef.current === mode) { - const { simNodes, links } = buildGraph(nodes, edges, mode); + const { simNodes, links } = buildGraph(nodes, edges, mode, rootLabel); handleRef.current.updateGraph(simNodes, links); return; } @@ -70,11 +85,14 @@ export function PixiGraph({ let cancelled = false; handleRef.current?.destroy(); handleRef.current = null; - const { simNodes, links } = buildGraph(nodes, edges, mode); + const { simNodes, links } = buildGraph(nodes, edges, mode, rootLabel); const pending = mountPixiGraph(host, { simNodes, links, dark: darkRef.current, + fitScale, + fitToBounds, + showLabels, onHover: n => onHoverRef.current(n), onOpen: n => onOpenRef.current(n), onReady: () => onReadyRef.current?.(), @@ -99,7 +117,7 @@ export function PixiGraph({ mountedModeRef.current = null; void pending.then(handle => handle?.destroy()); }; - }, [nodes, edges, mode]); + }, [nodes, edges, mode, rootLabel, fitScale, fitToBounds, showLabels]); useEffect(() => { handleRef.current?.setTheme(dark); @@ -113,8 +131,8 @@ export function PixiGraph({
); } diff --git a/app/src/components/intelligence/memoryGraphLayout.ts b/app/src/components/intelligence/memoryGraphLayout.ts index 5586d8386..692977751 100644 --- a/app/src/components/intelligence/memoryGraphLayout.ts +++ b/app/src/components/intelligence/memoryGraphLayout.ts @@ -57,6 +57,7 @@ export function levelColor(level: number | null | undefined): string { } export function nodeColor(node: GraphNode): string { + if (node.color) return node.color; // UI override (e.g. dimmed offline agents) if (node.kind === 'root') return ROOT_COLOR; if (node.kind === 'source') return SOURCE_COLOR; if (node.kind === 'summary') return levelColor(node.level); @@ -104,12 +105,15 @@ export const ROOT_NODE_ID = '__root__'; export function buildGraph( nodes: GraphNode[], edges: GraphEdge[], - mode: GraphMode + mode: GraphMode, + rootLabel = 'Memory' ): { simNodes: SimNode[]; links: SimLink[] } { const ids = new Set(nodes.map(n => n.id)); // Synthetic master root at the origin — all source nodes fan out from it. - const rootNode: SimNode = { kind: 'root', id: ROOT_NODE_ID, label: 'Memory', x: 0, y: 0 }; + // `rootLabel` lets non-memory reuses (e.g. the orchestration overview) name + // the hub; defaults to "Memory". + const rootNode: SimNode = { kind: 'root', id: ROOT_NODE_ID, label: rootLabel, x: 0, y: 0 }; const simNodes: SimNode[] = [rootNode]; for (let i = 0; i < nodes.length; i++) { diff --git a/app/src/components/intelligence/pixiGraphRenderer.ts b/app/src/components/intelligence/pixiGraphRenderer.ts index fda5b955b..82265260b 100644 --- a/app/src/components/intelligence/pixiGraphRenderer.ts +++ b/app/src/components/intelligence/pixiGraphRenderer.ts @@ -13,7 +13,7 @@ * Drawing is dirty-flagged: while the simulation is warm (or the user is * interacting) we redraw each frame; once it cools the loop idles. */ -import { Application, Container, type FederatedPointerEvent, Graphics } from 'pixi.js'; +import { Application, Container, type FederatedPointerEvent, Graphics, Text } from 'pixi.js'; import 'pixi.js/unsafe-eval'; import { @@ -36,6 +36,12 @@ export interface PixiGraphOptions { onOpen: (node: SimNode) => void; /** Fired once the force simulation first cools (graph is laid out). */ onReady?: () => void; + /** Initial auto-fit zoom (world scale). Defaults to 0.17. */ + fitScale?: number; + /** Fit the whole node cloud tightly to the viewport instead of a fixed zoom. */ + fitToBounds?: boolean; + /** Draw an always-on text label under each node (off by default). */ + showLabels?: boolean; } export interface PixiGraphHandle { @@ -76,8 +82,10 @@ export async function mountPixiGraph( const world = new Container(); const edgeG = new Graphics(); const nodeG = new Graphics(); + const labelG = new Container(); world.addChild(edgeG); world.addChild(nodeG); + world.addChild(labelG); // labels paint above the discs app.stage.addChild(world); const recenter = () => world.position.set(app.screen.width / 2, app.screen.height / 2); @@ -96,17 +104,74 @@ export async function mountPixiGraph( // so the initial frame is zoomed out to show as much as possible. let userInteracted = false; - /** Centre on the root node at a fixed comfortable zoom. */ + /** + * Frame the graph. With `fitToBounds`, scale so the whole node cloud fits the + * viewport as tightly as possible (a little margin for node radii/labels); + * otherwise centre on the root at a fixed comfortable zoom (`fitScale`). + */ const fitToView = () => { if (opts.simNodes.length === 0) return; + + if (opts.fitToBounds) { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const n of opts.simNodes) { + const r = nodeRadius(n) + 8; // pad for the node radius + a little label room + minX = Math.min(minX, n.x - r); + minY = Math.min(minY, n.y - r); + maxX = Math.max(maxX, n.x + r); + maxY = Math.max(maxY, n.y + r); + } + const w = Math.max(1, maxX - minX); + const h = Math.max(1, maxY - minY); + const margin = 0.92; // leave ~8% breathing room around the content + const scale = Math.min( + ZOOM_MAX, + Math.max(ZOOM_MIN, Math.min(app.screen.width / w, app.screen.height / h) * margin) + ); + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + world.scale.set(scale); + world.position.set(app.screen.width / 2 - cx * scale, app.screen.height / 2 - cy * scale); + return; + } + const root = opts.simNodes.find(n => n.kind === 'root'); const cx = root?.x ?? 0; const cy = root?.y ?? 0; - const scale = 0.17; + const scale = opts.fitScale ?? 0.17; world.scale.set(scale); world.position.set(app.screen.width / 2 - cx * scale, app.screen.height / 2 - cy * scale); }; + // Always-on node labels (opt-in via `showLabels`). Parallel to `opts.simNodes` + // and repositioned each frame in `draw()` so they track the simulation. + const labelColor = () => (dark ? 0xe2e8f0 : 0x334155); + const labelText = (n: SimNode): string => { + const s = (n.label ?? '').trim(); + return s.length > 22 ? `${s.slice(0, 21)}…` : s; + }; + let labels: Text[] = []; + const rebuildLabels = () => { + for (const label of labels) label.destroy(); + labels = []; + labelG.removeChildren(); + if (!opts.showLabels) return; + for (const n of opts.simNodes) { + const text = new Text({ + text: labelText(n), + style: { fontFamily: 'sans-serif', fontSize: 13, fill: labelColor(), align: 'center' }, + }); + text.anchor.set(0.5, 0); + text.resolution = 2; // crisp when the world is scaled up + labelG.addChild(text); + labels.push(text); + } + }; + rebuildLabels(); + const draw = () => { edgeG.clear(); for (const l of opts.links) { @@ -132,6 +197,18 @@ export async function mountPixiGraph( nodeG.circle(n.x, n.y, r).fill({ color: colorNum(nodeColor(n)), alpha: 1 }); if (hover) nodeG.circle(n.x, n.y, r).stroke({ width: 1.4, color: 0x0f172a, alpha: 0.9 }); } + + // Node labels follow the discs; recolour to the live theme. + if (opts.showLabels) { + const fill = labelColor(); + for (let i = 0; i < labels.length; i++) { + const n = opts.simNodes[i]; + const label = labels[i]; + if (!n || !label) continue; + label.position.set(n.x, n.y + nodeRadius(n) + 3); + label.style.fill = fill; + } + } }; app.ticker.add(() => { @@ -296,6 +373,7 @@ export async function mountPixiGraph( // Hot-swap the simulation's node and link arrays. opts.simNodes = nextNodes; opts.links = nextLinks; + rebuildLabels(); sim.nodes(nextNodes); const linkForce = sim.force('link') as ReturnType; if (linkForce && typeof linkForce.links === 'function') { diff --git a/app/src/components/layout/TwoPaneNav.tsx b/app/src/components/layout/TwoPaneNav.tsx index e37e78327..fe4be8cd1 100644 --- a/app/src/components/layout/TwoPaneNav.tsx +++ b/app/src/components/layout/TwoPaneNav.tsx @@ -18,6 +18,11 @@ interface TwoPaneNavProps { onSelect: (value: string) => void; /** Optional fixed header (title/subtitle) above the scrolling nav list. */ header?: ReactNode; + /** + * Optional content rendered inside the scroll region *below* the nav groups — + * e.g. a separator + a live list of active sessions. Scrolls with the nav. + */ + footer?: ReactNode; ariaLabel?: string; } @@ -33,12 +38,15 @@ export default function TwoPaneNav({ selected, onSelect, header, + footer, ariaLabel, }: TwoPaneNavProps) { return ( ); diff --git a/app/src/components/orchestration/ActiveSubagentsRail.tsx b/app/src/components/orchestration/ActiveSubagentsRail.tsx new file mode 100644 index 000000000..8cd803a57 --- /dev/null +++ b/app/src/components/orchestration/ActiveSubagentsRail.tsx @@ -0,0 +1,188 @@ +/** + * ActiveSubagentsRail — the sidebar's "Active sub-agents" list, grouped by + * instance. + * + * An **instance** is a contact (peer `agentId`) you coordinate with; its + * **sub-agents** are the sessions running under it. Each instance shows an + * avatar icon + a connection-status dot (aggregated over its sessions), and + * expands to its sub-agents — each with its own status dot. Clicking a sub-agent + * opens that session's full chat subpage. + * + * Status dot: green = connected, amber = waiting for input, red = errored, + * hollow = disconnected. The instance dot surfaces the most actionable state + * across its sessions (waiting › errored › connected › disconnected). + */ +import { useCallback, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import type { SessionSummary } from '../../lib/orchestration/orchestrationClient'; + +type ConnState = 'connected' | 'waiting' | 'error' | 'disconnected'; + +/** Filled dot colour per state; `null` renders a hollow ring (disconnected). */ +const DOT_CLASS: Record = { + connected: 'bg-sage-500', + waiting: 'bg-amber-500', + error: 'bg-coral-500', + disconnected: null, +}; + +const LABEL_KEY: Record = { + connected: 'orchPage.sessions.statusConnected', + waiting: 'orchPage.sessions.statusWaiting', + error: 'orchPage.connections.status.error', + disconnected: 'orchPage.sessions.statusDisconnected', +}; + +function sessionState(session: SessionSummary): ConnState { + // Work-state semantics win (they carry their own colour). + if (session.status === 'waiting-approval') return 'waiting'; + if (session.status === 'errored') return 'error'; + // Live peer presence when the core is confident; else the recency heuristic. + if (session.peerOnline === true) return 'connected'; + if (session.peerOnline === false) return 'disconnected'; + if (session.active || session.status === 'running') return 'connected'; + return 'disconnected'; +} + +/** Most actionable state across an instance's sessions. */ +function instanceState(sessions: SessionSummary[]): ConnState { + const states = sessions.map(sessionState); + if (states.includes('waiting')) return 'waiting'; + if (states.includes('error')) return 'error'; + if (states.includes('connected')) return 'connected'; + return 'disconnected'; +} + +function shortAddress(address: string): string { + return address.length <= 14 ? address : `${address.slice(0, 6)}…${address.slice(-4)}`; +} + +function sessionLabel(session: SessionSummary): string { + return session.label?.trim() || session.sessionId; +} + +function StatusDot({ state }: { state: ConnState }) { + const { t } = useT(); + const fill = DOT_CLASS[state]; + return ( + + ); +} + +export interface ActiveSubagentsRailProps { + /** Sessions grouped by contact (`agentId`) — the instances. */ + byContact: Map; + /** Currently open session id (highlighted when on the agent tab). */ + openSessionId: string | null; + /** Whether the agent chat tab is active (so a selected row reads as current). */ + isAgentTab: boolean; + onOpenSession: (sessionId: string) => void; +} + +export default function ActiveSubagentsRail({ + byContact, + openSessionId, + isAgentTab, + onOpenSession, +}: ActiveSubagentsRailProps) { + const { t } = useT(); + const [collapsed, setCollapsed] = useState>(new Set()); + + const toggle = useCallback((address: string) => { + setCollapsed(prev => { + const next = new Set(prev); + if (next.has(address)) next.delete(address); + else next.add(address); + return next; + }); + }, []); + + const instances = Array.from(byContact.entries()); + + return ( +
+
+ + {t('orchPage.sessions.railTitle')} + +
+ + {instances.length === 0 ? ( +

{t('orchPage.sessions.empty')}

+ ) : ( +
    + {instances.map(([address, sessions]) => { + const isOpen = !collapsed.has(address); + return ( +
  • + {/* Instance header — icon + aggregate status dot + name. */} + + + {/* Sub-agents nested under the instance. */} + {isOpen ? ( +
      + {sessions.map(session => { + const active = isAgentTab && openSessionId === session.sessionId; + return ( +
    • + +
    • + ); + })} +
    + ) : null} +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/app/src/components/orchestration/AgentChatPanel.tsx b/app/src/components/orchestration/AgentChatPanel.tsx index 669c139ce..f6255519d 100644 --- a/app/src/components/orchestration/AgentChatPanel.tsx +++ b/app/src/components/orchestration/AgentChatPanel.tsx @@ -1,18 +1,29 @@ /** - * AgentChatPanel — chat with the main agent, styled like the app's normal chat. + * AgentChatPanel — chat with the main agent, rendered exactly like the app's + * normal chat page. * - * A ThreadList-style rail (Main agent / Subconscious) beside a centered message - * pane rendered with the shared {@link SessionTranscript} (chat-window bubbles + - * inline harness activity), plus the subconscious steering header and the Master - * composer. + * Full-bleed page-variant layout (dark background, centered width-capped message + * column, a floating composer over a bottom fade, one vertical scroll) shared by + * three views via {@link ChatPageScaffold}: * - * When the agent engages a fleet session (a session parked on an approval), an - * inline **View session** card surfaces below the thread; opening it slides in a - * right-hand session side-tab showing that session's live chat + a reply - * composer. The side-tab never opens on its own — the user clicks the card. + * - the **conscious** master session (composer + welcome hero when empty), + * - the **subconscious** steering loop (steering header, no composer), + * - a **peer session subpage** — opened from the sidebar's active sub-agents + * list or an inline "needs you" card, it takes over the whole pane (not a + * side drawer) so the session's chat renders full-size, with a back link. + * + * Conscious/Subconscious is a bottom toggle in the composer footer (where the + * generic chat's super-context / quick-reasoning controls sit). */ import debugFactory from 'debug'; -import { type FormEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { + type KeyboardEvent, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; import { useT } from '../../lib/i18n/I18nContext'; import { @@ -29,6 +40,8 @@ import { useSessionTranscript, } from '../../lib/orchestration/useOrchestrationSessions'; import { subconsciousTrigger } from '../../utils/tauriCommands/subconscious'; +import ChatComposer from '../chat/ChatComposer'; +import ChatNewWindowHero from '../chat/ChatNewWindowHero'; import Button from '../ui/Button'; import SessionTranscript from './SessionTranscript'; @@ -38,111 +51,284 @@ function sessionLabel(session: SessionSummary): string { return session.label?.trim() || session.sessionId; } -/** Right-hand session side-tab: a fleet session's live chat + reply composer. */ -function SessionDrawer({ session, onClose }: { session: SessionSummary; onClose: () => void }) { +/** + * Page-variant chat scaffold: the normal chat window's dark surface, a + * full-width scroll region with a centered width-capped body, a bottom fade, and + * a floating composer footer. The footer's measured height reserves bottom + * padding on the scroll region so the last message clears it. + */ +function ChatPageScaffold({ + header, + footer, + children, +}: { + header?: ReactNode; + footer?: ReactNode; + children: ReactNode; +}) { + const footerRef = useRef(null); + const [footerHeight, setFooterHeight] = useState(0); + + useEffect(() => { + const el = footerRef.current; + if (!el) { + setFooterHeight(0); + return; + } + // ResizeObserver may be absent in some test environments; fall back to a + // one-shot measure so the layout still resolves. + if (typeof ResizeObserver === 'undefined') { + setFooterHeight(el.offsetHeight); + return; + } + const ro = new ResizeObserver(() => setFooterHeight(el.offsetHeight)); + ro.observe(el); + setFooterHeight(el.offsetHeight); + return () => ro.disconnect(); + }, [footer]); + + return ( +
+ {header} +
+ {children} +
+ + {/* Fade so messages dissolve into the background behind the composer. */} + {footer ? ( + + ); +} + +/** + * The shared chat composer wired for the orchestration surfaces: attachments and + * voice mode are off, and Enter sends (Shift+Enter inserts a newline). The host + * owns the input value + the async send. + */ +function AgentComposer({ + value, + setValue, + onSend, + isSending, + placeholder, +}: { + value: string; + setValue: (value: string | ((prev: string) => string)) => void; + onSend: (text?: string) => Promise; + isSending: boolean; + placeholder: string; +}) { + const textInputRef = useRef(null); + const fileInputRef = useRef(null); + const isComposingTextRef = useRef(false); + + const handleInputKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'Enter' && !event.shiftKey && !isComposingTextRef.current) { + event.preventDefault(); + void onSend(); + } + }, + [onSend] + ); + + return ( + { + await onSend(text); + }} + textInputRef={textInputRef} + fileInputRef={fileInputRef} + composerInteractionBlocked={false} + isSending={isSending} + attachments={[]} + onAttachFiles={async () => {}} + onRemoveAttachment={() => {}} + attachError={null} + onSwitchToMicCloud={() => {}} + handleInputKeyDown={handleInputKeyDown} + inlineCompletionSuffix="" + isComposingTextRef={isComposingTextRef} + maxAttachments={0} + allowedMimeTypes={[]} + attachmentsEnabled={false} + micEnabled={false} + placeholder={placeholder} + /> + ); +} + +/** + * Peer-session subpage — takes over the whole agent pane (not a side drawer) so + * the session's chat renders full-size, with a back link to return to the agent + * chat. Replies go to the peer via the master send path. + */ +function SessionChatView({ session }: { session: SessionSummary }) { const { t } = useT(); const { state, messages, refresh } = useSessionTranscript(session.sessionId); const [body, setBody] = useState(''); const [sending, setSending] = useState(false); const [sendError, setSendError] = useState(null); + const mountedRef = useRef(true); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); - const submit = useCallback( - (event: FormEvent) => { - event.preventDefault(); - const trimmed = body.trim(); + const send = useCallback( + async (text?: string) => { + const trimmed = (text ?? body).trim(); if (!trimmed || sending) return; setSending(true); setSendError(null); debug('[orchestration:agent-chat] session reply: send session=%s', session.sessionId); - void orchestrationClient - .sendMasterMessage({ + try { + await orchestrationClient.sendMasterMessage({ body: trimmed, recipient: session.agentId, sessionId: session.sessionId, - }) - .then(() => { - setBody(''); - void refresh(); - }) - .catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - debug( - '[orchestration:agent-chat] session reply: failed session=%s %s', - session.sessionId, - message - ); - setSendError(message); - }) - .finally(() => setSending(false)); + }); + if (!mountedRef.current) return; + setBody(''); + void refresh(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + debug( + '[orchestration:agent-chat] session reply: failed session=%s %s', + session.sessionId, + message + ); + if (mountedRef.current) setSendError(message); + } finally { + if (mountedRef.current) setSending(false); + } }, [body, sending, session.agentId, session.sessionId, refresh] ); + const runtime = session.harnessType || session.source || null; + const directory = session.workspace?.trim() || null; + const runningOn = session.agentId?.trim() || null; + return ( - + ); } -export default function AgentChatPanel() { +export interface AgentChatPanelProps { + /** + * Controlled open peer-session id (the full-page session subpage). When + * `onOpenSession` is provided the parent owns this (OrchestrationPage drives + * it from the `?session=` query param + the sidebar's active sub-agents list); + * otherwise the panel falls back to its own local state. + */ + openSessionId?: string | null; + onOpenSession?: (sessionId: string | null) => void; +} + +export default function AgentChatPanel({ + openSessionId: controlledOpenSessionId, + onOpenSession, +}: AgentChatPanelProps = {}) { const { t } = useT(); const { sessionsState, @@ -161,7 +347,12 @@ export default function AgentChatPanel() { const [composerBody, setComposerBody] = useState(''); const [sending, setSending] = useState(false); const [runningReview, setRunningReview] = useState(false); - const [openSessionId, setOpenSessionId] = useState(null); + // Controlled by the parent when `onOpenSession` is wired (OrchestrationPage + // drives it from the URL + sidebar session list); local state otherwise. + const [localOpenSessionId, setLocalOpenSessionId] = useState(null); + const openSessionId = + controlledOpenSessionId !== undefined ? controlledOpenSessionId : localOpenSessionId; + const setOpenSessionId = onOpenSession ?? setLocalOpenSessionId; const mountedRef = useRef(true); useEffect(() => { mountedRef.current = true; @@ -170,17 +361,16 @@ export default function AgentChatPanel() { }; }, []); - const submitComposer = useCallback( - (event: FormEvent) => { - event.preventDefault(); - const body = composerBody.trim(); + // Send the master composer's body via the orchestration send path. + const sendComposer = useCallback( + async (text?: string) => { + const body = (text ?? composerBody).trim(); if (!body || sending) return; setSending(true); - void sendMessage(selected, body).then(ok => { - if (!mountedRef.current) return; - if (ok) setComposerBody(''); - setSending(false); - }); + const ok = await sendMessage(selected, body); + if (!mountedRef.current) return; + if (ok) setComposerBody(''); + setSending(false); }, [composerBody, sending, sendMessage, selected] ); @@ -205,193 +395,167 @@ export default function AgentChatPanel() { // Sessions the agent has engaged that are parked on an approval → "needs you". const pinged = contactSessions.sessions.filter(s => s.status === 'waiting-approval'); const openSession = contactSessions.sessions.find(s => s.sessionId === openSessionId) ?? null; + // Empty conscious thread → show the shared welcome hero (reuses the generic + // chat's "new window" panel) instead of the bare "no messages" line. + const showHero = isMasterSelected && (selected?.messages.length ?? 0) === 0; - return ( -
- {/* Thread rail — mirrors the normal chat's ThreadList. */} - + // A session is open → take over the whole pane with its full-page chat. + // Returning to the master chat is done from the sidebar's Chat item (which + // clears the `?session=` param), so no in-view back button is needed. + if (openSession) { + return ; + } - {/* Message pane. */} -
- {/* Subconscious steering header. */} - {isSubconscious ? ( -
-
-

- {steeringText - ? t('tinyplaceOrchestration.steeringHeader.current') - : t('tinyplaceOrchestration.steeringHeader.none')} -

- {steeringText ? ( -

{steeringText}

- ) : null} -
- -
- ) : null} + const loading = sessionsState.status === 'loading' || messagesState.status === 'loading'; + const errored = sessionsState.status === 'error' || messagesState.status === 'error'; - {sessionsState.status === 'loading' || messagesState.status === 'loading' ? ( -
- {t('tinyplaceOrchestration.loading')} -
- ) : sessionsState.status === 'error' || messagesState.status === 'error' ? ( -
-

- {t('tinyplaceOrchestration.failedToLoad')}:{' '} - {sessionsState.status === 'error' - ? sessionsState.message - : messagesState.status === 'error' - ? messagesState.message - : ''} -

- -
- ) : ( -
-
- {selected?.messages.length ? ( - - ) : ( -

- {t('tinyplaceOrchestration.noMessages')} -

- )} - - {/* View-session cards for sessions the agent engaged (needs you). */} - {pinged.map(session => ( -
- -
- ))} -
-
- )} - - {/* Master composer. */} - {isMasterSelected && sessionsState.status === 'ok' ? ( -
- {masterError ? ( -

- {t('tinyplaceOrchestration.composer.sendFailed')}: {masterError} -

+ // The Conscious/Subconscious toggle — sits in the composer footer slot where + // the generic chat's super-context / quick-reasoning controls live. + const modeToggle = ( +
+ {rail.map(chat => { + const active = selectedId === chat.id; + const label = + chat.id === SUBCONSCIOUS_CHAT_KEY + ? t('orchPage.agent.subconsciousTab') + : t('orchPage.agent.consciousTab'); + return ( + -
-
- ) : null} -
- - {/* Session side-tab (opens on demand from a View-session card). */} - {openSession ? ( - // `key` resets the drawer's local composer/sending state when switching - // to a different session, so a draft reply never leaks across sessions. - setOpenSessionId(null)} - /> - ) : null} + + ); + })}
); + + const showComposer = isMasterSelected && sessionsState.status === 'ok'; + + return ( + // The main agent chat has no top header — just the conscious/subconscious + // switching chip in the footer. When subconscious is active, the steering + // directive + "Run review" ride alongside the chip (no header bar). + + {showComposer ? ( + <> + {masterError ? ( +

+ {t('tinyplaceOrchestration.composer.sendFailed')}: {masterError} +

+ ) : null} + + + ) : null} +
+ {modeToggle} + {isSubconscious ? ( +
+ {steeringText ? ( + + {steeringText} + + ) : null} + +
+ ) : null} +
+
+ }> + {loading ? ( +
+ {t('tinyplaceOrchestration.loading')} +
+ ) : errored ? ( +
+

+ {t('tinyplaceOrchestration.failedToLoad')}:{' '} + {sessionsState.status === 'error' + ? sessionsState.message + : messagesState.status === 'error' + ? messagesState.message + : ''} +

+ +
+ ) : showHero && pinged.length === 0 ? ( + // Empty conscious thread: reuse the generic chat's welcome hero. +
+ +
+ ) : ( +
+ {selected?.messages.length ? ( + + ) : showHero ? null : ( +

+ {t('tinyplaceOrchestration.noMessages')} +

+ )} + + {/* "Needs you" cards for sessions the agent engaged → open the session + subpage (same as clicking it in the sidebar's active sub-agents). */} + {pinged.map(session => ( +
+ +
+ ))} +
+ )} + + ); } diff --git a/app/src/components/orchestration/ConnectionsPanel.tsx b/app/src/components/orchestration/ConnectionsPanel.tsx index f9bae83cf..dd4695bb1 100644 --- a/app/src/components/orchestration/ConnectionsPanel.tsx +++ b/app/src/components/orchestration/ConnectionsPanel.tsx @@ -299,7 +299,14 @@ function ConnectionRow({ ); } -export default function ConnectionsPanel({ onDiscover }: { onDiscover?: () => void }) { +export default function ConnectionsPanel({ + onDiscover, + onInitializeAgent, +}: { + onDiscover?: () => void; + /** Jump to the agent chat to describe + spin up a new sub-agent. */ + onInitializeAgent?: () => void; +}) { const { t } = useT(); const { state, runAction, pendingAction, actionError } = usePairing(); const sessions = useContactSessions(); @@ -519,6 +526,22 @@ export default function ConnectionsPanel({ onDiscover }: { onDiscover?: () => vo )} + + {/* Initialize a brand-new sub-agent / instance. Unlike Discover (which + links to an EXISTING peer), this explains how to spin up your own. */} + + +
); } diff --git a/app/src/components/orchestration/OrchestratorTaskBoard.tsx b/app/src/components/orchestration/OrchestratorTaskBoard.tsx new file mode 100644 index 000000000..7eb92242b --- /dev/null +++ b/app/src/components/orchestration/OrchestratorTaskBoard.tsx @@ -0,0 +1,191 @@ +/** + * OrchestratorTaskBoard — the orchestrator's single, app-wide Kanban board. + * + * One global board (keyed by {@link ORCHESTRATOR_TASKS_THREAD_ID}) that the + * orchestrator owns and the user can edit. Deliberately NOT per-thread: thread- + * scoped to-dos / goals live under Tiny Agents, so this surface shows exactly + * one board — no aggregation of agent/thread boards. + * + * Fully editable via the `todos_*` RPC (each mutation returns the fresh board, + * which we set directly). Renders the shared {@link TaskKanbanBoard}. + */ +import debugFactory from 'debug'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { + type ColumnDef, + TaskKanbanBoard, +} from '../../features/conversations/components/TaskKanbanBoard'; +import { useT } from '../../lib/i18n/I18nContext'; +import { ORCHESTRATOR_TASKS_THREAD_ID, todosApi } from '../../services/api/todosApi'; +import type { TaskBoard, TaskBoardCard, TaskBoardCardStatus } from '../../types/turnState'; +import Button from '../ui/Button'; + +const debug = debugFactory('orchestration:task-board'); + +const EMPTY_BOARD: TaskBoard = { threadId: ORCHESTRATOR_TASKS_THREAD_ID, cards: [], updatedAt: '' }; + +/** The orchestrator board is a simple 4-column board. */ +const ORCHESTRATOR_COLUMNS: ColumnDef[] = [ + { status: 'todo', labelKey: 'orchPage.tasks.colPending' }, + { status: 'in_progress', labelKey: 'orchPage.tasks.colActive' }, + { status: 'blocked', labelKey: 'orchPage.tasks.colBlocked' }, + { status: 'done', labelKey: 'orchPage.tasks.colCompleted' }, +]; + +export default function OrchestratorTaskBoard() { + const { t } = useT(); + const [board, setBoard] = useState(null); + const [error, setError] = useState(null); + const [draft, setDraft] = useState(''); + const [adding, setAdding] = useState(false); + const [mutatingCardId, setMutatingCardId] = useState(null); + const mountedRef = useRef(true); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const load = useCallback(async () => { + try { + const next = await todosApi.list(ORCHESTRATOR_TASKS_THREAD_ID); + if (!mountedRef.current) return; + setBoard(next); + setError(null); + } catch (err) { + debug('load failed: %o', err); + if (!mountedRef.current) return; + setError(err instanceof Error ? err.message : String(err)); + setBoard(prev => prev ?? EMPTY_BOARD); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + // Every todos_* mutation returns the fresh board — apply it directly. + const runMutation = useCallback(async (cardId: string | null, op: () => Promise) => { + setMutatingCardId(cardId); + setError(null); + try { + const next = await op(); + if (mountedRef.current) setBoard(next); + } catch (err) { + debug('mutation failed card=%s: %o', cardId, err); + if (mountedRef.current) setError(err instanceof Error ? err.message : String(err)); + } finally { + if (mountedRef.current) setMutatingCardId(null); + } + }, []); + + const handleAdd = useCallback(async () => { + const content = draft.trim(); + if (!content || adding) return; + setAdding(true); + setError(null); + try { + const next = await todosApi.add({ threadId: ORCHESTRATOR_TASKS_THREAD_ID, content }); + if (!mountedRef.current) return; + setBoard(next); + setDraft(''); + } catch (err) { + debug('add failed: %o', err); + if (mountedRef.current) setError(err instanceof Error ? err.message : String(err)); + } finally { + if (mountedRef.current) setAdding(false); + } + }, [draft, adding]); + + const handleMove = useCallback( + (card: TaskBoardCard, status: TaskBoardCardStatus) => { + void runMutation(card.id, () => + todosApi.updateStatus(ORCHESTRATOR_TASKS_THREAD_ID, card.id, status) + ); + }, + [runMutation] + ); + + const handleUpdate = useCallback( + (card: TaskBoardCard, nextCard: TaskBoardCard) => { + void runMutation(card.id, () => + todosApi.edit({ + threadId: ORCHESTRATOR_TASKS_THREAD_ID, + id: card.id, + content: nextCard.title, + status: nextCard.status, + objective: nextCard.objective ?? null, + notes: nextCard.notes ?? null, + blocker: nextCard.blocker ?? null, + assignedAgent: nextCard.assignedAgent ?? null, + approvalMode: nextCard.approvalMode ?? null, + plan: nextCard.plan ?? [], + allowedTools: nextCard.allowedTools ?? [], + acceptanceCriteria: nextCard.acceptanceCriteria ?? [], + evidence: nextCard.evidence ?? [], + }) + ); + }, + [runMutation] + ); + + const handleDelete = useCallback( + (card: TaskBoardCard) => { + void runMutation(card.id, () => todosApi.remove(ORCHESTRATOR_TASKS_THREAD_ID, card.id)); + }, + [runMutation] + ); + + return ( +
+
+

{t('orchPage.tasks.nav')}

+

{t('orchPage.tasks.subtitle')}

+
+ + {error ? ( +
+ {error} + +
+ ) : null} + +
{ + event.preventDefault(); + void handleAdd(); + }}> + setDraft(event.target.value)} + placeholder={t('subconscious.addTaskPlaceholder')} + data-testid="orch-task-add-input" + className="min-w-0 flex-1 rounded-md border border-line bg-surface px-3 py-2 text-sm text-content outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20" + /> + +
+ + +
+ ); +} diff --git a/app/src/components/orchestration/OverviewPanel.tsx b/app/src/components/orchestration/OverviewPanel.tsx new file mode 100644 index 000000000..b99171223 --- /dev/null +++ b/app/src/components/orchestration/OverviewPanel.tsx @@ -0,0 +1,110 @@ +/** + * OverviewPanel — an interactive "Agent graph" of the agent / sub-agent system, + * reusing the Brain's {@link MemoryGraph} (drag / pan / zoom, WebGL with SVG + * fallback), full-screen and fit-to-bounds. + * + * Three tiers fan out from a central **agent core** hub: + * - core (the synthetic root) + * - **agents** — every accepted peer contact (`source` nodes). Offline agents + * (no live session) render in a dead/grey colour. + * - **sub-agents** — the sessions running under each agent (`chunk` nodes), + * linked to their agent by explicit edges; disconnected ones also greyed. + * + * Contacts come from {@link usePairing} (so offline agents with no sessions + * still appear); sessions come from {@link useContactSessions}. + */ +import { useMemo } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import type { SessionSummary } from '../../lib/orchestration/orchestrationClient'; +import { useContactSessions } from '../../lib/orchestration/useOrchestrationSessions'; +import { usePairing } from '../../lib/orchestration/usePairing'; +import type { GraphEdge, GraphNode } from '../../utils/tauriCommands'; +import { MemoryGraph } from '../intelligence/MemoryGraph'; +import { contactAddress } from '../intelligence/orchestrationTabHelpers'; + +/** Muted grey for offline agents / disconnected sub-agents. */ +const OFFLINE_COLOR = '#6B7280'; + +function shortAddress(address: string): string { + return address.length <= 14 ? address : `${address.slice(0, 6)}…${address.slice(-4)}`; +} + +function sessionLabel(session: SessionSummary): string { + return session.label?.trim() || session.sessionId; +} + +/** + * A session/agent is "connected" when the core reports live peer presence; when + * presence is unknown, fall back to the recency heuristic (active / running / + * awaiting input). + */ +function isConnected(session: SessionSummary): boolean { + if (session.peerOnline === true) return true; + if (session.peerOnline === false) return false; + return session.active || session.status === 'running' || session.status === 'waiting-approval'; +} + +export default function OverviewPanel() { + const { t } = useT(); + const { byContact } = useContactSessions(); + const pairing = usePairing(); + + const { nodes, edges } = useMemo(() => { + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + + // Every accepted contact is an agent — including offline ones with no + // sessions — unioned with any address that has live sessions. + const accepted = + pairing.state.status === 'ok' + ? pairing.state.snapshot.contacts.contacts.filter(c => c.status === 'accepted') + : []; + const addresses = new Set(); + for (const c of accepted) { + const addr = contactAddress(c); + if (addr) addresses.add(addr); + } + for (const addr of byContact.keys()) addresses.add(addr); + + for (const address of addresses) { + const sessions = byContact.get(address) ?? []; + const online = sessions.some(isConnected); + const instanceId = `inst:${address}`; + nodes.push({ + kind: 'source', + id: instanceId, + label: shortAddress(address), + ...(online ? {} : { color: OFFLINE_COLOR }), + }); + for (const session of sessions) { + const sessionNodeId = `sess:${session.sessionId}`; + nodes.push({ + kind: 'chunk', + id: sessionNodeId, + label: sessionLabel(session), + ...(isConnected(session) ? {} : { color: OFFLINE_COLOR }), + }); + edges.push({ from: sessionNodeId, to: instanceId }); + } + } + return { nodes, edges }; + }, [byContact, pairing.state]); + + return ( + // Full-screen: the graph fills the whole pane (no card gutter) and fits the + // whole agent/sub-agent cloud tightly to the viewport. +
+ +
+ ); +} diff --git a/app/src/components/orchestration/__tests__/AgentChatPanel.test.tsx b/app/src/components/orchestration/__tests__/AgentChatPanel.test.tsx index a27ff99f6..b9088ff15 100644 --- a/app/src/components/orchestration/__tests__/AgentChatPanel.test.tsx +++ b/app/src/components/orchestration/__tests__/AgentChatPanel.test.tsx @@ -6,6 +6,12 @@ import AgentChatPanel from '../AgentChatPanel'; vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); +// The welcome hero pulls Redux/user/usage state; stub it so the panel renders +// without the app providers. We only assert it mounts on an empty conscious thread. +vi.mock('../../chat/ChatNewWindowHero', () => ({ + default: () =>
, +})); + const selectChat = vi.hoisted(() => vi.fn()); const sendMessage = vi.hoisted(() => vi.fn().mockResolvedValue(true)); const chatsApi = vi.hoisted(() => ({ @@ -86,13 +92,20 @@ describe('AgentChatPanel', () => { }; }); - it('renders the thread rail and switches conversation', () => { + it('renders the conscious/subconscious toggle and switches conversation', () => { render(); - expect(screen.getByTestId('orch-agent-tab-master')).toBeInTheDocument(); + const conscious = screen.getByTestId('orch-agent-tab-master'); + expect(conscious).toHaveAttribute('role', 'radio'); + expect(conscious).toHaveAttribute('aria-checked', 'true'); fireEvent.click(screen.getByTestId('orch-agent-tab-subconscious')); expect(selectChat).toHaveBeenCalledWith('subconscious'); }); + it('shows the welcome hero on an empty conscious thread', () => { + render(); + expect(screen.getByTestId('chat-new-window-hero')).toBeInTheDocument(); + }); + it('sends a master message from the composer', async () => { render(); fireEvent.change(screen.getByTestId('orch-agent-composer-input'), { target: { value: 'go' } }); diff --git a/app/src/features/conversations/components/TaskKanbanBoard.tsx b/app/src/features/conversations/components/TaskKanbanBoard.tsx index a90fcacd4..a351d59ba 100644 --- a/app/src/features/conversations/components/TaskKanbanBoard.tsx +++ b/app/src/features/conversations/components/TaskKanbanBoard.tsx @@ -31,12 +31,13 @@ import { type TaskSourcesStatus, } from '../../../utils/tauriCommands'; -type ColumnDef = { status: TaskBoardCardStatus; labelKey: string }; +export type ColumnDef = { status: TaskBoardCardStatus; labelKey: string }; const TASK_SOURCES_THREAD_ID = 'task-sources'; -// The board surfaces five columns: -// Pending / Awaiting Approval / In Progress / Blocked / Done +// Default board layout — five columns: +// Pending / Awaiting Approval / In Progress / Blocked / Done. +// Callers can pass their own `columns` (e.g. the orchestrator's 4-column board). const COLUMN_DEFS: ColumnDef[] = [ { status: 'todo', labelKey: 'conversations.taskKanban.pending' }, { status: 'awaiting_approval', labelKey: 'conversations.taskKanban.awaitingApprovalColumn' }, @@ -45,11 +46,9 @@ const COLUMN_DEFS: ColumnDef[] = [ { status: 'done', labelKey: 'conversations.taskKanban.done' }, ]; -/** The five column statuses a user can set directly from the board. */ +/** Statuses offered in the edit dialog's status dropdown (the default set). */ const COLUMN_STATUSES = COLUMN_DEFS.map(column => column.status); -const STATUS_INDEX = new Map(COLUMN_DEFS.map((column, index) => [column.status, index])); - /** Per-column visual accent: left-border + background tint. Empty string = no accent. */ const COLUMN_ACCENT: Record = { todo: '', @@ -75,14 +74,17 @@ const STATUS_LABEL_KEYS: Record = { rejected: 'conversations.taskKanban.rejected', }; -/** Whether a status owns a kanban column. */ +/** Default status→column index, over the full column set. Used by the shared + * status helpers below (a caller's custom `columns` are always a subset). */ +const STATUS_INDEX = new Map(COLUMN_DEFS.map((column, index) => [column.status, index])); + +/** Whether a status owns a kanban column (in the default set). */ function isColumnStatus(status: TaskBoardCardStatus): boolean { return STATUS_INDEX.has(status); } /** Map a card status to the column it renders under. - * ready → in_progress column; rejected → done column. - * All other statuses now own their own column. */ + * ready → in_progress column; rejected → done column; else its own status. */ function columnFor(status: TaskBoardCardStatus): TaskBoardCardStatus { switch (status) { case 'ready': @@ -97,6 +99,9 @@ function columnFor(status: TaskBoardCardStatus): TaskBoardCardStatus { interface TaskKanbanBoardProps { board: TaskBoard; disabled?: boolean; + /** Column layout. Defaults to the 5-column set; the orchestrator board passes + * a 4-column set (Pending / Active / Blocked / Completed). */ + columns?: ColumnDef[]; headerTitleKey?: string; /** Hide the board's own "Tasks" title row — used where the caller already * renders a heading for the board, to avoid a doubled-up title. */ @@ -119,6 +124,7 @@ interface TaskKanbanBoardProps { export function TaskKanbanBoard({ board, disabled = false, + columns = COLUMN_DEFS, headerTitleKey = 'conversations.taskKanban.title', hideHeader = false, onMove, @@ -135,6 +141,15 @@ export function TaskKanbanBoard({ const [sourceControlsOpen, setSourceControlsOpen] = useState(false); const [dragOverColumn, setDragOverColumn] = useState(null); + // Per-instance column layout so a caller can override it (e.g. the + // orchestrator's 4-column board). The status→column mapping helpers stay + // shared, since any custom `columns` is a subset of the default statuses. + const columnDefs = columns; + const statusIndex = useMemo( + () => new Map(columnDefs.map((column, index) => [column.status, index])), + [columnDefs] + ); + const selectedCard = useMemo( () => board.cards.find(card => card.id === selectedCardId) ?? null, [board.cards, selectedCardId] @@ -144,7 +159,7 @@ export function TaskKanbanBoard({ const showSourceControls = isTaskSourcesBoard || hasSourceCards; // Always render (even with 0 cards) so a live agent board stays visible. - const cardsByStatus = COLUMN_DEFS.reduce( + const cardsByStatus = columnDefs.reduce( (acc, column) => { acc[column.status] = []; return acc; @@ -157,8 +172,8 @@ export function TaskKanbanBoard({ } const moveCard = (card: TaskBoardCard, direction: -1 | 1) => { - const current = STATUS_INDEX.get(columnFor(card.status)) ?? 0; - const next = COLUMN_DEFS[current + direction]?.status; + const current = statusIndex.get(columnFor(card.status)) ?? 0; + const next = columnDefs[current + direction]?.status; if (!next || disabled) return; onMove?.(card, next); }; @@ -221,7 +236,7 @@ export function TaskKanbanBoard({ )}
- {COLUMN_DEFS.map(column => { + {columnDefs.map(column => { const cards = cardsByStatus[column.status]; const isBlockedColumn = column.status === 'blocked'; const isDragTarget = dragOverColumn === column.status; diff --git a/app/src/features/human/Mascot/ManifestRiveMascot.test.tsx b/app/src/features/human/Mascot/ManifestRiveMascot.test.tsx index ced4d399c..b08db7aab 100644 --- a/app/src/features/human/Mascot/ManifestRiveMascot.test.tsx +++ b/app/src/features/human/Mascot/ManifestRiveMascot.test.tsx @@ -17,6 +17,11 @@ const h = vi.hoisted(() => ({ useRiveParams: null as Record | null, enumCalls: {} as Record, colorCalls: {} as Record, + enumValuesByPath: { + pose: ['idle', 'look_around', 'pointing'], + mouthVisemeCode: ['sil', 'PP', 'aa'], + eyes: ['blink', 'look_left', 'look_right'], + } as Record, })); vi.mock('@rive-app/react-webgl2', () => ({ @@ -35,7 +40,7 @@ vi.mock('@rive-app/react-webgl2', () => ({ useViewModelInstanceEnum: (path: string) => ({ setValue: (v: string) => (h.enumCalls[path] ??= []).push(v), value: null, - values: [], + values: h.enumValuesByPath[path] ?? [], }), useViewModelInstanceColor: (path: string) => ({ setValue: (v: number) => (h.colorCalls[path] ??= []).push(v), @@ -109,8 +114,8 @@ describe('ManifestRiveMascot', () => { await waitFor(() => expect(h.useRiveParams?.buffer).toBeInstanceOf(ArrayBuffer)); // 'thinking' face → Toshi's look_around (it has no 'thinking' pose). - expect((h.enumCalls['pose'] ?? []).at(-1)).toBe('look_around'); - expect(enumLast('mouthVisemeCode')).toBe('aa'); + await waitFor(() => expect(h.enumCalls['pose'] ?? []).toContain('look_around')); + await waitFor(() => expect(enumLast('mouthVisemeCode')).toBe('aa')); }); it('clears the previous buffer when the entry changes without a remount', async () => { diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index e2910208f..eebefc16c 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -176,11 +176,30 @@ const messages: TranslationMap = { 'orchPage.group.agent': 'الوكيل', 'orchPage.group.network': 'الشبكة', 'orchPage.group.insights': 'رؤى', + 'orchPage.overview.nav': 'رسم الوكلاء', + 'orchPage.tasks.nav': 'المهام', + 'orchPage.tasks.subtitle': 'لوحة مهام المنسق العامة', + 'orchPage.tasks.colPending': 'قيد الانتظار', + 'orchPage.tasks.colActive': 'نشط', + 'orchPage.tasks.colBlocked': 'محظور', + 'orchPage.tasks.colCompleted': 'مكتمل', + 'orchPage.overview.core': 'نواة الوكيل', + 'orchPage.overview.empty': 'لا توجد وكلاء فرعيون لعرضهم بعد', 'orchPage.agent.nav': 'محادثة', 'orchPage.agent.mainTab': 'الوكيل الرئيسي', + 'orchPage.agent.consciousTab': 'الوعي', 'orchPage.agent.subconsciousTab': 'اللاوعي', + 'orchPage.agent.modeLabel': 'عقل الوكيل', 'orchPage.agent.description': 'تحدّث مع الوكيل الرئيسي وراقب لاوعيه', 'orchPage.agent.viewSession': 'عرض الجلسة', + 'orchPage.sessions.railTitle': 'الوكلاء النشطون', + 'orchPage.sessions.empty': 'لا يوجد وكلاء نشطون بعد', + 'orchPage.sessions.statusConnected': 'متصل', + 'orchPage.sessions.statusDisconnected': 'غير متصل', + 'orchPage.sessions.statusWaiting': 'بانتظار الإدخال', + 'orchPage.session.runtime': 'وقت التشغيل', + 'orchPage.session.directory': 'الدليل', + 'orchPage.session.runningOn': 'يعمل على', 'orchPage.connections.nav': 'الاتصالات', 'orchPage.connections.title': 'الوكلاء المرتبطون', 'orchPage.connections.description': 'الأقران الذين ينسّق معهم وكيلك — وسّع أحدهم لعرض جلساتك معه', @@ -194,6 +213,10 @@ const messages: TranslationMap = { 'orchPage.connections.messageCount': '{n} رسالة', 'orchPage.connections.back': 'الاتصالات', 'orchPage.connections.replyPlaceholder': 'رد…', + 'orchPage.connections.initTitle': 'تهيئة وكيل أو نسخة', + 'orchPage.connections.initDesc': + 'أنشئ وكيلًا فرعيًا جديدًا يعمل إلى جانبك. افتح محادثة الوكيل وصف الدور والهدف والأدوات التي يجب أن يستخدمها — يقوم OpenHuman بتجهيز نسخة مخصصة يمكنك توجيهها من هنا.', + 'orchPage.connections.initCta': 'ابدأ في المحادثة', 'orchPage.connections.status.needsYou': 'يحتاج إليك', 'orchPage.connections.status.running': 'قيد التشغيل', 'orchPage.connections.status.idle': 'خامل', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 35129199f..3ec5cf7f7 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -181,11 +181,30 @@ const messages: TranslationMap = { 'orchPage.group.agent': 'এজেন্ট', 'orchPage.group.network': 'নেটওয়ার্ক', 'orchPage.group.insights': 'অন্তর্দৃষ্টি', + 'orchPage.overview.nav': 'এজেন্ট গ্রাফ', + 'orchPage.tasks.nav': 'টাস্ক', + 'orchPage.tasks.subtitle': 'অর্কেস্ট্রেটরের গ্লোবাল টাস্ক বোর্ড', + 'orchPage.tasks.colPending': 'মুলতুবি', + 'orchPage.tasks.colActive': 'সক্রিয়', + 'orchPage.tasks.colBlocked': 'অবরুদ্ধ', + 'orchPage.tasks.colCompleted': 'সম্পন্ন', + 'orchPage.overview.core': 'এজেন্ট কোর', + 'orchPage.overview.empty': 'এখনও ভিজ্যুয়ালাইজ করার মতো কোনো সাব-এজেন্ট নেই', 'orchPage.agent.nav': 'চ্যাট', 'orchPage.agent.mainTab': 'প্রধান এজেন্ট', + 'orchPage.agent.consciousTab': 'সচেতন', 'orchPage.agent.subconsciousTab': 'অবচেতন', + 'orchPage.agent.modeLabel': 'এজেন্টের মন', 'orchPage.agent.description': 'প্রধান এজেন্টের সাথে চ্যাট করুন এবং তার অবচেতন দেখুন', 'orchPage.agent.viewSession': 'সেশন দেখুন', + 'orchPage.sessions.railTitle': 'সক্রিয় এজেন্ট', + 'orchPage.sessions.empty': 'এখনও কোনো সক্রিয় এজেন্ট নেই', + 'orchPage.sessions.statusConnected': 'সংযুক্ত', + 'orchPage.sessions.statusDisconnected': 'সংযোগ বিচ্ছিন্ন', + 'orchPage.sessions.statusWaiting': 'ইনপুটের অপেক্ষায়', + 'orchPage.session.runtime': 'রানটাইম', + 'orchPage.session.directory': 'ডিরেক্টরি', + 'orchPage.session.runningOn': 'চলছে', 'orchPage.connections.nav': 'সংযোগ', 'orchPage.connections.title': 'সংযুক্ত এজেন্ট', 'orchPage.connections.description': @@ -200,6 +219,10 @@ const messages: TranslationMap = { 'orchPage.connections.messageCount': '{n}টি বার্তা', 'orchPage.connections.back': 'সংযোগ', 'orchPage.connections.replyPlaceholder': 'উত্তর…', + 'orchPage.connections.initTitle': 'একটি এজেন্ট বা ইনস্ট্যান্স চালু করুন', + 'orchPage.connections.initDesc': + 'আপনার সাথে কাজ করার জন্য একটি নতুন সাব-এজেন্ট চালু করুন। এজেন্ট চ্যাট খুলুন এবং এর ভূমিকা, লক্ষ্য ও ব্যবহার্য টুল বর্ণনা করুন — OpenHuman একটি ডেডিকেটেড ইনস্ট্যান্স তৈরি করে যা আপনি এখান থেকে পরিচালনা করতে পারেন।', + 'orchPage.connections.initCta': 'চ্যাটে শুরু করুন', 'orchPage.connections.status.needsYou': 'আপনাকে প্রয়োজন', 'orchPage.connections.status.running': 'চলছে', 'orchPage.connections.status.idle': 'নিষ্ক্রিয়', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 34fadc1be..5509defc6 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -187,11 +187,30 @@ const messages: TranslationMap = { 'orchPage.group.agent': 'Agent', 'orchPage.group.network': 'Netzwerk', 'orchPage.group.insights': 'Einblicke', + 'orchPage.overview.nav': 'Agenten-Graph', + 'orchPage.tasks.nav': 'Aufgaben', + 'orchPage.tasks.subtitle': 'Die globale Aufgabentafel des Orchestrators', + 'orchPage.tasks.colPending': 'Ausstehend', + 'orchPage.tasks.colActive': 'Aktiv', + 'orchPage.tasks.colBlocked': 'Blockiert', + 'orchPage.tasks.colCompleted': 'Abgeschlossen', + 'orchPage.overview.core': 'Agent-Kern', + 'orchPage.overview.empty': 'Noch keine Sub-Agenten zum Visualisieren', 'orchPage.agent.nav': 'Chat', 'orchPage.agent.mainTab': 'Hauptagent', + 'orchPage.agent.consciousTab': 'Bewusstsein', 'orchPage.agent.subconsciousTab': 'Unterbewusstsein', + 'orchPage.agent.modeLabel': 'Agentengeist', 'orchPage.agent.description': 'Chatte mit dem Hauptagenten und beobachte sein Unterbewusstsein', 'orchPage.agent.viewSession': 'Sitzung ansehen', + 'orchPage.sessions.railTitle': 'Aktive Agenten', + 'orchPage.sessions.empty': 'Noch keine aktiven Agenten', + 'orchPage.sessions.statusConnected': 'Verbunden', + 'orchPage.sessions.statusDisconnected': 'Getrennt', + 'orchPage.sessions.statusWaiting': 'Wartet auf Eingabe', + 'orchPage.session.runtime': 'Laufzeit', + 'orchPage.session.directory': 'Verzeichnis', + 'orchPage.session.runningOn': 'Läuft auf', 'orchPage.connections.nav': 'Verbindungen', 'orchPage.connections.title': 'Verknüpfte Agenten', 'orchPage.connections.description': @@ -206,6 +225,10 @@ const messages: TranslationMap = { 'orchPage.connections.messageCount': '{n} Nachrichten', 'orchPage.connections.back': 'Verbindungen', 'orchPage.connections.replyPlaceholder': 'Antworten…', + 'orchPage.connections.initTitle': 'Einen Agenten oder eine Instanz initialisieren', + 'orchPage.connections.initDesc': + 'Starte einen neuen Sub-Agenten, der an deiner Seite arbeitet. Öffne den Agent-Chat und beschreibe Rolle, Ziel und Tools — OpenHuman stellt eine eigene Instanz bereit, die du von hier aus steuerst.', + 'orchPage.connections.initCta': 'Im Chat starten', 'orchPage.connections.status.needsYou': 'Braucht dich', 'orchPage.connections.status.running': 'Läuft', 'orchPage.connections.status.idle': 'Inaktiv', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 9990880cc..9cbb7ab4b 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -33,11 +33,30 @@ const en: TranslationMap = { 'orchPage.group.agent': 'Agent', 'orchPage.group.network': 'Network', 'orchPage.group.insights': 'Insights', + 'orchPage.overview.nav': 'Agent graph', + 'orchPage.tasks.nav': 'Tasks', + 'orchPage.tasks.subtitle': "The orchestrator's global task board", + 'orchPage.tasks.colPending': 'Pending', + 'orchPage.tasks.colActive': 'Active', + 'orchPage.tasks.colBlocked': 'Blocked', + 'orchPage.tasks.colCompleted': 'Completed', + 'orchPage.overview.core': 'Agent core', + 'orchPage.overview.empty': 'No sub-agents to visualize yet', 'orchPage.agent.nav': 'Chat', 'orchPage.agent.mainTab': 'Main agent', + 'orchPage.agent.consciousTab': 'Conscious', 'orchPage.agent.subconsciousTab': 'Subconscious', + 'orchPage.agent.modeLabel': 'Agent mind', 'orchPage.agent.description': 'Chat with the main agent and watch its subconscious', 'orchPage.agent.viewSession': 'View session', + 'orchPage.sessions.railTitle': 'Active agents', + 'orchPage.sessions.empty': 'No active agents yet', + 'orchPage.sessions.statusConnected': 'Connected', + 'orchPage.sessions.statusDisconnected': 'Disconnected', + 'orchPage.sessions.statusWaiting': 'Waiting for input', + 'orchPage.session.runtime': 'Runtime', + 'orchPage.session.directory': 'Directory', + 'orchPage.session.runningOn': 'Running on', 'orchPage.connections.nav': 'Connections', 'orchPage.connections.title': 'Linked agents', 'orchPage.connections.description': @@ -53,6 +72,10 @@ const en: TranslationMap = { 'orchPage.connections.messageCount': '{n} messages', 'orchPage.connections.back': 'Connections', 'orchPage.connections.replyPlaceholder': 'Reply…', + 'orchPage.connections.initTitle': 'Initialize an agent or an instance', + 'orchPage.connections.initDesc': + 'Spin up a new sub-agent to work alongside you. Open the agent chat and describe the role, goal, and tools it should use — OpenHuman provisions a dedicated instance you can steer from here.', + 'orchPage.connections.initCta': 'Start in chat', 'orchPage.connections.status.needsYou': 'Needs you', 'orchPage.connections.status.running': 'Running', 'orchPage.connections.status.idle': 'Idle', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index d633f9c6e..5a172594e 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -184,11 +184,30 @@ const messages: TranslationMap = { 'orchPage.group.agent': 'Agente', 'orchPage.group.network': 'Red', 'orchPage.group.insights': 'Estadísticas', + 'orchPage.overview.nav': 'Grafo de agentes', + 'orchPage.tasks.nav': 'Tareas', + 'orchPage.tasks.subtitle': 'El tablero de tareas global del orquestador', + 'orchPage.tasks.colPending': 'Pendiente', + 'orchPage.tasks.colActive': 'Activo', + 'orchPage.tasks.colBlocked': 'Bloqueado', + 'orchPage.tasks.colCompleted': 'Completado', + 'orchPage.overview.core': 'Núcleo del agente', + 'orchPage.overview.empty': 'Aún no hay subagentes para visualizar', 'orchPage.agent.nav': 'Chat', 'orchPage.agent.mainTab': 'Agente principal', + 'orchPage.agent.consciousTab': 'Consciente', 'orchPage.agent.subconsciousTab': 'Subconsciente', + 'orchPage.agent.modeLabel': 'Mente del agente', 'orchPage.agent.description': 'Chatea con el agente principal y observa su subconsciente', 'orchPage.agent.viewSession': 'Ver sesión', + 'orchPage.sessions.railTitle': 'Agentes activos', + 'orchPage.sessions.empty': 'Aún no hay agentes activos', + 'orchPage.sessions.statusConnected': 'Conectado', + 'orchPage.sessions.statusDisconnected': 'Desconectado', + 'orchPage.sessions.statusWaiting': 'Esperando entrada', + 'orchPage.session.runtime': 'Tiempo de ejecución', + 'orchPage.session.directory': 'Directorio', + 'orchPage.session.runningOn': 'Ejecutándose en', 'orchPage.connections.nav': 'Conexiones', 'orchPage.connections.title': 'Agentes vinculados', 'orchPage.connections.description': @@ -203,6 +222,10 @@ const messages: TranslationMap = { 'orchPage.connections.messageCount': '{n} mensajes', 'orchPage.connections.back': 'Conexiones', 'orchPage.connections.replyPlaceholder': 'Responder…', + 'orchPage.connections.initTitle': 'Inicializar un agente o una instancia', + 'orchPage.connections.initDesc': + 'Crea un nuevo subagente para trabajar contigo. Abre el chat del agente y describe el rol, el objetivo y las herramientas que debe usar — OpenHuman aprovisiona una instancia dedicada que puedes dirigir desde aquí.', + 'orchPage.connections.initCta': 'Empezar en el chat', 'orchPage.connections.status.needsYou': 'Te necesita', 'orchPage.connections.status.running': 'En ejecución', 'orchPage.connections.status.idle': 'Inactivo', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 90773d9ce..82afb2009 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -184,11 +184,30 @@ const messages: TranslationMap = { 'orchPage.group.agent': 'Agent', 'orchPage.group.network': 'Réseau', 'orchPage.group.insights': 'Analyses', + 'orchPage.overview.nav': 'Graphe des agents', + 'orchPage.tasks.nav': 'Tâches', + 'orchPage.tasks.subtitle': 'Le tableau de tâches global de l’orchestrateur', + 'orchPage.tasks.colPending': 'En attente', + 'orchPage.tasks.colActive': 'Actif', + 'orchPage.tasks.colBlocked': 'Bloqué', + 'orchPage.tasks.colCompleted': 'Terminé', + 'orchPage.overview.core': 'Cœur de l’agent', + 'orchPage.overview.empty': 'Aucun sous-agent à visualiser pour l’instant', 'orchPage.agent.nav': 'Chat', 'orchPage.agent.mainTab': 'Agent principal', + 'orchPage.agent.consciousTab': 'Conscient', 'orchPage.agent.subconsciousTab': 'Subconscient', + 'orchPage.agent.modeLabel': 'Esprit de l’agent', 'orchPage.agent.description': "Discutez avec l'agent principal et observez son subconscient", 'orchPage.agent.viewSession': 'Voir la session', + 'orchPage.sessions.railTitle': 'Agents actifs', + 'orchPage.sessions.empty': 'Aucun agent actif pour le moment', + 'orchPage.sessions.statusConnected': 'Connecté', + 'orchPage.sessions.statusDisconnected': 'Déconnecté', + 'orchPage.sessions.statusWaiting': 'En attente de saisie', + 'orchPage.session.runtime': 'Environnement d’exécution', + 'orchPage.session.directory': 'Répertoire', + 'orchPage.session.runningOn': 'En cours sur', 'orchPage.connections.nav': 'Connexions', 'orchPage.connections.title': 'Agents liés', 'orchPage.connections.description': @@ -203,6 +222,10 @@ const messages: TranslationMap = { 'orchPage.connections.messageCount': '{n} messages échangés', 'orchPage.connections.back': 'Connexions', 'orchPage.connections.replyPlaceholder': 'Répondre…', + 'orchPage.connections.initTitle': 'Initialiser un agent ou une instance', + 'orchPage.connections.initDesc': + "Lancez un nouveau sous-agent pour travailler à vos côtés. Ouvrez le chat de l'agent et décrivez son rôle, son objectif et ses outils — OpenHuman provisionne une instance dédiée que vous pilotez d'ici.", + 'orchPage.connections.initCta': 'Commencer dans le chat', 'orchPage.connections.status.needsYou': 'Requiert votre attention', 'orchPage.connections.status.running': 'En cours', 'orchPage.connections.status.idle': 'Inactif', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 504271601..9415c7811 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -181,11 +181,30 @@ const messages: TranslationMap = { 'orchPage.group.agent': 'एजेंट', 'orchPage.group.network': 'नेटवर्क', 'orchPage.group.insights': 'अंतर्दृष्टि', + 'orchPage.overview.nav': 'एजेंट ग्राफ़', + 'orchPage.tasks.nav': 'कार्य', + 'orchPage.tasks.subtitle': 'ऑर्केस्ट्रेटर का वैश्विक कार्य बोर्ड', + 'orchPage.tasks.colPending': 'लंबित', + 'orchPage.tasks.colActive': 'सक्रिय', + 'orchPage.tasks.colBlocked': 'अवरुद्ध', + 'orchPage.tasks.colCompleted': 'पूर्ण', + 'orchPage.overview.core': 'एजेंट कोर', + 'orchPage.overview.empty': 'विज़ुअलाइज़ करने के लिए अभी कोई सब-एजेंट नहीं', 'orchPage.agent.nav': 'चैट', 'orchPage.agent.mainTab': 'मुख्य एजेंट', + 'orchPage.agent.consciousTab': 'चेतन', 'orchPage.agent.subconsciousTab': 'अवचेतन', + 'orchPage.agent.modeLabel': 'एजेंट का मन', 'orchPage.agent.description': 'मुख्य एजेंट से चैट करें और उसका अवचेतन देखें', 'orchPage.agent.viewSession': 'सत्र देखें', + 'orchPage.sessions.railTitle': 'सक्रिय एजेंट', + 'orchPage.sessions.empty': 'अभी तक कोई सक्रिय एजेंट नहीं', + 'orchPage.sessions.statusConnected': 'कनेक्टेड', + 'orchPage.sessions.statusDisconnected': 'डिस्कनेक्ट किया गया', + 'orchPage.sessions.statusWaiting': 'इनपुट की प्रतीक्षा में', + 'orchPage.session.runtime': 'रनटाइम', + 'orchPage.session.directory': 'निर्देशिका', + 'orchPage.session.runningOn': 'यहाँ चल रहा', 'orchPage.connections.nav': 'कनेक्शन', 'orchPage.connections.title': 'लिंक किए गए एजेंट', 'orchPage.connections.description': @@ -200,6 +219,10 @@ const messages: TranslationMap = { 'orchPage.connections.messageCount': '{n} संदेश', 'orchPage.connections.back': 'कनेक्शन', 'orchPage.connections.replyPlaceholder': 'उत्तर दें…', + 'orchPage.connections.initTitle': 'एजेंट या इंस्टेंस आरंभ करें', + 'orchPage.connections.initDesc': + 'अपने साथ काम करने के लिए एक नया सब-एजेंट शुरू करें। एजेंट चैट खोलें और उसकी भूमिका, लक्ष्य और उपकरण बताएं — OpenHuman एक समर्पित इंस्टेंस तैयार करता है जिसे आप यहीं से नियंत्रित कर सकते हैं।', + 'orchPage.connections.initCta': 'चैट में शुरू करें', 'orchPage.connections.status.needsYou': 'आपकी ज़रूरत है', 'orchPage.connections.status.running': 'चल रहा है', 'orchPage.connections.status.idle': 'निष्क्रिय', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 89738d585..9b3b98c34 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -182,11 +182,30 @@ const messages: TranslationMap = { 'orchPage.group.agent': 'Agen', 'orchPage.group.network': 'Jaringan', 'orchPage.group.insights': 'Wawasan', + 'orchPage.overview.nav': 'Grafik agen', + 'orchPage.tasks.nav': 'Tugas', + 'orchPage.tasks.subtitle': 'Papan tugas global orkestrator', + 'orchPage.tasks.colPending': 'Tertunda', + 'orchPage.tasks.colActive': 'Aktif', + 'orchPage.tasks.colBlocked': 'Terblokir', + 'orchPage.tasks.colCompleted': 'Selesai', + 'orchPage.overview.core': 'Inti agen', + 'orchPage.overview.empty': 'Belum ada sub-agen untuk divisualisasikan', 'orchPage.agent.nav': 'Obrolan', 'orchPage.agent.mainTab': 'Agen utama', + 'orchPage.agent.consciousTab': 'Sadar', 'orchPage.agent.subconsciousTab': 'Bawah sadar', + 'orchPage.agent.modeLabel': 'Pikiran agen', 'orchPage.agent.description': 'Mengobrol dengan agen utama dan amati bawah sadarnya', 'orchPage.agent.viewSession': 'Lihat sesi', + 'orchPage.sessions.railTitle': 'Agen aktif', + 'orchPage.sessions.empty': 'Belum ada agen aktif', + 'orchPage.sessions.statusConnected': 'Terhubung', + 'orchPage.sessions.statusDisconnected': 'Terputus', + 'orchPage.sessions.statusWaiting': 'Menunggu masukan', + 'orchPage.session.runtime': 'Waktu-jalan', + 'orchPage.session.directory': 'Direktori', + 'orchPage.session.runningOn': 'Berjalan di', 'orchPage.connections.nav': 'Koneksi', 'orchPage.connections.title': 'Agen tertaut', 'orchPage.connections.description': @@ -201,6 +220,10 @@ const messages: TranslationMap = { 'orchPage.connections.messageCount': '{n} pesan', 'orchPage.connections.back': 'Koneksi', 'orchPage.connections.replyPlaceholder': 'Balas…', + 'orchPage.connections.initTitle': 'Inisialisasi agen atau instans', + 'orchPage.connections.initDesc': + 'Buat sub-agen baru untuk bekerja bersama Anda. Buka obrolan agen dan jelaskan peran, tujuan, serta alat yang harus digunakan — OpenHuman menyediakan instans khusus yang dapat Anda kendalikan dari sini.', + 'orchPage.connections.initCta': 'Mulai di obrolan', 'orchPage.connections.status.needsYou': 'Perlu Anda', 'orchPage.connections.status.running': 'Berjalan', 'orchPage.connections.status.idle': 'Menganggur', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index b546d0056..5b171b95b 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -184,11 +184,30 @@ const messages: TranslationMap = { 'orchPage.group.agent': 'Agente', 'orchPage.group.network': 'Rete', 'orchPage.group.insights': 'Approfondimenti', + 'orchPage.overview.nav': 'Grafo degli agenti', + 'orchPage.tasks.nav': 'Attività', + 'orchPage.tasks.subtitle': 'La bacheca attività globale dell’orchestratore', + 'orchPage.tasks.colPending': 'In attesa', + 'orchPage.tasks.colActive': 'Attivo', + 'orchPage.tasks.colBlocked': 'Bloccato', + 'orchPage.tasks.colCompleted': 'Completato', + 'orchPage.overview.core': 'Nucleo dell’agente', + 'orchPage.overview.empty': 'Nessun sotto-agente da visualizzare ancora', 'orchPage.agent.nav': 'Chat', 'orchPage.agent.mainTab': 'Agente principale', + 'orchPage.agent.consciousTab': 'Conscio', 'orchPage.agent.subconsciousTab': 'Subconscio', + 'orchPage.agent.modeLabel': 'Mente dell’agente', 'orchPage.agent.description': "Chatta con l'agente principale e osserva il suo subconscio", 'orchPage.agent.viewSession': 'Vedi sessione', + 'orchPage.sessions.railTitle': 'Agenti attivi', + 'orchPage.sessions.empty': 'Ancora nessun agente attivo', + 'orchPage.sessions.statusConnected': 'Connesso', + 'orchPage.sessions.statusDisconnected': 'Disconnesso', + 'orchPage.sessions.statusWaiting': 'In attesa di input', + 'orchPage.session.runtime': 'Esecuzione', + 'orchPage.session.directory': 'Cartella', + 'orchPage.session.runningOn': 'In esecuzione su', 'orchPage.connections.nav': 'Connessioni', 'orchPage.connections.title': 'Agenti collegati', 'orchPage.connections.description': @@ -203,6 +222,10 @@ const messages: TranslationMap = { 'orchPage.connections.messageCount': '{n} messaggi', 'orchPage.connections.back': 'Connessioni', 'orchPage.connections.replyPlaceholder': 'Rispondi…', + 'orchPage.connections.initTitle': "Inizializza un agente o un'istanza", + 'orchPage.connections.initDesc': + "Avvia un nuovo sotto-agente che lavori al tuo fianco. Apri la chat dell'agente e descrivi ruolo, obiettivo e strumenti — OpenHuman fornisce un'istanza dedicata che puoi guidare da qui.", + 'orchPage.connections.initCta': 'Inizia nella chat', 'orchPage.connections.status.needsYou': 'Richiede la tua attenzione', 'orchPage.connections.status.running': 'In esecuzione', 'orchPage.connections.status.idle': 'Inattivo', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index e74e0dd07..93980148c 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -178,11 +178,30 @@ const messages: TranslationMap = { 'orchPage.group.agent': '에이전트', 'orchPage.group.network': '네트워크', 'orchPage.group.insights': '인사이트', + 'orchPage.overview.nav': '에이전트 그래프', + 'orchPage.tasks.nav': '작업', + 'orchPage.tasks.subtitle': '오케스트레이터의 전역 작업 보드', + 'orchPage.tasks.colPending': '대기 중', + 'orchPage.tasks.colActive': '활성', + 'orchPage.tasks.colBlocked': '차단됨', + 'orchPage.tasks.colCompleted': '완료', + 'orchPage.overview.core': '에이전트 코어', + 'orchPage.overview.empty': '아직 시각화할 서브 에이전트가 없습니다', 'orchPage.agent.nav': '채팅', 'orchPage.agent.mainTab': '메인 에이전트', + 'orchPage.agent.consciousTab': '의식', 'orchPage.agent.subconsciousTab': '잠재의식', + 'orchPage.agent.modeLabel': '에이전트 마음', 'orchPage.agent.description': '메인 에이전트와 대화하고 잠재의식을 지켜보세요', 'orchPage.agent.viewSession': '세션 보기', + 'orchPage.sessions.railTitle': '활성 에이전트', + 'orchPage.sessions.empty': '아직 활성 에이전트가 없습니다', + 'orchPage.sessions.statusConnected': '연결됨', + 'orchPage.sessions.statusDisconnected': '연결 끊김', + 'orchPage.sessions.statusWaiting': '입력 대기 중', + 'orchPage.session.runtime': '런타임', + 'orchPage.session.directory': '디렉터리', + 'orchPage.session.runningOn': '실행 위치', 'orchPage.connections.nav': '연결', 'orchPage.connections.title': '연결된 에이전트', 'orchPage.connections.description': @@ -197,6 +216,10 @@ const messages: TranslationMap = { 'orchPage.connections.messageCount': '{n}개 메시지', 'orchPage.connections.back': '연결', 'orchPage.connections.replyPlaceholder': '답장…', + 'orchPage.connections.initTitle': '에이전트 또는 인스턴스 초기화', + 'orchPage.connections.initDesc': + '함께 일할 새 서브 에이전트를 만드세요. 에이전트 채팅을 열고 역할, 목표, 사용할 도구를 설명하면 OpenHuman이 여기서 조종할 수 있는 전용 인스턴스를 준비합니다.', + 'orchPage.connections.initCta': '채팅에서 시작', 'orchPage.connections.status.needsYou': '확인 필요', 'orchPage.connections.status.running': '실행 중', 'orchPage.connections.status.idle': '유휴', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 8092624b6..172798ec4 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -186,11 +186,30 @@ const messages: TranslationMap = { 'orchPage.group.agent': 'Agent', 'orchPage.group.network': 'Sieć', 'orchPage.group.insights': 'Statystyki', + 'orchPage.overview.nav': 'Graf agentów', + 'orchPage.tasks.nav': 'Zadania', + 'orchPage.tasks.subtitle': 'Globalna tablica zadań orkiestratora', + 'orchPage.tasks.colPending': 'Oczekujące', + 'orchPage.tasks.colActive': 'Aktywne', + 'orchPage.tasks.colBlocked': 'Zablokowane', + 'orchPage.tasks.colCompleted': 'Ukończone', + 'orchPage.overview.core': 'Rdzeń agenta', + 'orchPage.overview.empty': 'Brak subagentów do wizualizacji', 'orchPage.agent.nav': 'Czat', 'orchPage.agent.mainTab': 'Główny agent', + 'orchPage.agent.consciousTab': 'Świadomość', 'orchPage.agent.subconsciousTab': 'Podświadomość', + 'orchPage.agent.modeLabel': 'Umysł agenta', 'orchPage.agent.description': 'Rozmawiaj z głównym agentem i obserwuj jego podświadomość', 'orchPage.agent.viewSession': 'Zobacz sesję', + 'orchPage.sessions.railTitle': 'Aktywni agenci', + 'orchPage.sessions.empty': 'Brak aktywnych agentów', + 'orchPage.sessions.statusConnected': 'Połączono', + 'orchPage.sessions.statusDisconnected': 'Rozłączono', + 'orchPage.sessions.statusWaiting': 'Oczekiwanie na dane', + 'orchPage.session.runtime': 'Środowisko', + 'orchPage.session.directory': 'Katalog', + 'orchPage.session.runningOn': 'Działa na', 'orchPage.connections.nav': 'Połączenia', 'orchPage.connections.title': 'Połączeni agenci', 'orchPage.connections.description': @@ -205,6 +224,10 @@ const messages: TranslationMap = { 'orchPage.connections.messageCount': '{n} wiadomości', 'orchPage.connections.back': 'Połączenia', 'orchPage.connections.replyPlaceholder': 'Odpowiedz…', + 'orchPage.connections.initTitle': 'Zainicjuj agenta lub instancję', + 'orchPage.connections.initDesc': + 'Uruchom nowego subagenta do pracy u twojego boku. Otwórz czat agenta i opisz rolę, cel oraz narzędzia — OpenHuman udostępni dedykowaną instancję, którą pokierujesz stąd.', + 'orchPage.connections.initCta': 'Zacznij w czacie', 'orchPage.connections.status.needsYou': 'Wymaga Ciebie', 'orchPage.connections.status.running': 'Uruchomione', 'orchPage.connections.status.idle': 'Bezczynny', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 52eb829f8..6379f6883 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -183,11 +183,30 @@ const messages: TranslationMap = { 'orchPage.group.agent': 'Agente', 'orchPage.group.network': 'Rede', 'orchPage.group.insights': 'Insights', + 'orchPage.overview.nav': 'Grafo de agentes', + 'orchPage.tasks.nav': 'Tarefas', + 'orchPage.tasks.subtitle': 'O quadro de tarefas global do orquestrador', + 'orchPage.tasks.colPending': 'Pendente', + 'orchPage.tasks.colActive': 'Ativo', + 'orchPage.tasks.colBlocked': 'Bloqueado', + 'orchPage.tasks.colCompleted': 'Concluído', + 'orchPage.overview.core': 'Núcleo do agente', + 'orchPage.overview.empty': 'Ainda não há subagentes para visualizar', 'orchPage.agent.nav': 'Chat', 'orchPage.agent.mainTab': 'Agente principal', + 'orchPage.agent.consciousTab': 'Consciente', 'orchPage.agent.subconsciousTab': 'Subconsciente', + 'orchPage.agent.modeLabel': 'Mente do agente', 'orchPage.agent.description': 'Converse com o agente principal e observe seu subconsciente', 'orchPage.agent.viewSession': 'Ver sessão', + 'orchPage.sessions.railTitle': 'Agentes ativos', + 'orchPage.sessions.empty': 'Ainda não há agentes ativos', + 'orchPage.sessions.statusConnected': 'Conectado', + 'orchPage.sessions.statusDisconnected': 'Desconectado', + 'orchPage.sessions.statusWaiting': 'Aguardando entrada', + 'orchPage.session.runtime': 'Tempo de execução', + 'orchPage.session.directory': 'Diretório', + 'orchPage.session.runningOn': 'Em execução em', 'orchPage.connections.nav': 'Conexões', 'orchPage.connections.title': 'Agentes vinculados', 'orchPage.connections.description': @@ -202,6 +221,10 @@ const messages: TranslationMap = { 'orchPage.connections.messageCount': '{n} mensagens', 'orchPage.connections.back': 'Conexões', 'orchPage.connections.replyPlaceholder': 'Responder…', + 'orchPage.connections.initTitle': 'Inicializar um agente ou uma instância', + 'orchPage.connections.initDesc': + 'Crie um novo subagente para trabalhar ao seu lado. Abra o chat do agente e descreva o papel, o objetivo e as ferramentas — o OpenHuman provisiona uma instância dedicada que você controla daqui.', + 'orchPage.connections.initCta': 'Começar no chat', 'orchPage.connections.status.needsYou': 'Precisa de você', 'orchPage.connections.status.running': 'Em execução', 'orchPage.connections.status.idle': 'Inativo', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 5d59e25b3..b55cb4ca3 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -186,11 +186,30 @@ const messages: TranslationMap = { 'orchPage.group.agent': 'Агент', 'orchPage.group.network': 'Сеть', 'orchPage.group.insights': 'Аналитика', + 'orchPage.overview.nav': 'Граф агентов', + 'orchPage.tasks.nav': 'Задачи', + 'orchPage.tasks.subtitle': 'Глобальная доска задач оркестратора', + 'orchPage.tasks.colPending': 'Ожидание', + 'orchPage.tasks.colActive': 'Активно', + 'orchPage.tasks.colBlocked': 'Заблокировано', + 'orchPage.tasks.colCompleted': 'Завершено', + 'orchPage.overview.core': 'Ядро агента', + 'orchPage.overview.empty': 'Пока нет субагентов для визуализации', 'orchPage.agent.nav': 'Чат', 'orchPage.agent.mainTab': 'Главный агент', + 'orchPage.agent.consciousTab': 'Сознание', 'orchPage.agent.subconsciousTab': 'Подсознание', + 'orchPage.agent.modeLabel': 'Разум агента', 'orchPage.agent.description': 'Общайтесь с главным агентом и наблюдайте за его подсознанием', 'orchPage.agent.viewSession': 'Показать сессию', + 'orchPage.sessions.railTitle': 'Активные агенты', + 'orchPage.sessions.empty': 'Пока нет активных агентов', + 'orchPage.sessions.statusConnected': 'Подключено', + 'orchPage.sessions.statusDisconnected': 'Отключено', + 'orchPage.sessions.statusWaiting': 'Ожидание ввода', + 'orchPage.session.runtime': 'Среда выполнения', + 'orchPage.session.directory': 'Каталог', + 'orchPage.session.runningOn': 'Выполняется на', 'orchPage.connections.nav': 'Связи', 'orchPage.connections.title': 'Связанные агенты', 'orchPage.connections.description': @@ -205,6 +224,10 @@ const messages: TranslationMap = { 'orchPage.connections.messageCount': '{n} сообщений', 'orchPage.connections.back': 'Связи', 'orchPage.connections.replyPlaceholder': 'Ответить…', + 'orchPage.connections.initTitle': 'Инициализировать агента или экземпляр', + 'orchPage.connections.initDesc': + 'Создайте нового субагента для работы рядом с вами. Откройте чат агента и опишите роль, цель и инструменты — OpenHuman подготовит выделенный экземпляр, которым вы управляете отсюда.', + 'orchPage.connections.initCta': 'Начать в чате', 'orchPage.connections.status.needsYou': 'Требует вас', 'orchPage.connections.status.running': 'Выполняется', 'orchPage.connections.status.idle': 'Неактивно', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 45c0d13a1..bc5462a54 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -164,11 +164,30 @@ const messages: TranslationMap = { 'orchPage.group.agent': '智能体', 'orchPage.group.network': '网络', 'orchPage.group.insights': '洞察', + 'orchPage.overview.nav': '智能体图谱', + 'orchPage.tasks.nav': '任务', + 'orchPage.tasks.subtitle': '编排器的全局任务看板', + 'orchPage.tasks.colPending': '待处理', + 'orchPage.tasks.colActive': '进行中', + 'orchPage.tasks.colBlocked': '已阻塞', + 'orchPage.tasks.colCompleted': '已完成', + 'orchPage.overview.core': '智能体核心', + 'orchPage.overview.empty': '暂无可可视化的子智能体', 'orchPage.agent.nav': '聊天', 'orchPage.agent.mainTab': '主智能体', + 'orchPage.agent.consciousTab': '意识', 'orchPage.agent.subconsciousTab': '潜意识', + 'orchPage.agent.modeLabel': '智能体心智', 'orchPage.agent.description': '与主智能体聊天并观察其潜意识', 'orchPage.agent.viewSession': '查看会话', + 'orchPage.sessions.railTitle': '活跃智能体', + 'orchPage.sessions.empty': '暂无活跃智能体', + 'orchPage.sessions.statusConnected': '已连接', + 'orchPage.sessions.statusDisconnected': '已断开', + 'orchPage.sessions.statusWaiting': '等待输入', + 'orchPage.session.runtime': '运行时', + 'orchPage.session.directory': '目录', + 'orchPage.session.runningOn': '运行于', 'orchPage.connections.nav': '连接', 'orchPage.connections.title': '已关联智能体', 'orchPage.connections.description': '你的智能体协作的伙伴 — 展开其中一个即可查看你与它的会话', @@ -182,6 +201,10 @@ const messages: TranslationMap = { 'orchPage.connections.messageCount': '{n} 条消息', 'orchPage.connections.back': '连接', 'orchPage.connections.replyPlaceholder': '回复…', + 'orchPage.connections.initTitle': '初始化一个智能体或实例', + 'orchPage.connections.initDesc': + '启动一个新的子智能体与你协作。打开智能体聊天,描述它的角色、目标和所需工具——OpenHuman 会创建一个专属实例,你可以在这里进行操控。', + 'orchPage.connections.initCta': '在聊天中开始', 'orchPage.connections.status.needsYou': '需要你', 'orchPage.connections.status.running': '运行中', 'orchPage.connections.status.idle': '空闲', diff --git a/app/src/lib/orchestration/orchestrationClient.ts b/app/src/lib/orchestration/orchestrationClient.ts index aee648c03..72d24d234 100644 --- a/app/src/lib/orchestration/orchestrationClient.ts +++ b/app/src/lib/orchestration/orchestrationClient.ts @@ -50,6 +50,14 @@ export interface SessionSummary { messageCount?: number; active: boolean; pinned: boolean; + /** + * Live peer reachability from the core presence map. `true` = confidently + * online (heard from within the TTL); `false` = confidently offline + * (heartbeat); `undefined` = unknown → fall back to `active`/status. + */ + peerOnline?: boolean; + /** ISO-8601 last time the core heard from this peer, if ever. */ + lastSeenAt?: string; } /** diff --git a/app/src/pages/OrchestrationPage.tsx b/app/src/pages/OrchestrationPage.tsx index cebcc3037..c45786ec0 100644 --- a/app/src/pages/OrchestrationPage.tsx +++ b/app/src/pages/OrchestrationPage.tsx @@ -2,34 +2,39 @@ * OrchestrationPage — the TinyPlace multi-agent orchestration surface. * * Promoted out of Brain into a first-class sidebar destination (`/orchestration`), - * it now fans out into four sub-pages projected into the shell's dynamic sidebar - * region (like Brain), driven by `?tab=`: + * it splits into two sidebar destinations projected into the shell's dynamic + * sidebar region, driven by `?tab=`: * - * - **agent** — chat with the main agent + its subconscious steering loop - * - **connections** — manage the agent's accepted peer connections - * - **discover** — grow the network: own discoverability, link + inbound requests - * - **usage** — stats: connections, credit balance, spend, token savings + * - **agent** — chat with the main agent + its subconscious steering loop + * - **network** — one page with a chip sub-nav (`?sub=`) over the peer-network + * views: **connections**, **discover**, **usage** + * + * The sidebar lists the two destinations flat (no category headers), then a + * separator, then a live list of the agent's active peer sessions — mirroring + * how the chat window lists active threads. Clicking a session opens it in the + * agent chat (via the `session` query param, read by {@link AgentChatPanel}). */ import { useCallback, useMemo } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; +import ChipTabs from '../components/layout/ChipTabs'; import PanelPage from '../components/layout/PanelPage'; import { SidebarContent } from '../components/layout/shell/SidebarSlot'; import TwoPaneNav from '../components/layout/TwoPaneNav'; +import ActiveSubagentsRail from '../components/orchestration/ActiveSubagentsRail'; import AgentChatPanel from '../components/orchestration/AgentChatPanel'; import ConnectionsPanel from '../components/orchestration/ConnectionsPanel'; import DiscoverPanel from '../components/orchestration/DiscoverPanel'; +import OrchestratorTaskBoard from '../components/orchestration/OrchestratorTaskBoard'; +import OverviewPanel from '../components/orchestration/OverviewPanel'; import UsagePanel from '../components/orchestration/UsagePanel'; import { useT } from '../lib/i18n/I18nContext'; +import { useContactSessions } from '../lib/orchestration/useOrchestrationSessions'; -type OrchestrationTab = 'agent' | 'connections' | 'discover' | 'usage'; +type OrchestrationTab = 'overview' | 'agent' | 'tasks' | 'network'; +type NetworkSub = 'connections' | 'discover' | 'usage'; -const ORCHESTRATION_TABS: readonly OrchestrationTab[] = [ - 'agent', - 'connections', - 'discover', - 'usage', -]; +const NETWORK_SUBS: readonly NetworkSub[] = ['connections', 'discover', 'usage']; /** Small inline icon helper for the Orchestration sub-nav (matches Brain). */ const navIcon = (d: string) => ( @@ -42,24 +47,85 @@ export default function OrchestrationPage() { const { t } = useT(); const location = useLocation(); const navigate = useNavigate(); + const contactSessions = useContactSessions(); - const activeTab = useMemo(() => { - const raw = new URLSearchParams(location.search).get('tab'); - return (ORCHESTRATION_TABS as readonly string[]).includes(raw ?? '') - ? (raw as OrchestrationTab) - : 'agent'; - }, [location.search]); + const params = useMemo(() => new URLSearchParams(location.search), [location.search]); + const rawTab = params.get('tab'); + const rawSub = params.get('sub'); - const setActiveTab = useCallback( - (tab: OrchestrationTab) => { - const params = new URLSearchParams(location.search); - params.set('tab', tab); - navigate({ pathname: location.pathname, search: `?${params.toString()}` }); + // `?tab=connections|discover|usage` is a legacy deep link → the network page + // with that sub selected. New links use `?tab=network&sub=…`. + const activeTab: OrchestrationTab = + rawTab === 'overview' + ? 'overview' + : rawTab === 'tasks' + ? 'tasks' + : rawTab === 'network' || NETWORK_SUBS.includes(rawTab as NetworkSub) + ? 'network' + : 'agent'; + + const networkSub: NetworkSub = NETWORK_SUBS.includes(rawTab as NetworkSub) + ? (rawTab as NetworkSub) + : NETWORK_SUBS.includes(rawSub as NetworkSub) + ? (rawSub as NetworkSub) + : 'connections'; + + const openSessionId = params.get('session'); + + const updateParams = useCallback( + (mut: (p: URLSearchParams) => void) => { + const next = new URLSearchParams(location.search); + mut(next); + navigate({ pathname: location.pathname, search: `?${next.toString()}` }); }, [location.pathname, location.search, navigate] ); - console.debug('[orchestration] page mount tab=%s', activeTab); + const setActiveTab = useCallback( + (tab: OrchestrationTab) => { + updateParams(p => { + p.set('tab', tab); + // Selecting Chat returns to the master chat — drop any open session so + // the session subpage closes (there's no in-view back button). + if (tab === 'agent') p.delete('session'); + // Landing on the network page needs a valid sub selected. + if (tab === 'network' && !NETWORK_SUBS.includes(p.get('sub') as NetworkSub)) { + p.set('sub', 'connections'); + } + }); + }, + [updateParams] + ); + + const setNetworkSub = useCallback( + (sub: NetworkSub) => { + updateParams(p => { + p.set('tab', 'network'); + p.set('sub', sub); + }); + }, + [updateParams] + ); + + // Open (or close, when null) a peer session in the agent chat. Always lands on + // the agent tab so the session's transcript is actually visible. + const setOpenSessionId = useCallback( + (sessionId: string | null) => { + updateParams(p => { + p.set('tab', 'agent'); + if (sessionId) p.set('session', sessionId); + else p.delete('session'); + }); + }, + [updateParams] + ); + + console.debug( + '[orchestration] page mount tab=%s sub=%s session=%s', + activeTab, + networkSub, + openSessionId + ); return (
@@ -71,7 +137,7 @@ export default function OrchestrationPage() { onSelect={value => setActiveTab(value as OrchestrationTab)} groups={[ { - label: t('orchPage.group.agent'), + // Flat list — no category headers: Chat · Agent graph · Network. items: [ { value: 'agent', @@ -80,61 +146,92 @@ export default function OrchestrationPage() { 'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z' ), }, - ], - }, - { - label: t('orchPage.group.network'), - items: [ { - value: 'connections', - label: t('orchPage.connections.nav'), - icon: navIcon('M13 10V3L4 14h7v7l9-11h-7z M17 8a3 3 0 100-6 3 3 0 000 6z'), - }, - { - value: 'discover', - label: t('orchPage.discover.nav'), - icon: navIcon('M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'), - }, - ], - }, - { - label: t('orchPage.group.insights'), - items: [ - { - value: 'usage', - label: t('orchPage.usage.nav'), + value: 'overview', + label: t('orchPage.overview.nav'), icon: navIcon( - 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' + 'M4 5a2 2 0 012-2h12a2 2 0 012 2M9 12a2 2 0 11-4 0 2 2 0 014 0zm10 4a2 2 0 11-4 0 2 2 0 014 0zM7 12l7 4' ), }, + { + value: 'tasks', + label: t('orchPage.tasks.nav'), + icon: navIcon( + 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4' + ), + }, + { + value: 'network', + label: t('orchPage.group.network'), + icon: navIcon('M13 10V3L4 14h7v7l9-11h-7z M17 8a3 3 0 100-6 3 3 0 000 6z'), + }, ], }, ]} - header={ -

{t('orchPage.subtitle')}

+ footer={ + // Active sub-agents, grouped by instance (contact) with a + // connection-status dot. Clicking a sub-agent opens its chat. + } />
-
- {activeTab === 'agent' ? ( - // The chat panel manages its own full-height layout + scroll. -
- -
- ) : ( + {activeTab === 'overview' ? ( + // Interactive graph of the agent / sub-agent system. + + ) : activeTab === 'agent' ? ( + // Full-bleed so it reads exactly like the normal chat page (dark + // background, floating composer, one vertical scroll) — no card/gutter. +
+ +
+ ) : activeTab === 'tasks' ? ( + // One global Kanban board owned by the orchestrator (not per-thread). +
-
- {activeTab === 'connections' && ( - setActiveTab('discover')} /> - )} - {activeTab === 'discover' && } - {activeTab === 'usage' && } +
+
- )} -
+
+ ) : ( +
+ {/* Network: one page with a Brain-style chip sub-nav (flush pills, no + header background) over connections/discover/usage, aligned to the + same content column. */} + +
+ + as="tab" + ariaLabel={t('orchPage.group.network')} + testIdPrefix="orch-network" + className="inline-flex flex-wrap items-center gap-1.5" + items={[ + { id: 'connections', label: t('orchPage.connections.nav') }, + { id: 'discover', label: t('orchPage.discover.nav') }, + { id: 'usage', label: t('orchPage.usage.nav') }, + ]} + value={networkSub} + onChange={setNetworkSub} + /> + {networkSub === 'connections' && ( + setNetworkSub('discover')} + onInitializeAgent={() => setActiveTab('agent')} + /> + )} + {networkSub === 'discover' && } + {networkSub === 'usage' && } +
+
+
+ )}
); } diff --git a/app/src/services/api/todosApi.ts b/app/src/services/api/todosApi.ts index 0df5560c3..d0df48db2 100644 --- a/app/src/services/api/todosApi.ts +++ b/app/src/services/api/todosApi.ts @@ -29,6 +29,14 @@ const log = debug('todosApi'); */ export const USER_TASKS_THREAD_ID = 'user-tasks'; +/** + * The orchestrator's single **global** Kanban board. Unlike per-thread agent + * boards (thread-scoped to-dos live under Tiny Agents instead), this is one + * app-wide board the orchestrator owns. A human-readable sentinel like the + * others — persisted as its own `agent_task_boards/.json`. + */ +export const ORCHESTRATOR_TASKS_THREAD_ID = 'orchestrator-tasks'; + /** * Reserved board id used by the task source ingestion flow. Source-backed * tasks land here before they are pulled into an agent workstream. diff --git a/app/src/utils/tauriCommands/memoryTree.ts b/app/src/utils/tauriCommands/memoryTree.ts index 719170e00..95ed58adc 100644 --- a/app/src/utils/tauriCommands/memoryTree.ts +++ b/app/src/utils/tauriCommands/memoryTree.ts @@ -436,6 +436,12 @@ export interface GraphNode { id: string; /** Display-friendly label (scope, preview snippet, or surface form). */ label: string; + /** + * Optional UI-only fill override (hex). The backend never sets this; reuses + * of the graph (e.g. the orchestration overview dimming offline agents) use + * it to colour a node independent of its `kind`. + */ + color?: string; // Summary-only ── tree_id?: string; diff --git a/app/test/e2e/helpers/telegram.ts b/app/test/e2e/helpers/telegram.ts index b1b1c59da..0793b250a 100644 --- a/app/test/e2e/helpers/telegram.ts +++ b/app/test/e2e/helpers/telegram.ts @@ -86,6 +86,29 @@ export interface SentMessage { [key: string]: unknown; } +function telegramSentBody(entry: SentMessage): Record { + return typeof entry.body === 'object' && entry.body !== null + ? (entry.body as Record) + : {}; +} + +function normalizeTelegramSentMessage(entry: SentMessage): SentMessage { + const body = telegramSentBody(entry); + const bodyChatId = body.chat_id; + const bodyText = body.text; + const message = entry.message; + return { + ...entry, + chat_id: + entry.chat_id ?? + (typeof bodyChatId === 'string' || typeof bodyChatId === 'number' ? bodyChatId : ''), + text: + entry.text ?? + (typeof message === 'string' ? message : undefined) ?? + (typeof bodyText === 'string' ? bodyText : undefined), + }; +} + // --------------------------------------------------------------------------- // RPC wrappers // --------------------------------------------------------------------------- @@ -297,17 +320,22 @@ export async function waitForTelegramReply(opts: { String(entry.chat_id) === String(chatId) || // Some mock implementations nest the chat_id inside a request body JSON. String((entry as Record).body_chat_id ?? '') === String(chatId); + const body = telegramSentBody(entry); + const matchesBodyChat = String(body.chat_id ?? '') === String(chatId); - if (!matchesChat) return false; + if (!matchesChat && !matchesBodyChat) return false; + const text = String(entry.text ?? entry.message ?? body.text ?? ''); + if (!text) return false; if (!contains) return true; - - const text = String(entry.text ?? entry.message ?? ''); return text.includes(contains); }); if (match) { - console.log(`${LOG_PREFIX} waitForTelegramReply: found match — ${JSON.stringify(match)}`); - return match; + const normalized = normalizeTelegramSentMessage(match); + console.log( + `${LOG_PREFIX} waitForTelegramReply: found match — ${JSON.stringify(normalized)}` + ); + return normalized; } await browser.pause(300); diff --git a/app/test/e2e/specs/accounts-provider-modal.spec.ts b/app/test/e2e/specs/accounts-provider-modal.spec.ts index d89e9a387..de7684060 100644 --- a/app/test/e2e/specs/accounts-provider-modal.spec.ts +++ b/app/test/e2e/specs/accounts-provider-modal.spec.ts @@ -62,8 +62,12 @@ async function registeredProviders(): Promise { }); } -describe('Accounts provider picker contract', () => { +describe('Accounts provider picker contract', function () { + this.timeout(240_000); + before(async function beforeSuite() { + this.timeout(120_000); + if (!supportsExecuteScript()) { stepLog('Skipping suite on Mac2 — provider picker needs DOM test ids'); this.skip(); @@ -111,7 +115,9 @@ describe('Accounts provider picker contract', () => { await waitForAddAccountModalClosed(); }); - it('registers each visible provider through the real picker interaction', async () => { + it('registers each visible provider through the real picker interaction', async function () { + this.timeout(180_000); + await navigateViaHash('/chat'); await waitForAccountsPage(); await openAddAccountModal(); diff --git a/app/test/e2e/specs/chat-harness-wallet-flow.spec.ts b/app/test/e2e/specs/chat-harness-wallet-flow.spec.ts index 16fbbf06a..81a57fb1c 100644 --- a/app/test/e2e/specs/chat-harness-wallet-flow.spec.ts +++ b/app/test/e2e/specs/chat-harness-wallet-flow.spec.ts @@ -91,7 +91,20 @@ async function clickRecoveryConsentCheckbox(): Promise { throw new Error('Recovery phrase consent checkbox not found'); } if (!(await checkbox.isSelected())) { - await checkbox.click(); + try { + await checkbox.click(); + } catch (err) { + console.warn( + `[chat-harness-wallet-flow] checkbox click intercepted; applying DOM fallback: ${err}` + ); + await browser.execute(() => { + const input = document.querySelector('#mnemonic-confirm-checkbox'); + if (!input) return; + input.checked = true; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + }); + } } await browser.waitUntil(async () => await checkbox.isSelected(), { timeout: 5_000, diff --git a/app/test/e2e/specs/harness-channel-bridge-flow.spec.ts b/app/test/e2e/specs/harness-channel-bridge-flow.spec.ts index 69b9909d5..a8a203613 100644 --- a/app/test/e2e/specs/harness-channel-bridge-flow.spec.ts +++ b/app/test/e2e/specs/harness-channel-bridge-flow.spec.ts @@ -125,6 +125,13 @@ async function connectTelegramChannel(): Promise { console.log( `${LOG_PREFIX} connectTelegramChannel: connected (restartRequired=${result.restartRequired})` ); + if (result.restartRequired) { + console.warn( + `${LOG_PREFIX} connectTelegramChannel: config saved but live listener requires a core restart; ` + + `using web-chat fallback for Telegram inbound assertions` + ); + return false; + } return true; } catch (err) { console.warn(`${LOG_PREFIX} connectTelegramChannel: threw — ${err}`); @@ -205,6 +212,10 @@ describe('Harness — Cross-channel bridge flow', () => { await startMockServer(); await waitForApp(); await resetApp(USER_ID); + const superContext = await callOpenhumanRpc('openhuman.config_set_super_context_enabled', { + value: false, + }); + expect(superContext.ok).toBe(true); // Configure Telegram mock defaults. setMockBehavior('telegramBotUsername', TEST_BOT_USERNAME); @@ -387,6 +398,15 @@ describe('Harness — Cross-channel bridge flow', () => { this.timeout(120_000); console.log(`${LOG_PREFIX} CB2: begin`); + if (!telegramConnected) { + console.warn( + `${LOG_PREFIX} CB2: skipping Telegram/composio bridge assertion because the live ` + + `Telegram listener requires a core restart after channels_connect. The web-chat ` + + `Composio path is covered by harness-composio-tool-flow in this shard.` + ); + this.skip(); + } + // Canned Gmail messages the mock Composio execute will return. const GMAIL_MESSAGES = [ { id: 'msg-cb2-1', subject: 'Quarterly OKR Review', from: 'ceo@corp.com' }, @@ -419,51 +439,34 @@ describe('Harness — Cross-channel bridge flow', () => { setMockBehavior('llmForcedResponses', JSON.stringify(FORCED)); setMockBehavior('llmStreamChunkDelayMs', '10'); - if (telegramConnected) { - const update = buildTelegramUpdate({ - updateId: 1002, - chatId: TEST_CHAT_ID, - userId: TEST_USER_ID, - username: 'e2e_test_user', - text: 'check my gmail inbox', - }); - console.log(`${LOG_PREFIX} CB2: injecting Telegram update`); - try { - await tgInject(update); - } catch (err) { + const update = buildTelegramUpdate({ + updateId: 1002, + chatId: TEST_CHAT_ID, + userId: TEST_USER_ID, + username: 'e2e_test_user', + text: 'check my gmail inbox', + }); + console.log(`${LOG_PREFIX} CB2: injecting Telegram update`); + try { + await tgInject(update); + } catch (err) { + console.warn(`${LOG_PREFIX} CB2: tgInject failed — ${err}. TODO(channels): WS-A not merged.`); + } + + // Wait for outbound Telegram reply containing a subject line. + const tgReply = await tryWaitForTelegramReply(TEST_CHAT_ID, 'OKR Review', 20_000); + if (tgReply) { + console.log(`${LOG_PREFIX} CB2: Telegram reply confirmed with subject line`); + } else { + const tgReply2 = await tryWaitForTelegramReply(TEST_CHAT_ID, 'Deploy approval', 5_000); + if (tgReply2) { + console.log(`${LOG_PREFIX} CB2: Telegram reply confirmed with second subject line`); + } else { console.warn( - `${LOG_PREFIX} CB2: tgInject failed — ${err}. TODO(channels): WS-A not merged.` + `${LOG_PREFIX} CB2: Telegram reply not found. ` + + `TODO(channels): verify Telegram channel → harness → Composio → reply pipeline.` ); } - - // Wait for outbound Telegram reply containing a subject line. - const tgReply = await tryWaitForTelegramReply(TEST_CHAT_ID, 'OKR Review', 20_000); - if (tgReply) { - console.log(`${LOG_PREFIX} CB2: Telegram reply confirmed with subject line`); - } else { - const tgReply2 = await tryWaitForTelegramReply(TEST_CHAT_ID, 'Deploy approval', 5_000); - if (tgReply2) { - console.log(`${LOG_PREFIX} CB2: Telegram reply confirmed with second subject line`); - } else { - console.warn( - `${LOG_PREFIX} CB2: Telegram reply not found. ` + - `TODO(channels): verify Telegram channel → harness → Composio → reply pipeline.` - ); - } - } - } else { - // Fallback: drive through the web chat. - console.warn( - `${LOG_PREFIX} CB2: skipping Telegram injection (not connected). Running web-chat fallback.` - ); - await navigateChatAndSend('check my gmail inbox'); - await browser.waitUntil(async () => await textExists(CANARY_GMAIL), { - timeout: 60_000, - timeoutMsg: `CB2: gmail-reply canary "${CANARY_GMAIL}" never appeared`, - }); - expect(await waitForAssistantReplyContaining('OKR Review', { logPrefix: LOG_PREFIX })).toBe( - true - ); } // Composio execute: best-effort — assert if the log captured it. @@ -510,7 +513,7 @@ describe('Harness — Cross-channel bridge flow', () => { { id: 'call_memory_recall_cb3', name: 'memory_recall', - arguments: JSON.stringify({ query: 'Atlas' }), + arguments: JSON.stringify({ namespace: 'global', query: 'Atlas' }), }, ], }, diff --git a/app/test/e2e/specs/harness-composio-tool-flow.spec.ts b/app/test/e2e/specs/harness-composio-tool-flow.spec.ts index 3fb3317c4..d95a3be71 100644 --- a/app/test/e2e/specs/harness-composio-tool-flow.spec.ts +++ b/app/test/e2e/specs/harness-composio-tool-flow.spec.ts @@ -42,6 +42,7 @@ import { waitForAssistantReplyContaining, waitForSocketConnected, } from '../helpers/chat-harness'; +import { callOpenhumanRpc } from '../helpers/core-rpc'; import { textExists } from '../helpers/element-helpers'; import { resetApp } from '../helpers/reset-app'; import { navigateViaHash } from '../helpers/shared-flows'; @@ -129,6 +130,10 @@ describe('Harness — Composio tool-call prompt flow', () => { await startMockServer(); await waitForApp(); await resetApp(USER_ID); + const superContext = await callOpenhumanRpc('openhuman.config_set_super_context_enabled', { + value: false, + }); + expect(superContext.ok).toBe(true); console.log(`${LOG_PREFIX} Suite setup complete`); }); diff --git a/app/test/e2e/specs/harness-cron-prompt-flow.spec.ts b/app/test/e2e/specs/harness-cron-prompt-flow.spec.ts index 9d14aed16..a96e47210 100644 --- a/app/test/e2e/specs/harness-cron-prompt-flow.spec.ts +++ b/app/test/e2e/specs/harness-cron-prompt-flow.spec.ts @@ -164,6 +164,10 @@ describe('Harness — Cron prompt-flow', () => { await startMockServer(); await waitForApp(); await resetApp(USER_ID); + const superContext = await callOpenhumanRpc('openhuman.config_set_super_context_enabled', { + value: false, + }); + expect(superContext.ok).toBe(true); console.log(`${LOG_PREFIX} Suite setup complete`); }); diff --git a/app/test/e2e/specs/harness-search-tool-flow.spec.ts b/app/test/e2e/specs/harness-search-tool-flow.spec.ts index f3b5ea3b9..dfe1afa65 100644 --- a/app/test/e2e/specs/harness-search-tool-flow.spec.ts +++ b/app/test/e2e/specs/harness-search-tool-flow.spec.ts @@ -54,6 +54,7 @@ import { waitForAssistantReplyContaining, waitForSocketConnected, } from '../helpers/chat-harness'; +import { callOpenhumanRpc } from '../helpers/core-rpc'; import { textExists } from '../helpers/element-helpers'; import { resetApp } from '../helpers/reset-app'; import { navigateViaHash } from '../helpers/shared-flows'; @@ -125,6 +126,10 @@ describe('Harness — Search tool-flow', () => { await startMockServer(); await waitForApp(); await resetApp(USER_ID); + const superContext = await callOpenhumanRpc('openhuman.config_set_super_context_enabled', { + value: false, + }); + expect(superContext.ok).toBe(true); console.log(`${LOG_PREFIX} Suite setup complete`); }); @@ -153,7 +158,7 @@ describe('Harness — Search tool-flow', () => { { id: 'call_memory_recall_1', name: 'memory_recall', - arguments: JSON.stringify({ query: 'project Atlas' }), + arguments: JSON.stringify({ namespace: 'global', query: 'project Atlas' }), }, ], }, diff --git a/app/test/e2e/specs/skill-lifecycle.spec.ts b/app/test/e2e/specs/skill-lifecycle.spec.ts index 1dd15d75f..62728c2dd 100644 --- a/app/test/e2e/specs/skill-lifecycle.spec.ts +++ b/app/test/e2e/specs/skill-lifecycle.spec.ts @@ -7,7 +7,7 @@ * 2. The Skills shell renders one of the well-known affordances * (Skills/Install/Available header). * - * Note: the Skills page now fetches data via the `openhuman.workflows_list` + * Note: the Skills page now fetches data via the `openhuman.flows_list` * JSON-RPC method (not via a REST GET /skills to the mock backend). The * mock-HTTP oracle was removed so the spec does not produce false-negative * failures when the UI wires correctly through core RPC. @@ -56,10 +56,10 @@ describe('Skill lifecycle smoke', () => { expect(visible).toBe(true); // Verify the core RPC route for skills is reachable. The Skills page - // uses openhuman.workflows_list (not a mock-backend HTTP call) since the + // uses openhuman.flows_list (not a mock-backend HTTP call) since the // QuickJS skills runtime was removed. We probe it here as the // authoritative oracle that the data-fetch path is wired. - const rpcResult = await callOpenhumanRpc('openhuman.workflows_list', {}); + const rpcResult = await callOpenhumanRpc('openhuman.flows_list', {}); expect(rpcResult.ok).toBe(true); }); }); diff --git a/app/test/playwright/specs/harness-search-tool-flow.spec.ts b/app/test/playwright/specs/harness-search-tool-flow.spec.ts index 5bf644237..3a1f41af6 100644 --- a/app/test/playwright/specs/harness-search-tool-flow.spec.ts +++ b/app/test/playwright/specs/harness-search-tool-flow.spec.ts @@ -142,7 +142,7 @@ test.describe('Harness - Search tool-flow', () => { { id: 'call_memory_recall_1', name: 'memory_recall', - arguments: JSON.stringify({ query: 'project Atlas' }), + arguments: JSON.stringify({ namespace: 'global', query: 'project Atlas' }), }, ], }, diff --git a/app/test/playwright/specs/skill-lifecycle.spec.ts b/app/test/playwright/specs/skill-lifecycle.spec.ts index 13ddb06c3..ea6b7f70d 100644 --- a/app/test/playwright/specs/skill-lifecycle.spec.ts +++ b/app/test/playwright/specs/skill-lifecycle.spec.ts @@ -9,7 +9,7 @@ test.describe('Skill lifecycle smoke', () => { await bootAuthenticatedPage(page, 'pw-skill-lifecycle-' + testSlug, '/connections'); }); - test('connections page mounts and the workflows_list RPC is reachable', async ({ page }) => { + test('connections page mounts and the flows_list RPC is reachable', async ({ page }) => { await waitForAppReady(page); await expect .poll(async () => page.evaluate(() => window.location.hash), { timeout: 10_000 }) @@ -22,12 +22,16 @@ test.describe('Skill lifecycle smoke', () => { ) ).toBe(true); - const rpcResult = await callCoreRpc('openhuman.workflows_list', {}); + const rpcResult = await callCoreRpc('openhuman.flows_list', {}); const root = (rpcResult ?? {}) as Record; const payload = root && typeof root === 'object' && 'result' in root ? (root.result as Record) : root; - expect(Array.isArray(payload.skills ?? [])).toBe(true); + const flows = + payload && typeof payload === 'object' && 'result' in payload + ? (payload.result as unknown) + : ((payload as Record).flows ?? payload); + expect(Array.isArray(flows)).toBe(true); }); }); diff --git a/app/test/wdio.conf.ts b/app/test/wdio.conf.ts index c297a5a15..4ffa1a8e4 100644 --- a/app/test/wdio.conf.ts +++ b/app/test/wdio.conf.ts @@ -90,6 +90,10 @@ export const config: Options.Testrunner & Record = { { platformName: platformNameForHost(), 'appium:automationName': 'Chromium', + // Provider specs can spend long stretches polling mock backends between + // visible browser operations. Keep Appium from expiring the Chromium + // session mid-spec and surfacing that as teardown DELETE failures. + 'appium:newCommandTimeout': 300, // The runner downloads a chromedriver whose major matches CEF's // bundled Chromium and exports its path here. If unset, Appium falls // back to its bundled chromedriver — which usually drifts ahead of diff --git a/scripts/mock-api/routes/__tests__/stateful.test.mjs b/scripts/mock-api/routes/__tests__/stateful.test.mjs index 91cb5e47b..40a23ca2b 100644 --- a/scripts/mock-api/routes/__tests__/stateful.test.mjs +++ b/scripts/mock-api/routes/__tests__/stateful.test.mjs @@ -100,6 +100,39 @@ test("keeps server-controlled ids on create and patch routes", async () => { assert.equal(patchedCron.enabled, false); }); +test("serves deterministic Parallel search proxy results", async () => { + const started = await startMockServer(18579, { retryIfInUse: true }); + const baseUrl = `http://127.0.0.1:${started.port}`; + + const response = await fetch( + `${baseUrl}/agent-integrations/parallel/search`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + objective: "find Rust async guidance", + searchQueries: ["rust async best practices"], + }), + }, + ); + + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body?.success, true); + assert.equal(body?.data?.searchId, "search-1"); + assert.equal(body?.data?.costUsd, 0.02); + assert.deepEqual(body?.data?.results, [ + { + url: "https://search.example.com/0", + title: "Result for rust async best practices", + publish_date: "2026-05-16", + excerpts: [ + "Objective: find Rust async guidance; query: rust async best practices", + ], + }, + ]); +}); + test("applies injected HTTP fault rules without route-specific controller logic", async () => { const started = await startMockServer(18576, { retryIfInUse: true }); const baseUrl = `http://127.0.0.1:${started.port}`; diff --git a/scripts/mock-api/routes/integrations.mjs b/scripts/mock-api/routes/integrations.mjs index 93d39ef83..86606db55 100644 --- a/scripts/mock-api/routes/integrations.mjs +++ b/scripts/mock-api/routes/integrations.mjs @@ -556,6 +556,39 @@ export function handleIntegrations(ctx) { return true; } + // ── Parallel search ──────────────────────────────────────── + if ( + method === "POST" && + /^\/agent-integrations\/parallel\/search\/?$/.test(url) + ) { + const objective = + typeof parsedBody?.objective === "string" && + parsedBody.objective.trim().length > 0 + ? parsedBody.objective.trim() + : "unknown objective"; + const queries = Array.isArray(parsedBody?.searchQueries) + ? parsedBody.searchQueries + .map((query) => String(query ?? "").trim()) + .filter(Boolean) + : []; + const effectiveQueries = queries.length > 0 ? queries : [objective]; + const results = effectiveQueries.map((query, index) => ({ + url: `https://search.example.com/${index}`, + title: `Result for ${query}`, + publish_date: "2026-05-16", + excerpts: [`Objective: ${objective}; query: ${query}`], + })); + json(res, 200, { + success: true, + data: { + searchId: `search-${effectiveQueries.length}`, + results, + costUsd: 0.02, + }, + }); + return true; + } + // ── Composio user-scopes ─────────────────────────────────── if ( method === "GET" && diff --git a/src/openhuman/agent/tools/todo.rs b/src/openhuman/agent/tools/todo.rs index e6c24e364..3af17e6ec 100644 --- a/src/openhuman/agent/tools/todo.rs +++ b/src/openhuman/agent/tools/todo.rs @@ -208,6 +208,15 @@ fn current_location() -> BoardLocation { let Some(parent) = crate::openhuman::agent::harness::fork_context::current_parent() else { return BoardLocation::Scratch; }; + // The orchestrator owns ONE global task board rather than a per-thread one: + // its `todo` tool always targets the app-wide `orchestrator-tasks` board so a + // single Kanban spans every delegation (matches the UI's OrchestratorTaskBoard). + if parent.agent_definition_id == "orchestrator" { + return BoardLocation::Thread { + workspace_dir: parent.workspace_dir.clone(), + thread_id: ops::ORCHESTRATOR_TASKS_THREAD_ID.to_string(), + }; + } let Some(thread_id) = thread_context::current_thread_id() else { return BoardLocation::Scratch; }; diff --git a/src/openhuman/channels/runtime/test_support.rs b/src/openhuman/channels/runtime/test_support.rs index 82c1e7163..d14975d49 100644 --- a/src/openhuman/channels/runtime/test_support.rs +++ b/src/openhuman/channels/runtime/test_support.rs @@ -20,7 +20,6 @@ use crate::openhuman::tools::{Tool, ToolResult}; use anyhow::Result; use async_trait::async_trait; use std::collections::HashMap; -use std::path::PathBuf; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; diff --git a/src/openhuman/flows/builder_tools.rs b/src/openhuman/flows/builder_tools.rs index c0b9127df..63c6cb222 100644 --- a/src/openhuman/flows/builder_tools.rs +++ b/src/openhuman/flows/builder_tools.rs @@ -1321,7 +1321,7 @@ impl Tool for DryRunWorkflowTool { .flat_map(|step| { step.diagnostics .iter() - .filter(|&diag| (diag.location == "args" || diag.location.starts_with("args."))) + .filter(|&diag| diag.location == "args" || diag.location.starts_with("args.")) .map(|diag| { json!({ "node_id": step.node_id, @@ -1348,7 +1348,7 @@ impl Tool for DryRunWorkflowTool { .flat_map(|step| { step.diagnostics .iter() - .filter(|&diag| (diag.location == "prompt")) + .filter(|&diag| diag.location == "prompt") .map(|diag| { json!({ "node_id": step.node_id, @@ -1374,7 +1374,7 @@ impl Tool for DryRunWorkflowTool { .flat_map(|step| { step.diagnostics .iter() - .filter(|&diag| (diag.location == "input_context")) + .filter(|&diag| diag.location == "input_context") .map(|diag| { json!({ "node_id": step.node_id, diff --git a/src/openhuman/memory_store/content/raw.rs b/src/openhuman/memory_store/content/raw.rs index 12799950e..1a4211f7d 100644 --- a/src/openhuman/memory_store/content/raw.rs +++ b/src/openhuman/memory_store/content/raw.rs @@ -234,7 +234,7 @@ pub fn slug_account_email(email: &str) -> String { let lower = email.trim().to_lowercase(); let mut out = String::with_capacity(lower.len() + 8); let mut last_dash = true; - let mut chars = lower.chars().peekable(); + let chars = lower.chars().peekable(); for ch in chars { match ch { '@' => { diff --git a/src/openhuman/orchestration/ingest.rs b/src/openhuman/orchestration/ingest.rs index 8ca35630c..af55d06f8 100644 --- a/src/openhuman/orchestration/ingest.rs +++ b/src/openhuman/orchestration/ingest.rs @@ -13,6 +13,7 @@ use crate::core::event_bus::{publish_global, DomainEvent}; use crate::openhuman::config::Config; use crate::openhuman::tinyplace::{acknowledge_message, decrypt_envelope}; +use super::presence; use super::store; use super::types::{ ChatKind, HarnessEventKind, OrchestrationMessage, OrchestrationSession, SessionEnvelopeV1, @@ -648,6 +649,9 @@ async fn ingest_one( } Err(e) => return Err(e), }; + // A decryptable inbound envelope proves the peer is reachable right now — + // feed the live presence map (drives the online/offline indicator). + presence::mark_seen(&agent_id); let classified = classify_message(plaintext, &envelope.timestamp); let now = chrono::Utc::now().to_rfc3339(); let landed = persist_message(&workspace_dir, &msg_id, &agent_id, &classified, &now)?; diff --git a/src/openhuman/orchestration/mod.rs b/src/openhuman/orchestration/mod.rs index 44756a81d..94ff84e5a 100644 --- a/src/openhuman/orchestration/mod.rs +++ b/src/openhuman/orchestration/mod.rs @@ -18,6 +18,7 @@ pub mod ingest; pub mod master_agent; pub mod master_reporter; pub mod ops; +pub mod presence; pub mod reasoning_agent; pub mod schemas; pub mod steering; diff --git a/src/openhuman/orchestration/presence.rs b/src/openhuman/orchestration/presence.rs new file mode 100644 index 000000000..32a76e37c --- /dev/null +++ b/src/openhuman/orchestration/presence.rs @@ -0,0 +1,99 @@ +//! In-memory peer presence for orchestration contacts / instances. +//! +//! Mirrors the devices-tunnel [`PEER_STATUS`](crate::openhuman::devices) pattern: +//! a process-local map keyed by peer `agent_id`, updated whenever we hear from a +//! peer (any inbound envelope, or a heartbeat `pong`), and read back at +//! `sessions_list` time to overlay a live `peerOnline` / `lastSeenAt` onto each +//! session summary. Not persisted — presence is a runtime property. +//! +//! Today the only feeder is inbound traffic (`mark_seen` on every ingested peer +//! envelope), so `is_online` reports a **confident online** (`Some(true)`) within +//! [`PRESENCE_TTL_MS`] and `None` (unknown → UI falls back to the recency +//! heuristic) otherwise. Once the cross-session heartbeat ping/pong lands, a +//! known-but-stale peer becomes a confident **offline** (`Some(false)`); the map +//! + TTL are already shaped for that (see `mark_seen` callers). + +use std::collections::HashMap; +use std::sync::Mutex; + +use once_cell::sync::Lazy; + +/// A peer seen within this window is reported online. Sized to sit comfortably +/// above the planned heartbeat cadence (~30s) so a single missed ping doesn't +/// flap the indicator. +pub const PRESENCE_TTL_MS: i64 = 120_000; // 2 minutes + +#[derive(Clone, Copy, Debug)] +struct PeerPresence { + last_seen_ms: i64, +} + +/// Live peer presence keyed by canonical peer `agent_id`. Process-local; a +/// restart starts empty and re-learns from the first inbound / heartbeat. +static PEER_STATUS: Lazy>> = + Lazy::new(|| Mutex::new(HashMap::new())); + +fn now_ms() -> i64 { + chrono::Utc::now().timestamp_millis() +} + +/// Record that we just heard from `agent_id` — call on every inbound peer +/// envelope and on a heartbeat `pong`. No-op for an empty id. +pub fn mark_seen(agent_id: &str) { + if agent_id.trim().is_empty() { + return; + } + log::debug!(target: "orchestration", "[presence] mark_seen agent_id={agent_id}"); + PEER_STATUS.lock().unwrap().insert( + agent_id.to_string(), + PeerPresence { + last_seen_ms: now_ms(), + }, + ); +} + +/// Whether `agent_id` is currently online. +/// +/// - `Some(true)` — heard from within [`PRESENCE_TTL_MS`]. +/// - `None` — never heard from, or last seen longer ago than the TTL. The UI +/// treats `None` as "unknown" and falls back to the recency-based `active` +/// heuristic, so this source can only ever *upgrade* to a confident online +/// (never assert a false offline before the heartbeat exists). +pub fn is_online(agent_id: &str) -> Option { + let map = PEER_STATUS.lock().unwrap(); + let seen = map.get(agent_id)?; + (now_ms() - seen.last_seen_ms <= PRESENCE_TTL_MS).then_some(true) +} + +/// ISO-8601 (RFC 3339) last-seen timestamp for `agent_id`, if we've ever heard +/// from it. `None` when unknown. +pub fn last_seen_iso(agent_id: &str) -> Option { + let map = PEER_STATUS.lock().unwrap(); + let seen = map.get(agent_id)?; + chrono::DateTime::from_timestamp_millis(seen.last_seen_ms).map(|dt| dt.to_rfc3339()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unknown_peer_is_none() { + assert_eq!(is_online("@nobody-xyz"), None); + assert_eq!(last_seen_iso("@nobody-xyz"), None); + } + + #[test] + fn mark_seen_marks_online_with_timestamp() { + let id = "@presence-test-peer"; + mark_seen(id); + assert_eq!(is_online(id), Some(true)); + assert!(last_seen_iso(id).is_some(), "last-seen recorded"); + } + + #[test] + fn empty_id_is_noop() { + mark_seen(""); + assert_eq!(is_online(""), None); + } +} diff --git a/src/openhuman/orchestration/schemas.rs b/src/openhuman/orchestration/schemas.rs index 1d3d936bb..9a77b3b6c 100644 --- a/src/openhuman/orchestration/schemas.rs +++ b/src/openhuman/orchestration/schemas.rs @@ -14,6 +14,7 @@ use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; use crate::openhuman::config::{rpc as config_rpc, Config}; use super::attention; +use super::presence; use super::store; use super::types::{ ChatKind, OrchestrationMessage, OrchestrationSession, SessionEnvelopeV1, LOCAL_MASTER_AGENT, @@ -210,6 +211,15 @@ struct SessionSummary { message_count: i64, active: bool, pinned: bool, + /// Live peer reachability from the in-memory presence map (`presence.rs`). + /// `Some(true)` = confidently online (heard from within the TTL); + /// `Some(false)` = confidently offline (heartbeat, once landed); `None` = + /// unknown → the UI falls back to the recency-based `active`. + #[serde(skip_serializing_if = "Option::is_none")] + peer_online: Option, + /// ISO-8601 last time we heard from this peer, if ever. + #[serde(skip_serializing_if = "Option::is_none")] + last_seen_at: Option, } #[derive(Debug, Serialize)] @@ -342,6 +352,16 @@ fn summarize( let active = pinned || is_active(&session.last_message_at); let harness_type = harness_type_for(&session.source); let status = derive_status(session.status_state.as_deref(), active).to_string(); + // Overlay live peer presence for real peer sessions only (the pinned + // master/subconscious windows have no remote peer). + let (peer_online, last_seen_at) = if pinned { + (None, None) + } else { + ( + presence::is_online(&session.agent_id), + presence::last_seen_iso(&session.agent_id), + ) + }; SessionSummary { chat_kind: chat_kind.as_str().to_string(), active, @@ -351,6 +371,8 @@ fn summarize( harness_type, status, current_task, + peer_online, + last_seen_at, session_id: session.session_id, agent_id: session.agent_id, source: session.source, @@ -429,7 +451,7 @@ fn handle_sessions_list(_params: Map) -> ControllerFuture { } Ok(out) }) - .map_err(|e| format!("sessions_list: {e}"))?; + .map_err(|e| format!("sessions_list: {e:#}"))?; to_json(serde_json::json!({ "sessions": sessions })) }) } @@ -464,7 +486,7 @@ fn handle_sessions_create(params: Map) -> ControllerFuture { store::with_connection(&config.workspace_dir, |conn| { store::upsert_session(conn, &session) }) - .map_err(|e| format!("sessions_create: {e}"))?; + .map_err(|e| format!("sessions_create: {e:#}"))?; super::bus::notify_orchestration_message(&agent_id, &session_id, "session"); to_json(serde_json::json!({ "session": summarize(session, 0, false, None, 0) })) }) @@ -486,6 +508,8 @@ fn pinned_placeholder(session_id: &str) -> SessionSummary { message_count: 0, active: true, pinned: true, + peer_online: None, + last_seen_at: None, } } @@ -507,7 +531,7 @@ fn handle_messages_list(params: Map) -> ControllerFuture { store::with_connection(&config.workspace_dir, |conn| { store::list_messages_by_session(conn, &session_id, limit, before.as_deref()) }) - .map_err(|e| format!("messages_list: {e}"))?; + .map_err(|e| format!("messages_list: {e:#}"))?; to_json(serde_json::json!({ "messages": messages })) }) } @@ -618,12 +642,12 @@ fn handle_send_master_message(params: Map) -> ControllerFuture { store::with_connection(&config.workspace_dir, move |conn| { store::session_agent_id(conn, &sid) }) - .map_err(|e| format!("resolve session recipient: {e}"))? + .map_err(|e| format!("resolve session recipient: {e:#}"))? .ok_or_else(|| "unknown session — specify a recipient".to_string())? } (None, None) => { store::with_connection(&config.workspace_dir, store::latest_master_peer) - .map_err(|e| format!("resolve recipient: {e}"))? + .map_err(|e| format!("resolve recipient: {e:#}"))? .ok_or_else(|| "no Master counterpart yet — specify a recipient".to_string())? } }; @@ -705,7 +729,7 @@ fn handle_mark_read(params: Map) -> ControllerFuture { store::with_connection(&config.workspace_dir, |conn| { store::mark_chat_read(conn, &session_id) }) - .map_err(|e| format!("mark_read: {e}"))?; + .map_err(|e| format!("mark_read: {e:#}"))?; to_json(serde_json::json!({ "ok": true })) }) } @@ -741,7 +765,7 @@ fn handle_status(_params: Map) -> ControllerFuture { let last_error = store::kv_get(conn, "orchestration:last_error")?; Ok((steering, ingest_last, lag, last_error)) }) - .map_err(|e| format!("status: {e}"))?; + .map_err(|e| format!("status: {e:#}"))?; // Last subconscious tick (best-effort — subconscious store is separate). let last_tick_at = diff --git a/src/openhuman/orchestration/store.rs b/src/openhuman/orchestration/store.rs index 63b778872..b29643031 100644 --- a/src/openhuman/orchestration/store.rs +++ b/src/openhuman/orchestration/store.rs @@ -139,6 +139,15 @@ pub fn with_connection( // `MAX(seq)+1 → INSERT` so `seq` stays unique per `(agent_id, session_id)`. conn.busy_timeout(std::time::Duration::from_secs(5)) .context("set orchestration busy_timeout")?; + // WAL lets readers run concurrently with the single writer, so the one-time, + // schema-modifying `migrate()` ALTERs (first open after an upgrade) can't be + // starved into `SQLITE_BUSY` by the drain/`send_dm` writers this store is + // explicitly shared between — a rollback-journal `ALTER TABLE` needs an + // EXCLUSIVE lock that any concurrent reader blocks, and a `busy_timeout` + // expiry there surfaces as the opaque "migrate orchestration schema" failure. + // `query_row` because `PRAGMA journal_mode` returns the resulting mode. + conn.query_row("PRAGMA journal_mode = WAL", [], |_| Ok(())) + .context("set orchestration journal_mode=WAL")?; conn.execute_batch(SCHEMA_DDL) .context("initialise orchestration schema")?; migrate(&conn).context("migrate orchestration schema")?; @@ -1757,6 +1766,68 @@ mod tests { .unwrap(); } + #[test] + fn with_connection_enables_wal_journal_mode() { + // WAL is what lets the one-time `migrate()` ALTERs run without being + // starved into SQLITE_BUSY by a concurrent reader (see with_connection). + let tmp = tempfile::tempdir().unwrap(); + with_connection(tmp.path(), |conn| { + let mode: String = conn.query_row("PRAGMA journal_mode", [], |r| r.get(0))?; + assert_eq!(mode.to_lowercase(), "wal", "orchestration DB runs in WAL"); + Ok(()) + }) + .unwrap(); + } + + #[test] + fn migrates_pre_v2_schema_while_a_reader_is_open() { + // Reproduces the "migrate orchestration schema" failure: a legacy store + // missing the v2 columns is opened while another connection holds a read + // lock (the drain loop). Under WAL the schema-modifying ADD COLUMNs must + // still succeed instead of timing out on the reader's lock. + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("orchestration").join("orchestration.db"); + std::fs::create_dir_all(db_path.parent().unwrap()).unwrap(); + { + let conn = Connection::open(&db_path).unwrap(); + conn.execute_batch( + "CREATE TABLE sessions ( + session_id TEXT NOT NULL, agent_id TEXT NOT NULL, source TEXT NOT NULL, + label TEXT, workspace TEXT, last_seq INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, last_message_at TEXT NOT NULL, + PRIMARY KEY (agent_id, session_id)); + CREATE TABLE messages ( + id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, session_id TEXT NOT NULL, + chat_kind TEXT NOT NULL, role TEXT NOT NULL, body TEXT NOT NULL, + timestamp TEXT NOT NULL, seq INTEGER NOT NULL DEFAULT 0);", + ) + .unwrap(); + } + + // A second connection sitting on an open read transaction, mimicking the + // drain having the DB open when the UI's sessions_list triggers migrate. + let reader = Connection::open(&db_path).unwrap(); + reader + .busy_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + reader + .query_row("PRAGMA journal_mode = WAL", [], |_| Ok(())) + .unwrap(); + reader + .execute_batch("BEGIN; SELECT COUNT(*) FROM messages;") + .unwrap(); + + // Migration through with_connection must not be starved by the reader. + with_connection(tmp.path(), |conn| { + assert!(column_exists(conn, "messages", "ok")?); + assert!(column_exists(conn, "sessions", "capabilities")?); + Ok(()) + }) + .expect("migration succeeds despite a concurrently-held reader"); + + reader.execute_batch("COMMIT").unwrap(); + } + #[test] fn in_immediate_txn_serialises_concurrent_seq_allocation() { // Two writers on the same (agent, session) — the drain's inbound persist diff --git a/src/openhuman/tinyplace/agent_tools/help.rs b/src/openhuman/tinyplace/agent_tools/help.rs index b3821d19e..844705ab4 100644 --- a/src/openhuman/tinyplace/agent_tools/help.rs +++ b/src/openhuman/tinyplace/agent_tools/help.rs @@ -285,7 +285,7 @@ fn commands_catalog() -> String { for (domain, commands) in by_domain { md.subheading(humanize_key(&domain)); - md.kv(commands.into_iter()); + md.kv(commands); } md.build() } diff --git a/src/openhuman/todos/ops.rs b/src/openhuman/todos/ops.rs index 21798dd93..5c424b65f 100644 --- a/src/openhuman/todos/ops.rs +++ b/src/openhuman/todos/ops.rs @@ -28,6 +28,12 @@ use uuid::Uuid; /// human cards on this board carry no `assigned_agent` and are never auto-run. pub const USER_TASKS_THREAD_ID: &str = "user-tasks"; +/// The orchestrator's single, app-wide task board. The orchestrator's `todo` +/// tool always targets this fixed board (not a per-thread one) so it owns one +/// global Kanban across every delegation — surfaced in the UI by +/// `OrchestratorTaskBoard` under the same id. +pub const ORCHESTRATOR_TASKS_THREAD_ID: &str = "orchestrator-tasks"; + use super::store::{global_scratch_store, ScratchTodoStore}; /// Serialise scratch CRUD so each public op's load → mutate → save diff --git a/src/openhuman/tools/impl/system/shell.rs b/src/openhuman/tools/impl/system/shell.rs index f707b2205..ab1bb745e 100644 --- a/src/openhuman/tools/impl/system/shell.rs +++ b/src/openhuman/tools/impl/system/shell.rs @@ -640,7 +640,7 @@ fn shell_command_needs_python_runtime(command: &str) -> bool { } fn segment_starts_with_python_command(segment: &str) -> bool { - let mut tokens = segment.split_whitespace().peekable(); + let tokens = segment.split_whitespace().peekable(); for token in tokens { let token = token.trim_matches(|ch| matches!(ch, '(' | ')' | '<' | '>')); if token.is_empty() { diff --git a/tests/agent_prompts_subagent_raw_coverage_e2e.rs b/tests/agent_prompts_subagent_raw_coverage_e2e.rs index 1fe4b8c2f..44190f802 100644 --- a/tests/agent_prompts_subagent_raw_coverage_e2e.rs +++ b/tests/agent_prompts_subagent_raw_coverage_e2e.rs @@ -598,7 +598,13 @@ async fn run_subagent_surfaces_provider_errors_and_can_be_cancelled() -> Result< }) .await }); - tokio::time::sleep(Duration::from_millis(50)).await; + tokio::time::timeout(Duration::from_secs(5), async { + while slow.requests().is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("provider request should start before abort"); handle.abort(); let cancelled = handle.await; assert!(cancelled.is_err()); diff --git a/vendor/tinyplace b/vendor/tinyplace index 63ce8663b..f48536492 160000 --- a/vendor/tinyplace +++ b/vendor/tinyplace @@ -1 +1 @@ -Subproject commit 63ce8663b8ba194d7404b57973116746d0a4eabb +Subproject commit f48536492364824e1da706a7b7d5f109eebc7296