feat(orchestration): chat rework, Agent graph, global Kanban, presence (#4722)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Steven Enamakel
2026-07-08 22:14:41 -07:00
committed by GitHub
co-authored by github-actions[bot] <github-actions[bot]@users.noreply.github.com>
parent e456b77993
commit 0881023866
83 changed files with 2422 additions and 676 deletions
+10 -8
View File
@@ -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
+3 -3
View File
@@ -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
+13 -12
View File
@@ -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
+14 -14
View File
@@ -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: |
+21 -21
View File
@@ -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: |
+8 -6
View File
@@ -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: |
+3 -3
View File
@@ -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: |
+63 -48
View File
@@ -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}}`.
+7 -5
View File
@@ -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
+5 -3
View File
@@ -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"
+4 -4
View File
@@ -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
@@ -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
+4 -4
View File
@@ -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
+12 -11
View File
@@ -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;
+20 -18
View File
@@ -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;
+9 -7
View File
@@ -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;
+1 -1
View File
@@ -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
+31 -22
View File
@@ -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
Generated
+3 -3
View File
@@ -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",
+1 -1
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openhuman-app",
"version": "0.58.12",
"version": "0.58.14",
"type": "module",
"engines": {
"node": ">=24.0.0"
+21 -10
View File
@@ -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
# ---------------------------------------------------------------------------
+17 -17
View File
@@ -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]]
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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",
@@ -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 (
<div
className="memory-graph rounded-lg border border-line-subtle bg-surface"
className={`memory-graph rounded-lg border border-line-subtle bg-surface ${
fill ? 'flex h-full flex-col overflow-hidden' : ''
}`}
onMouseLeave={() => setHovered(null)}>
<div className="flex items-center justify-between gap-4 border-b border-line-subtle px-4 py-2">
<div className="flex flex-none items-center justify-between gap-4 border-b border-line-subtle px-4 py-2">
<div className="flex items-center gap-3 text-xs text-content-muted">
<span>
{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
<svg
ref={svgRef}
viewBox={`0 0 ${VIEWPORT_W} ${VIEWPORT_H}`}
className="block w-full touch-none select-none"
className={`block w-full touch-none select-none ${fill ? 'min-h-0 flex-1' : ''}`}
style={{
height: 'min(640px, calc(100vh - 22rem))',
height: fill ? '100%' : 'min(640px, calc(100vh - 22rem))',
cursor: grabbing ? 'grabbing' : 'grab',
}}
onPointerDown={onBackgroundPointerDown}
@@ -695,6 +753,29 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint, onReady }: MemoryGr
);
})}
</g>
{showLabels && (
<g>
{sim.sim.slice(0, svgVisible).map((n, i) => {
const label = (n.label ?? '').trim();
const text = label.length > 22 ? `${label.slice(0, 21)}` : label;
return (
<text
key={n.id}
ref={el => {
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}
</text>
);
})}
</g>
)}
</g>
</svg>
)}
+23 -5
View File
@@ -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({
<div
ref={hostRef}
data-testid="memory-graph-canvas"
className="block w-full"
style={{ height: 'min(640px, calc(100vh - 22rem))' }}
className={`block w-full ${fill ? 'min-h-0 flex-1' : ''}`}
style={{ height: fill ? '100%' : 'min(640px, calc(100vh - 22rem))' }}
/>
);
}
@@ -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++) {
@@ -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<typeof import('d3-force').forceLink>;
if (linkForce && typeof linkForce.links === 'function') {
+10 -1
View File
@@ -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 (
<nav aria-label={ariaLabel} className="flex h-full flex-col">
{header && <div className="shrink-0 px-3 pb-1 pt-3">{header}</div>}
<div className="min-h-0 flex-1 overflow-y-auto px-1.5 pb-2">
{/* When there's no header, the list needs its own top padding so the first
item doesn't collide with the pane's top edge. */}
<div className={`min-h-0 flex-1 overflow-y-auto px-1.5 pb-2 ${header ? '' : 'pt-3'}`}>
{groups.map((group, groupIndex) => (
<div key={group.label ?? `__group-${groupIndex}`}>
{group.label && (
@@ -77,6 +85,7 @@ export default function TwoPaneNav({
</ul>
</div>
))}
{footer}
</div>
</nav>
);
@@ -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<ConnState, string | null> = {
connected: 'bg-sage-500',
waiting: 'bg-amber-500',
error: 'bg-coral-500',
disconnected: null,
};
const LABEL_KEY: Record<ConnState, string> = {
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 (
<span
title={t(LABEL_KEY[state])}
aria-label={t(LABEL_KEY[state])}
className={`h-2 w-2 flex-none rounded-full ${fill ?? 'border border-content-faint/60'}`}
/>
);
}
export interface ActiveSubagentsRailProps {
/** Sessions grouped by contact (`agentId`) — the instances. */
byContact: Map<string, SessionSummary[]>;
/** 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<Set<string>>(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 (
<div className="mt-2 border-t border-line-subtle pt-2">
<div className="px-2 pb-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-content-muted">
{t('orchPage.sessions.railTitle')}
</span>
</div>
{instances.length === 0 ? (
<p className="px-2 py-1 text-[11px] text-content-faint">{t('orchPage.sessions.empty')}</p>
) : (
<ul>
{instances.map(([address, sessions]) => {
const isOpen = !collapsed.has(address);
return (
<li key={address} data-testid={`orch-instance-${address}`}>
{/* Instance header — icon + aggregate status dot + name. */}
<button
type="button"
aria-expanded={isOpen}
data-testid={`orch-instance-toggle-${address}`}
onClick={() => toggle(address)}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-content-secondary transition-colors hover:bg-surface-hover hover:text-content">
<span className="flex-none text-[9px] text-content-faint">
{isOpen ? '▾' : '▸'}
</span>
<span className="relative flex-none">
<span className="flex h-6 w-6 items-center justify-center rounded-md border border-line bg-surface-strong text-[10px] font-semibold text-content-secondary">
{address.slice(0, 2).toUpperCase()}
</span>
<span className="absolute -bottom-0.5 -right-0.5 rounded-full bg-surface-muted p-[1px]">
<StatusDot state={instanceState(sessions)} />
</span>
</span>
<span className="min-w-0 flex-1 truncate font-mono text-[12px]">
{shortAddress(address)}
</span>
<span className="flex-none text-[10px] text-content-faint">
{sessions.length}
</span>
</button>
{/* Sub-agents nested under the instance. */}
{isOpen ? (
<ul className="ml-4 border-l border-line-subtle pl-1.5">
{sessions.map(session => {
const active = isAgentTab && openSessionId === session.sessionId;
return (
<li key={session.sessionId}>
<button
type="button"
data-testid={`orch-session-rail-${session.sessionId}`}
aria-current={active ? 'true' : undefined}
onClick={() => onOpenSession(session.sessionId)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors ${
active
? 'bg-surface-subtle text-content'
: 'text-content-secondary hover:bg-surface-hover hover:text-content'
}`}>
<StatusDot state={sessionState(session)} />
<span className="min-w-0 flex-1 truncate text-[13px]">
{sessionLabel(session)}
</span>
{session.unread > 0 ? (
<span className="flex-none rounded-full bg-primary-500 px-1.5 py-0.5 text-[10px] font-semibold text-content-inverted">
{session.unread}
</span>
) : null}
</button>
</li>
);
})}
</ul>
) : null}
</li>
);
})}
</ul>
)}
</div>
);
}
@@ -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<HTMLDivElement | null>(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 (
<div className="relative flex h-full flex-col overflow-hidden bg-surface/70 dark:bg-black/40">
{header}
<div
className="min-h-0 flex-1 overflow-y-auto"
style={footer ? { paddingBottom: footerHeight } : undefined}>
{children}
</div>
{/* Fade so messages dissolve into the background behind the composer. */}
{footer ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-28 bg-gradient-to-t from-white via-white/90 to-transparent dark:from-black dark:via-black/90"
/>
) : null}
{/* Floating, centered, width-capped composer footer over the fade. */}
{footer ? (
<div
ref={footerRef}
className="absolute inset-x-0 bottom-0 z-20 mx-auto w-full max-w-[48.75rem] px-4 pb-4 pt-6">
{footer}
</div>
) : null}
</div>
);
}
/**
* 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<void>;
isSending: boolean;
placeholder: string;
}) {
const textInputRef = useRef<HTMLTextAreaElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const isComposingTextRef = useRef(false);
const handleInputKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Enter' && !event.shiftKey && !isComposingTextRef.current) {
event.preventDefault();
void onSend();
}
},
[onSend]
);
return (
<ChatComposer
inputValue={value}
setInputValue={setValue}
onSend={async text => {
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<string | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const submit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
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 (
<aside
data-testid="orch-agent-session-drawer"
className="absolute inset-y-0 right-0 z-30 flex w-[24rem] flex-col border-l border-line bg-surface shadow-[-8px_0_24px_-12px_rgba(0,0,0,0.25)]">
<div className="flex items-center gap-2 border-b border-line px-4 py-2.5">
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-full border border-line bg-surface-strong text-[11px] font-semibold text-content-secondary">
{sessionLabel(session).slice(0, 2)}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-content">{sessionLabel(session)}</p>
<p className="truncate text-[11px] text-content-muted">
{session.status === 'waiting-approval' ? t('orchPage.connections.status.needsYou') : ''}
</p>
<ChatPageScaffold
header={
// Agent metadata, centered to the same width-capped column as the chat.
<div className="border-b border-line bg-surface/60 dark:bg-black/30">
<div
className="mx-auto w-full max-w-[48.75rem] px-5 py-3"
data-testid="orch-session-header">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-content">{sessionLabel(session)}</p>
{session.status === 'waiting-approval' ? (
<span className="flex-none rounded-full px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-300">
{t('orchPage.connections.status.needsYou')}
</span>
) : null}
</div>
{/* Full values — they wrap/break only at the column's max width, never
truncated at an arbitrary per-field cap. */}
<dl className="mt-1.5 flex flex-wrap gap-x-5 gap-y-1 text-[11px]">
{runtime ? (
<div className="flex items-baseline gap-1">
<dt className="text-content-faint">{t('orchPage.session.runtime')}</dt>
<dd className="font-medium text-content-secondary">{runtime}</dd>
</div>
) : null}
{directory ? (
<div className="flex items-baseline gap-1">
<dt className="text-content-faint">{t('orchPage.session.directory')}</dt>
<dd className="break-all font-mono font-medium text-content-secondary">
{directory}
</dd>
</div>
) : null}
{runningOn ? (
<div className="flex items-baseline gap-1">
<dt className="text-content-faint">{t('orchPage.session.runningOn')}</dt>
<dd className="break-all font-mono font-medium text-content-secondary">
{runningOn}
</dd>
</div>
) : null}
</dl>
</div>
</div>
<button
type="button"
onClick={onClose}
data-testid="orch-agent-drawer-close"
className="flex-none rounded p-1 text-content-faint transition hover:bg-surface-hover">
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
}
footer={
<>
{sendError ? (
<p
data-testid="orch-session-reply-error"
className="mb-2 rounded-md bg-coral-50 px-2 py-1 text-xs text-coral-700 dark:bg-coral-500/10 dark:text-coral-300">
{t('tinyplaceOrchestration.composer.sendFailed')}: {sendError}
</p>
) : null}
<AgentComposer
value={body}
setValue={setBody}
onSend={send}
isSending={sending}
placeholder={t('orchPage.connections.replyPlaceholder')}
/>
</>
}>
<div className="mx-auto w-full max-w-[48.75rem] space-y-3 px-5 pt-4">
{state.status === 'loading' ? (
<p className="py-6 text-center text-sm text-content-muted">
<p className="py-10 text-center text-sm text-content-muted">
{t('tinyplaceOrchestration.loading')}
</p>
) : state.status === 'error' ? (
<p className="py-6 text-center text-sm text-coral-600 dark:text-coral-300">
<p className="py-10 text-center text-sm text-coral-600 dark:text-coral-300">
{t('tinyplaceOrchestration.failedToLoad')}: {state.message}
</p>
) : messages.length === 0 ? (
<p className="py-6 text-center text-sm text-content-faint">
<p className="py-10 text-center text-sm text-content-faint">
{t('tinyplaceOrchestration.noMessages')}
</p>
) : (
<SessionTranscript messages={messages} />
)}
</div>
<form className="border-t border-line p-3" onSubmit={submit}>
{sendError ? (
<p
data-testid="orch-agent-drawer-reply-error"
className="mb-2 rounded-md bg-coral-50 px-2 py-1 text-xs text-coral-700 dark:bg-coral-500/10 dark:text-coral-300">
{t('tinyplaceOrchestration.composer.sendFailed')}: {sendError}
</p>
) : null}
<div className="flex gap-2">
<input
value={body}
onChange={e => setBody(e.target.value)}
placeholder={t('orchPage.connections.replyPlaceholder')}
data-testid="orch-agent-drawer-reply"
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"
/>
<Button type="submit" variant="primary" size="sm" disabled={!body.trim() || sending}>
{t('tinyplaceOrchestration.composer.send')}
</Button>
</div>
</form>
</aside>
</ChatPageScaffold>
);
}
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<string | null>(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<string | null>(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<HTMLFormElement>) => {
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 (
<div className="relative flex h-full min-h-[520px] overflow-hidden rounded-xl border border-line bg-surface shadow-soft">
{/* Thread rail — mirrors the normal chat's ThreadList. */}
<aside className="flex w-56 flex-none flex-col border-r border-line bg-surface-muted/40">
<div className="border-b border-line-subtle px-3 py-2 text-[10px] font-semibold uppercase tracking-wide text-content-muted">
{t('orchPage.agent.mainTab')}
</div>
<div
className="min-h-0 flex-1 overflow-y-auto"
role="tablist"
aria-label={t('orchPage.agent.description')}>
{rail.map(chat => {
const active = selectedId === chat.id;
return (
<button
key={chat.id}
type="button"
role="tab"
aria-selected={active}
data-testid={`orch-agent-tab-${chat.id}`}
onClick={() => selectChat(chat.id)}
className={`flex w-full items-center gap-2.5 border-b border-line-subtle/60 px-3 py-2.5 text-left transition-colors dark:border-line/60 ${
active
? 'border-l-2 border-l-primary-500 bg-primary-50 dark:bg-primary-900/30'
: 'hover:bg-surface-hover'
}`}>
<span
className={`flex h-7 w-7 flex-none items-center justify-center rounded-lg text-[11px] font-semibold ${
active
? 'bg-primary-500 text-white'
: 'border border-line bg-surface-strong text-content-secondary'
}`}>
{chat.id === SUBCONSCIOUS_CHAT_KEY ? 'S' : 'M'}
</span>
<span className="min-w-0 flex-1">
<span
className={`block truncate text-sm ${
active
? 'font-medium text-primary-700 dark:text-primary-200'
: 'text-content-secondary'
}`}>
{chat.title}
</span>
<span className="block truncate text-[11px] text-content-faint">
{chat.subtitle}
</span>
</span>
{chat.unread > 0 ? (
<span className="flex-none rounded-full bg-primary-500 px-1.5 py-0.5 text-[10px] font-semibold text-content-inverted">
{chat.unread}
</span>
) : null}
</button>
);
})}
</div>
</aside>
// 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 <SessionChatView session={openSession} />;
}
{/* Message pane. */}
<main className="relative flex min-w-0 flex-1 flex-col bg-surface/70 dark:bg-black/40">
{/* Subconscious steering header. */}
{isSubconscious ? (
<div
data-testid="orch-agent-steering-header"
className="flex items-center justify-between gap-3 border-b border-line bg-amber-50/40 px-5 py-2.5 dark:bg-amber-500/5">
<div className="min-w-0">
<p className="text-xs font-medium text-content">
{steeringText
? t('tinyplaceOrchestration.steeringHeader.current')
: t('tinyplaceOrchestration.steeringHeader.none')}
</p>
{steeringText ? (
<p className="mt-0.5 truncate text-xs text-content-muted">{steeringText}</p>
) : null}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => void runSteeringReview()}
disabled={runningReview}>
{runningReview
? t('tinyplaceOrchestration.steeringHeader.running')
: t('tinyplaceOrchestration.steeringHeader.runReview')}
</Button>
</div>
) : null}
const loading = sessionsState.status === 'loading' || messagesState.status === 'loading';
const errored = sessionsState.status === 'error' || messagesState.status === 'error';
{sessionsState.status === 'loading' || messagesState.status === 'loading' ? (
<div className="flex flex-1 items-center justify-center text-sm text-content-muted">
{t('tinyplaceOrchestration.loading')}
</div>
) : sessionsState.status === 'error' || messagesState.status === 'error' ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-sm text-coral-600 dark:text-coral-300">
<p>
{t('tinyplaceOrchestration.failedToLoad')}:{' '}
{sessionsState.status === 'error'
? sessionsState.message
: messagesState.status === 'error'
? messagesState.message
: ''}
</p>
<Button variant="secondary" size="sm" onClick={() => void refresh()}>
{t('common.retry')}
</Button>
</div>
) : (
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-[48.75rem] space-y-3 px-5 py-5">
{selected?.messages.length ? (
<SessionTranscript messages={selected.messages} />
) : (
<p className="py-10 text-center text-sm text-content-faint">
{t('tinyplaceOrchestration.noMessages')}
</p>
)}
{/* View-session cards for sessions the agent engaged (needs you). */}
{pinged.map(session => (
<div key={session.sessionId} className="flex justify-start">
<button
type="button"
data-testid={`orch-agent-view-session-${session.sessionId}`}
onClick={() => setOpenSessionId(session.sessionId)}
className="flex w-full max-w-[85%] items-center gap-3 rounded-xl border border-primary-200 bg-primary-50 px-3 py-2.5 text-left transition hover:bg-primary-100/60 dark:border-primary-500/30 dark:bg-primary-900/20">
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-lg bg-primary-500/15 text-sm text-primary-600 dark:text-primary-300">
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-content">
{sessionLabel(session)}
</span>
<span className="block truncate text-[11px] text-amber-600 dark:text-amber-300">
{t('orchPage.connections.status.needsYou')}
</span>
</span>
<span className="flex-none rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white">
{t('orchPage.agent.viewSession')}
</span>
</button>
</div>
))}
</div>
</div>
)}
{/* Master composer. */}
{isMasterSelected && sessionsState.status === 'ok' ? (
<form
className="flex flex-col gap-2 border-t border-line px-5 py-3"
onSubmit={submitComposer}>
{masterError ? (
<p className="rounded-md bg-coral-50 px-2 py-1 text-xs text-coral-700 dark:bg-coral-500/10 dark:text-coral-300">
{t('tinyplaceOrchestration.composer.sendFailed')}: {masterError}
</p>
// The Conscious/Subconscious toggle — sits in the composer footer slot where
// the generic chat's super-context / quick-reasoning controls live.
const modeToggle = (
<div
className="inline-flex h-7 items-center rounded-full border border-line bg-surface-subtle p-0.5"
role="radiogroup"
aria-label={t('orchPage.agent.modeLabel')}>
{rail.map(chat => {
const active = selectedId === chat.id;
const label =
chat.id === SUBCONSCIOUS_CHAT_KEY
? t('orchPage.agent.subconsciousTab')
: t('orchPage.agent.consciousTab');
return (
<button
key={chat.id}
type="button"
role="radio"
aria-checked={active}
data-testid={`orch-agent-tab-${chat.id}`}
onClick={() => selectChat(chat.id)}
className={`flex items-center gap-1.5 rounded-full px-3 py-0.5 text-xs font-medium transition-all ${
active
? 'bg-surface text-content shadow-sm'
: 'text-content-muted hover:text-content-secondary'
}`}>
{label}
{chat.unread > 0 ? (
<span className="inline-flex min-w-4 items-center justify-center rounded-full bg-primary-500 px-1 text-[10px] font-semibold text-content-inverted">
{chat.unread}
</span>
) : null}
<div className="flex gap-2">
<input
data-testid="orch-agent-composer-input"
value={composerBody}
onChange={event => setComposerBody(event.target.value)}
placeholder={t('tinyplaceOrchestration.composer.placeholder')}
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"
/>
<Button
type="submit"
variant="primary"
size="sm"
data-testid="orch-agent-composer-send"
disabled={!composerBody.trim() || sending}>
{t('tinyplaceOrchestration.composer.send')}
</Button>
</div>
</form>
) : null}
</main>
{/* 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.
<SessionDrawer
key={openSession.sessionId}
session={openSession}
onClose={() => setOpenSessionId(null)}
/>
) : null}
</button>
);
})}
</div>
);
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).
<ChatPageScaffold
footer={
<div className="flex flex-col gap-2">
{showComposer ? (
<>
{masterError ? (
<p className="rounded-md bg-coral-50 px-2 py-1 text-xs text-coral-700 dark:bg-coral-500/10 dark:text-coral-300">
{t('tinyplaceOrchestration.composer.sendFailed')}: {masterError}
</p>
) : null}
<AgentComposer
value={composerBody}
setValue={setComposerBody}
onSend={sendComposer}
isSending={sending}
placeholder={t('tinyplaceOrchestration.composer.placeholder')}
/>
</>
) : null}
<div className="flex items-center justify-between gap-2">
{modeToggle}
{isSubconscious ? (
<div className="flex min-w-0 items-center gap-2" data-testid="orch-agent-steering">
{steeringText ? (
<span className="hidden min-w-0 truncate text-[11px] text-content-muted sm:inline">
{steeringText}
</span>
) : null}
<Button
variant="secondary"
size="sm"
onClick={() => void runSteeringReview()}
disabled={runningReview}>
{runningReview
? t('tinyplaceOrchestration.steeringHeader.running')
: t('tinyplaceOrchestration.steeringHeader.runReview')}
</Button>
</div>
) : null}
</div>
</div>
}>
{loading ? (
<div className="flex h-full items-center justify-center text-sm text-content-muted">
{t('tinyplaceOrchestration.loading')}
</div>
) : errored ? (
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm text-coral-600 dark:text-coral-300">
<p>
{t('tinyplaceOrchestration.failedToLoad')}:{' '}
{sessionsState.status === 'error'
? sessionsState.message
: messagesState.status === 'error'
? messagesState.message
: ''}
</p>
<Button variant="secondary" size="sm" onClick={() => void refresh()}>
{t('common.retry')}
</Button>
</div>
) : showHero && pinged.length === 0 ? (
// Empty conscious thread: reuse the generic chat's welcome hero.
<div className="mx-auto flex h-full w-full max-w-[48.75rem] px-5">
<ChatNewWindowHero />
</div>
) : (
<div className="mx-auto w-full max-w-[48.75rem] space-y-3 px-5 pt-4">
{selected?.messages.length ? (
<SessionTranscript messages={selected.messages} />
) : showHero ? null : (
<p className="py-10 text-center text-sm text-content-faint">
{t('tinyplaceOrchestration.noMessages')}
</p>
)}
{/* "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 => (
<div key={session.sessionId} className="flex justify-start">
<button
type="button"
data-testid={`orch-agent-view-session-${session.sessionId}`}
onClick={() => setOpenSessionId(session.sessionId)}
className="flex w-full max-w-[85%] items-center gap-3 rounded-xl border border-primary-200 bg-primary-50 px-3 py-2.5 text-left transition hover:bg-primary-100/60 dark:border-primary-500/30 dark:bg-primary-900/20">
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-lg bg-primary-500/15 text-sm text-primary-600 dark:text-primary-300">
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-content">
{sessionLabel(session)}
</span>
<span className="block truncate text-[11px] text-amber-600 dark:text-amber-300">
{t('orchPage.connections.status.needsYou')}
</span>
</span>
<span className="flex-none rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white">
{t('orchPage.agent.viewSession')}
</span>
</button>
</div>
))}
</div>
)}
</ChatPageScaffold>
);
}
@@ -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
</ul>
)}
</SectionCard>
{/* Initialize a brand-new sub-agent / instance. Unlike Discover (which
links to an EXISTING peer), this explains how to spin up your own. */}
<SectionCard
title={t('orchPage.connections.initTitle')}
description={t('orchPage.connections.initDesc')}
testId="orch-connections-initialize">
<Button
variant="primary"
size="sm"
onClick={onInitializeAgent}
disabled={!onInitializeAgent}
data-testid="orch-connections-initialize-cta">
{t('orchPage.connections.initCta')}
</Button>
</SectionCard>
</div>
);
}
@@ -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<TaskBoard | null>(null);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState('');
const [adding, setAdding] = useState(false);
const [mutatingCardId, setMutatingCardId] = useState<string | null>(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<TaskBoard>) => {
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 (
<div className="space-y-4" data-testid="orch-task-board">
<div>
<h2 className="text-xl font-bold text-content">{t('orchPage.tasks.nav')}</h2>
<p className="mt-1 text-sm text-content-muted">{t('orchPage.tasks.subtitle')}</p>
</div>
{error ? (
<div className="flex items-center justify-between gap-3 rounded-lg border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
<span className="min-w-0 truncate">{error}</span>
<Button variant="secondary" size="sm" onClick={() => void load()}>
{t('common.retry')}
</Button>
</div>
) : null}
<form
className="flex gap-2"
onSubmit={event => {
event.preventDefault();
void handleAdd();
}}>
<input
value={draft}
onChange={event => 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"
/>
<Button
type="submit"
variant="primary"
size="sm"
data-testid="orch-task-add-submit"
disabled={!draft.trim() || adding}>
{t('common.add')}
</Button>
</form>
<TaskKanbanBoard
board={board ?? EMPTY_BOARD}
columns={ORCHESTRATOR_COLUMNS}
onMove={handleMove}
onUpdateCard={handleUpdate}
onDeleteCard={handleDelete}
mutatingCardId={mutatingCardId}
hideHeader
/>
</div>
);
}
@@ -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<string>();
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.
<div className="h-full p-2" data-testid="orch-overview">
<MemoryGraph
nodes={nodes}
edges={edges}
mode="contacts"
rootLabel={t('orchPage.overview.core')}
emptyHint={t('orchPage.overview.empty')}
fill
fitToBounds
showLabels
/>
</div>
);
}
@@ -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: () => <div data-testid="chat-new-window-hero" />,
}));
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(<AgentChatPanel />);
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(<AgentChatPanel />);
expect(screen.getByTestId('chat-new-window-hero')).toBeInTheDocument();
});
it('sends a master message from the composer', async () => {
render(<AgentChatPanel />);
fireEvent.change(screen.getByTestId('orch-agent-composer-input'), { target: { value: 'go' } });
@@ -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<TaskBoardCardStatus, string> = {
todo: '',
@@ -75,14 +74,17 @@ const STATUS_LABEL_KEYS: Record<TaskBoardCardStatus, string> = {
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<TaskBoardCardStatus | null>(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({
<TaskSourceControls disabled={disabled} compact={!isTaskSourcesBoard} />
)}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{COLUMN_DEFS.map(column => {
{columnDefs.map(column => {
const cards = cardsByStatus[column.status];
const isBlockedColumn = column.status === 'blocked';
const isDragTarget = dragOverColumn === column.status;
@@ -17,6 +17,11 @@ const h = vi.hoisted(() => ({
useRiveParams: null as Record<string, unknown> | null,
enumCalls: {} as Record<string, unknown[]>,
colorCalls: {} as Record<string, unknown[]>,
enumValuesByPath: {
pose: ['idle', 'look_around', 'pointing'],
mouthVisemeCode: ['sil', 'PP', 'aa'],
eyes: ['blink', 'look_left', 'look_right'],
} as Record<string, string[]>,
}));
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 () => {
+23
View File
@@ -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': 'خامل',
+23
View File
@@ -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': 'নিষ্ক্রিয়',
+23
View File
@@ -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',
+23
View File
@@ -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',
+23
View File
@@ -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',
+23
View File
@@ -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 lorchestrateur',
'orchPage.tasks.colPending': 'En attente',
'orchPage.tasks.colActive': 'Actif',
'orchPage.tasks.colBlocked': 'Bloqué',
'orchPage.tasks.colCompleted': 'Terminé',
'orchPage.overview.core': 'Cœur de lagent',
'orchPage.overview.empty': 'Aucun sous-agent à visualiser pour linstant',
'orchPage.agent.nav': 'Chat',
'orchPage.agent.mainTab': 'Agent principal',
'orchPage.agent.consciousTab': 'Conscient',
'orchPage.agent.subconsciousTab': 'Subconscient',
'orchPage.agent.modeLabel': 'Esprit de lagent',
'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 dexé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',
+23
View File
@@ -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': 'निष्क्रिय',
+23
View File
@@ -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',
+23
View File
@@ -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 dellorchestratore',
'orchPage.tasks.colPending': 'In attesa',
'orchPage.tasks.colActive': 'Attivo',
'orchPage.tasks.colBlocked': 'Bloccato',
'orchPage.tasks.colCompleted': 'Completato',
'orchPage.overview.core': 'Nucleo dellagente',
'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 dellagente',
'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',
+23
View File
@@ -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': '유휴',
+23
View File
@@ -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',
+23
View File
@@ -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',
+23
View File
@@ -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': 'Неактивно',
+23
View File
@@ -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': '空闲',
@@ -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;
}
/**
+163 -66
View File
@@ -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<OrchestrationTab>(() => {
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 (
<div className="h-full">
@@ -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={
<p className="min-w-0 text-[11px] text-content-muted">{t('orchPage.subtitle')}</p>
footer={
// Active sub-agents, grouped by instance (contact) with a
// connection-status dot. Clicking a sub-agent opens its chat.
<ActiveSubagentsRail
byContact={contactSessions.byContact}
openSessionId={openSessionId}
isAgentTab={activeTab === 'agent'}
onOpenSession={setOpenSessionId}
/>
}
/>
</div>
</SidebarContent>
<div className="mx-auto h-full w-full max-w-5xl">
{activeTab === 'agent' ? (
// The chat panel manages its own full-height layout + scroll.
<div className="h-full p-4">
<AgentChatPanel />
</div>
) : (
{activeTab === 'overview' ? (
// Interactive graph of the agent / sub-agent system.
<OverviewPanel />
) : activeTab === 'agent' ? (
// Full-bleed so it reads exactly like the normal chat page (dark
// background, floating composer, one vertical scroll) — no card/gutter.
<div className="h-full">
<AgentChatPanel openSessionId={openSessionId} onOpenSession={setOpenSessionId} />
</div>
) : activeTab === 'tasks' ? (
// One global Kanban board owned by the orchestrator (not per-thread).
<div className="mx-auto h-full w-full max-w-5xl">
<PanelPage contentClassName="p-4">
<div className="mx-auto max-w-3xl animate-fade-up">
{activeTab === 'connections' && (
<ConnectionsPanel onDiscover={() => setActiveTab('discover')} />
)}
{activeTab === 'discover' && <DiscoverPanel />}
{activeTab === 'usage' && <UsagePanel />}
<div className="animate-fade-up">
<OrchestratorTaskBoard />
</div>
</PanelPage>
)}
</div>
</div>
) : (
<div className="mx-auto h-full w-full max-w-5xl">
{/* 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. */}
<PanelPage contentClassName="p-4">
<div className="mx-auto max-w-3xl space-y-5 animate-fade-up">
<ChipTabs<NetworkSub>
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' && (
<ConnectionsPanel
onDiscover={() => setNetworkSub('discover')}
onInitializeAgent={() => setActiveTab('agent')}
/>
)}
{networkSub === 'discover' && <DiscoverPanel />}
{networkSub === 'usage' && <UsagePanel />}
</div>
</PanelPage>
</div>
)}
</div>
);
}
+8
View File
@@ -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/<hex(id)>.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.
@@ -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;
+33 -5
View File
@@ -86,6 +86,29 @@ export interface SentMessage {
[key: string]: unknown;
}
function telegramSentBody(entry: SentMessage): Record<string, unknown> {
return typeof entry.body === 'object' && entry.body !== null
? (entry.body as Record<string, unknown>)
: {};
}
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<string, unknown>).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);
@@ -62,8 +62,12 @@ async function registeredProviders(): Promise<string[]> {
});
}
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();
@@ -91,7 +91,20 @@ async function clickRecoveryConsentCheckbox(): Promise<void> {
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<HTMLInputElement>('#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,
@@ -125,6 +125,13 @@ async function connectTelegramChannel(): Promise<boolean> {
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' }),
},
],
},
@@ -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`);
});
@@ -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`);
});
@@ -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' }),
},
],
},
+3 -3
View File
@@ -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);
});
});
@@ -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' }),
},
],
},
@@ -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<unknown>('openhuman.workflows_list', {});
const rpcResult = await callCoreRpc<unknown>('openhuman.flows_list', {});
const root = (rpcResult ?? {}) as Record<string, unknown>;
const payload =
root && typeof root === 'object' && 'result' in root
? (root.result as Record<string, unknown>)
: root;
expect(Array.isArray(payload.skills ?? [])).toBe(true);
const flows =
payload && typeof payload === 'object' && 'result' in payload
? (payload.result as unknown)
: ((payload as Record<string, unknown>).flows ?? payload);
expect(Array.isArray(flows)).toBe(true);
});
});
+4
View File
@@ -90,6 +90,10 @@ export const config: Options.Testrunner & Record<string, unknown> = {
{
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
@@ -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}`;
+33
View File
@@ -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" &&
+9
View File
@@ -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;
};
@@ -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;
+3 -3
View File
@@ -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,
+1 -1
View File
@@ -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 {
'@' => {
+4
View File
@@ -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)?;
+1
View File
@@ -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;
+99
View File
@@ -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<Mutex<HashMap<String, PeerPresence>>> =
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<bool> {
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<String> {
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);
}
}
+31 -7
View File
@@ -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<bool>,
/// ISO-8601 last time we heard from this peer, if ever.
#[serde(skip_serializing_if = "Option::is_none")]
last_seen_at: Option<String>,
}
#[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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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 =
+71
View File
@@ -139,6 +139,15 @@ pub fn with_connection<T>(
// `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
+1 -1
View File
@@ -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()
}
+6
View File
@@ -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
+1 -1
View File
@@ -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() {
@@ -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());