mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
co-authored by
github-actions[bot] <github-actions[bot]@users.noreply.github.com>
parent
e456b77993
commit
0881023866
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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}}`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhuman-app",
|
||||
"version": "0.58.12",
|
||||
"version": "0.58.14",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
|
||||
@@ -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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Generated
+17
-17
@@ -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,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,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>
|
||||
)}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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': 'خامل',
|
||||
|
||||
@@ -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': 'নিষ্ক্রিয়',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -184,11 +184,30 @@ const messages: TranslationMap = {
|
||||
'orchPage.group.agent': 'Agent',
|
||||
'orchPage.group.network': 'Réseau',
|
||||
'orchPage.group.insights': 'Analyses',
|
||||
'orchPage.overview.nav': 'Graphe des agents',
|
||||
'orchPage.tasks.nav': 'Tâches',
|
||||
'orchPage.tasks.subtitle': 'Le tableau de tâches global de l’orchestrateur',
|
||||
'orchPage.tasks.colPending': 'En attente',
|
||||
'orchPage.tasks.colActive': 'Actif',
|
||||
'orchPage.tasks.colBlocked': 'Bloqué',
|
||||
'orchPage.tasks.colCompleted': 'Terminé',
|
||||
'orchPage.overview.core': 'Cœur de l’agent',
|
||||
'orchPage.overview.empty': 'Aucun sous-agent à visualiser pour l’instant',
|
||||
'orchPage.agent.nav': 'Chat',
|
||||
'orchPage.agent.mainTab': 'Agent principal',
|
||||
'orchPage.agent.consciousTab': 'Conscient',
|
||||
'orchPage.agent.subconsciousTab': 'Subconscient',
|
||||
'orchPage.agent.modeLabel': 'Esprit de l’agent',
|
||||
'orchPage.agent.description': "Discutez avec l'agent principal et observez son subconscient",
|
||||
'orchPage.agent.viewSession': 'Voir la session',
|
||||
'orchPage.sessions.railTitle': 'Agents actifs',
|
||||
'orchPage.sessions.empty': 'Aucun agent actif pour le moment',
|
||||
'orchPage.sessions.statusConnected': 'Connecté',
|
||||
'orchPage.sessions.statusDisconnected': 'Déconnecté',
|
||||
'orchPage.sessions.statusWaiting': 'En attente de saisie',
|
||||
'orchPage.session.runtime': 'Environnement d’exécution',
|
||||
'orchPage.session.directory': 'Répertoire',
|
||||
'orchPage.session.runningOn': 'En cours sur',
|
||||
'orchPage.connections.nav': 'Connexions',
|
||||
'orchPage.connections.title': 'Agents liés',
|
||||
'orchPage.connections.description':
|
||||
@@ -203,6 +222,10 @@ const messages: TranslationMap = {
|
||||
'orchPage.connections.messageCount': '{n} messages échangés',
|
||||
'orchPage.connections.back': 'Connexions',
|
||||
'orchPage.connections.replyPlaceholder': 'Répondre…',
|
||||
'orchPage.connections.initTitle': 'Initialiser un agent ou une instance',
|
||||
'orchPage.connections.initDesc':
|
||||
"Lancez un nouveau sous-agent pour travailler à vos côtés. Ouvrez le chat de l'agent et décrivez son rôle, son objectif et ses outils — OpenHuman provisionne une instance dédiée que vous pilotez d'ici.",
|
||||
'orchPage.connections.initCta': 'Commencer dans le chat',
|
||||
'orchPage.connections.status.needsYou': 'Requiert votre attention',
|
||||
'orchPage.connections.status.running': 'En cours',
|
||||
'orchPage.connections.status.idle': 'Inactif',
|
||||
|
||||
@@ -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': 'निष्क्रिय',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -184,11 +184,30 @@ const messages: TranslationMap = {
|
||||
'orchPage.group.agent': 'Agente',
|
||||
'orchPage.group.network': 'Rete',
|
||||
'orchPage.group.insights': 'Approfondimenti',
|
||||
'orchPage.overview.nav': 'Grafo degli agenti',
|
||||
'orchPage.tasks.nav': 'Attività',
|
||||
'orchPage.tasks.subtitle': 'La bacheca attività globale dell’orchestratore',
|
||||
'orchPage.tasks.colPending': 'In attesa',
|
||||
'orchPage.tasks.colActive': 'Attivo',
|
||||
'orchPage.tasks.colBlocked': 'Bloccato',
|
||||
'orchPage.tasks.colCompleted': 'Completato',
|
||||
'orchPage.overview.core': 'Nucleo dell’agente',
|
||||
'orchPage.overview.empty': 'Nessun sotto-agente da visualizzare ancora',
|
||||
'orchPage.agent.nav': 'Chat',
|
||||
'orchPage.agent.mainTab': 'Agente principale',
|
||||
'orchPage.agent.consciousTab': 'Conscio',
|
||||
'orchPage.agent.subconsciousTab': 'Subconscio',
|
||||
'orchPage.agent.modeLabel': 'Mente dell’agente',
|
||||
'orchPage.agent.description': "Chatta con l'agente principale e osserva il suo subconscio",
|
||||
'orchPage.agent.viewSession': 'Vedi sessione',
|
||||
'orchPage.sessions.railTitle': 'Agenti attivi',
|
||||
'orchPage.sessions.empty': 'Ancora nessun agente attivo',
|
||||
'orchPage.sessions.statusConnected': 'Connesso',
|
||||
'orchPage.sessions.statusDisconnected': 'Disconnesso',
|
||||
'orchPage.sessions.statusWaiting': 'In attesa di input',
|
||||
'orchPage.session.runtime': 'Esecuzione',
|
||||
'orchPage.session.directory': 'Cartella',
|
||||
'orchPage.session.runningOn': 'In esecuzione su',
|
||||
'orchPage.connections.nav': 'Connessioni',
|
||||
'orchPage.connections.title': 'Agenti collegati',
|
||||
'orchPage.connections.description':
|
||||
@@ -203,6 +222,10 @@ const messages: TranslationMap = {
|
||||
'orchPage.connections.messageCount': '{n} messaggi',
|
||||
'orchPage.connections.back': 'Connessioni',
|
||||
'orchPage.connections.replyPlaceholder': 'Rispondi…',
|
||||
'orchPage.connections.initTitle': "Inizializza un agente o un'istanza",
|
||||
'orchPage.connections.initDesc':
|
||||
"Avvia un nuovo sotto-agente che lavori al tuo fianco. Apri la chat dell'agente e descrivi ruolo, obiettivo e strumenti — OpenHuman fornisce un'istanza dedicata che puoi guidare da qui.",
|
||||
'orchPage.connections.initCta': 'Inizia nella chat',
|
||||
'orchPage.connections.status.needsYou': 'Richiede la tua attenzione',
|
||||
'orchPage.connections.status.running': 'In esecuzione',
|
||||
'orchPage.connections.status.idle': 'Inattivo',
|
||||
|
||||
@@ -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': '유휴',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Неактивно',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' }),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
'@' => {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
Vendored
+1
-1
Submodule vendor/tinyplace updated: 63ce8663b8...f485364923
Reference in New Issue
Block a user