merge: dev into main
320
.github/workflows/release.yml
vendored
|
|
@ -5,13 +5,33 @@ on:
|
|||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Release tag to build into a draft, for example v2.0.0
|
||||
required: true
|
||||
type: string
|
||||
publish_release:
|
||||
description: Publish the release after all assets are uploaded
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || inputs.release_tag || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
IS_RELEASE_BUILD: ${{ startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' }}
|
||||
RELEASE_TAG: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || inputs.release_tag }}
|
||||
PUBLISH_RELEASE: ${{ startsWith(github.ref, 'refs/tags/v') || inputs.publish_release }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
|
@ -31,32 +51,74 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Set version from tag
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
- name: Validate release metadata
|
||||
if: ${{ env.IS_RELEASE_BUILD == 'true' }}
|
||||
run: |
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
set -euo pipefail
|
||||
case "${RELEASE_TAG:-}" in
|
||||
v[0-9]*) ;;
|
||||
*)
|
||||
echo "release_tag must start with v and include a numeric version, got '${RELEASE_TAG:-<empty>}'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Set version from release tag
|
||||
if: ${{ env.IS_RELEASE_BUILD == 'true' }}
|
||||
run: |
|
||||
VERSION="${RELEASE_TAG#v}"
|
||||
pnpm pkg set version="$VERSION"
|
||||
|
||||
- name: Verify Sentry release env
|
||||
if: ${{ env.IS_RELEASE_BUILD == 'true' }}
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: node ./scripts/ci/verify-sentry-release.cjs prebuild
|
||||
|
||||
- name: Build app
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: quant-jump-pro
|
||||
SENTRY_PROJECT: electron
|
||||
SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }}
|
||||
SENTRY_AUTH_TOKEN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_AUTH_TOKEN || '' }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: pnpm build
|
||||
|
||||
- name: Verify Sentry source map upload
|
||||
if: ${{ env.IS_RELEASE_BUILD == 'true' }}
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: node ./scripts/ci/verify-sentry-release.cjs postbuild
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
if: ${{ env.IS_RELEASE_BUILD == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
TAG="${RELEASE_TAG:-$TAG}"
|
||||
existing_is_draft="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft' 2>/dev/null || true)"
|
||||
if [ -n "$existing_is_draft" ]; then
|
||||
if [ "${PUBLISH_RELEASE}" != "true" ] && [ "$existing_is_draft" != "true" ]; then
|
||||
echo "Release $TAG already exists and is not a draft; refusing to overwrite it from draft mode." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Release $TAG already exists, skipping creation"
|
||||
exit 0
|
||||
fi
|
||||
gh release create "$TAG" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--target "$GITHUB_SHA" \
|
||||
--title "$TAG" \
|
||||
--generate-notes \
|
||||
--draft=false 2>/dev/null || echo "Release $TAG already exists, skipping creation"
|
||||
--draft=true
|
||||
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
|
|
@ -69,35 +131,61 @@ jobs:
|
|||
|
||||
prepare-runtime:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Validate release metadata
|
||||
if: ${{ env.IS_RELEASE_BUILD == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "${RELEASE_TAG:-}" in
|
||||
v[0-9]*) ;;
|
||||
*)
|
||||
echo "release_tag must start with v and include a numeric version, got '${RELEASE_TAG:-<empty>}'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
if: ${{ env.IS_RELEASE_BUILD == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
TAG="${RELEASE_TAG:-$TAG}"
|
||||
existing_is_draft="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft' 2>/dev/null || true)"
|
||||
if [ -n "$existing_is_draft" ]; then
|
||||
if [ "${PUBLISH_RELEASE}" != "true" ] && [ "$existing_is_draft" != "true" ]; then
|
||||
echo "Release $TAG already exists and is not a draft; refusing to overwrite it from draft mode." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Release $TAG already exists, skipping creation"
|
||||
exit 0
|
||||
fi
|
||||
gh release create "$TAG" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--target "$GITHUB_SHA" \
|
||||
--title "$TAG" \
|
||||
--generate-notes \
|
||||
--draft=false 2>/dev/null || echo "Release $TAG already exists, skipping creation"
|
||||
--draft=true
|
||||
|
||||
- name: Skip runtime asset preparation for manual builds
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: echo "Runtime asset preparation is only needed for tagged releases."
|
||||
- name: Skip runtime asset preparation for non-release builds
|
||||
if: ${{ env.IS_RELEASE_BUILD != 'true' }}
|
||||
run: echo "Runtime asset preparation is only needed for release builds."
|
||||
|
||||
- name: Check runtime assets
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
if: ${{ env.IS_RELEASE_BUILD == 'true' }}
|
||||
id: runtime-assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
TAG="${RELEASE_TAG:-$TAG}"
|
||||
existing="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' 2>/dev/null || true)"
|
||||
missing=0
|
||||
while IFS= read -r asset; do
|
||||
|
|
@ -120,7 +208,7 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
TARGET_TAG="${GITHUB_REF#refs/tags/}"
|
||||
TARGET_TAG="${RELEASE_TAG}"
|
||||
SOURCE_REPO="$(node ./scripts/runtime-lock.mjs source-repository)"
|
||||
SOURCE_REF="$(node ./scripts/runtime-lock.mjs source-ref)"
|
||||
RUNTIME_VERSION="$(node ./scripts/runtime-lock.mjs version)"
|
||||
|
|
@ -191,6 +279,7 @@ jobs:
|
|||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
TAG="${RELEASE_TAG:-$TAG}"
|
||||
for attempt in $(seq 1 12); do
|
||||
existing="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' 2>/dev/null || true)"
|
||||
all_found=1
|
||||
|
|
@ -216,6 +305,7 @@ jobs:
|
|||
|
||||
release-mac:
|
||||
needs: [build, prepare-runtime]
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
|
@ -256,10 +346,10 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Set version from tag
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
- name: Set version from release tag
|
||||
if: ${{ env.IS_RELEASE_BUILD == 'true' }}
|
||||
run: |
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
VERSION="${RELEASE_TAG#v}"
|
||||
pnpm pkg set version="$VERSION"
|
||||
|
||||
- name: Stage bundled runtime (macOS ${{ matrix.arch }})
|
||||
|
|
@ -268,21 +358,39 @@ jobs:
|
|||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
if [[ "${IS_RELEASE_BUILD}" == "true" ]]; then
|
||||
TAG="${RELEASE_TAG}"
|
||||
node ./scripts/stage-runtime.mjs --platform "darwin-${{ matrix.arch }}" --release-tag "$TAG"
|
||||
else
|
||||
node ./scripts/stage-runtime.mjs --platform "darwin-${{ matrix.arch }}"
|
||||
fi
|
||||
|
||||
- name: Verify Sentry release env (macOS ${{ matrix.arch }})
|
||||
if: ${{ env.IS_RELEASE_BUILD == 'true' }}
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: node ./scripts/ci/verify-sentry-release.cjs prebuild
|
||||
|
||||
- name: Build app (macOS ${{ matrix.arch }})
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }}
|
||||
SENTRY_AUTH_TOKEN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_AUTH_TOKEN || '' }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: pnpm build
|
||||
|
||||
- name: Verify Sentry source map upload (macOS ${{ matrix.arch }})
|
||||
if: ${{ env.IS_RELEASE_BUILD == 'true' }}
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: quant-jump-pro
|
||||
SENTRY_PROJECT: electron
|
||||
run: pnpm build
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: node ./scripts/ci/verify-sentry-release.cjs postbuild
|
||||
|
||||
- name: Verify packaged inputs (macOS ${{ matrix.arch }})
|
||||
run: |
|
||||
|
|
@ -309,22 +417,22 @@ jobs:
|
|||
run: node ./scripts/electron-builder/smokePackagedApp.cjs "release/mac-${{ matrix.arch }}/Agent Teams UI.app" darwin
|
||||
|
||||
- name: Upload assets to release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
if: ${{ env.IS_RELEASE_BUILD == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
TAG="${RELEASE_TAG}"
|
||||
for f in release/*.dmg release/*.zip release/*.blockmap; do
|
||||
[ -f "$f" ] || continue
|
||||
echo "Uploading: $f"
|
||||
gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber 2>&1 || \
|
||||
(sleep 5 && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber 2>&1) || \
|
||||
echo "WARNING: failed to upload $f, continuing..."
|
||||
(sleep 5 && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber 2>&1)
|
||||
done
|
||||
|
||||
release-win:
|
||||
needs: [build, prepare-runtime]
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
|
@ -354,11 +462,11 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Set version from tag
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
- name: Set version from release tag
|
||||
if: ${{ env.IS_RELEASE_BUILD == 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
VERSION="${RELEASE_TAG#v}"
|
||||
pnpm pkg set version="$VERSION"
|
||||
|
||||
- name: Stage bundled runtime (Windows)
|
||||
|
|
@ -367,21 +475,39 @@ jobs:
|
|||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||
$tag = $env:GITHUB_REF.Replace('refs/tags/', '')
|
||||
if ($env:IS_RELEASE_BUILD -eq 'true') {
|
||||
$tag = $env:RELEASE_TAG
|
||||
node ./scripts/stage-runtime.mjs --platform win32-x64 --release-tag $tag
|
||||
} else {
|
||||
node ./scripts/stage-runtime.mjs --platform win32-x64
|
||||
}
|
||||
|
||||
- name: Verify Sentry release env (Windows)
|
||||
if: ${{ env.IS_RELEASE_BUILD == 'true' }}
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: node ./scripts/ci/verify-sentry-release.cjs prebuild
|
||||
|
||||
- name: Build app (Windows)
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }}
|
||||
SENTRY_AUTH_TOKEN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_AUTH_TOKEN || '' }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: pnpm build
|
||||
|
||||
- name: Verify Sentry source map upload (Windows)
|
||||
if: ${{ env.IS_RELEASE_BUILD == 'true' }}
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: quant-jump-pro
|
||||
SENTRY_PROJECT: electron
|
||||
run: pnpm build
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: node ./scripts/ci/verify-sentry-release.cjs postbuild
|
||||
|
||||
- name: Verify packaged inputs (Windows)
|
||||
shell: bash
|
||||
|
|
@ -406,23 +532,23 @@ jobs:
|
|||
run: node ./scripts/electron-builder/smokePackagedApp.cjs "release/win-unpacked" win32
|
||||
|
||||
- name: Upload assets to release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
if: ${{ env.IS_RELEASE_BUILD == 'true' }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
TAG="${RELEASE_TAG}"
|
||||
for f in release/*.exe release/*.blockmap; do
|
||||
[ -f "$f" ] || continue
|
||||
echo "Uploading: $f"
|
||||
gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber 2>&1 || \
|
||||
(sleep 5 && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber 2>&1) || \
|
||||
echo "WARNING: failed to upload $f, continuing..."
|
||||
(sleep 5 && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber 2>&1)
|
||||
done
|
||||
|
||||
release-linux:
|
||||
needs: [build, prepare-runtime]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
|
@ -457,10 +583,10 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Set version from tag
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
- name: Set version from release tag
|
||||
if: ${{ env.IS_RELEASE_BUILD == 'true' }}
|
||||
run: |
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
VERSION="${RELEASE_TAG#v}"
|
||||
pnpm pkg set version="$VERSION"
|
||||
|
||||
- name: Stage bundled runtime (Linux)
|
||||
|
|
@ -469,21 +595,39 @@ jobs:
|
|||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
if [[ "${IS_RELEASE_BUILD}" == "true" ]]; then
|
||||
TAG="${RELEASE_TAG}"
|
||||
node ./scripts/stage-runtime.mjs --platform linux-x64 --release-tag "$TAG"
|
||||
else
|
||||
node ./scripts/stage-runtime.mjs --platform linux-x64
|
||||
fi
|
||||
|
||||
- name: Verify Sentry release env (Linux)
|
||||
if: ${{ env.IS_RELEASE_BUILD == 'true' }}
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: node ./scripts/ci/verify-sentry-release.cjs prebuild
|
||||
|
||||
- name: Build app (Linux)
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
SENTRY_DSN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_DSN || '' }}
|
||||
SENTRY_AUTH_TOKEN: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && secrets.SENTRY_AUTH_TOKEN || '' }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: pnpm build
|
||||
|
||||
- name: Verify Sentry source map upload (Linux)
|
||||
if: ${{ env.IS_RELEASE_BUILD == 'true' }}
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: quant-jump-pro
|
||||
SENTRY_PROJECT: electron
|
||||
run: pnpm build
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: node ./scripts/ci/verify-sentry-release.cjs postbuild
|
||||
|
||||
- name: Verify packaged inputs (Linux)
|
||||
run: |
|
||||
|
|
@ -505,39 +649,39 @@ jobs:
|
|||
run: xvfb-run -a node ./scripts/electron-builder/smokePackagedApp.cjs "release/linux-unpacked" linux
|
||||
|
||||
- name: Upload assets to release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
if: ${{ env.IS_RELEASE_BUILD == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -x
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
TAG="${RELEASE_TAG}"
|
||||
ls -la release/ || true
|
||||
for f in release/*.AppImage release/*.deb release/*.rpm release/*.pacman release/*.blockmap; do
|
||||
[ -f "$f" ] || continue
|
||||
echo "Uploading: $f"
|
||||
gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber 2>&1 || \
|
||||
(sleep 5 && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber 2>&1) || \
|
||||
echo "WARNING: failed to upload $f, continuing..."
|
||||
(sleep 5 && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber 2>&1)
|
||||
done
|
||||
|
||||
upload-stable-links:
|
||||
needs: [release-mac, release-win, release-linux]
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
timeout-minutes: 30
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' }}
|
||||
|
||||
steps:
|
||||
- name: Upload stable-named assets for /latest/download links
|
||||
- name: Upload compatibility aliases for older updater clients
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
VERSION="${RELEASE_TAG#v}"
|
||||
TAG="${RELEASE_TAG}"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
DOWNLOAD_BASE="https://github.com/${REPO}/releases/download/v${VERSION}"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
declare -A FILES=(
|
||||
declare -A COMPATIBILITY_ALIASES=(
|
||||
["Claude-Agent-Teams-UI-arm64.dmg"]="Agent.Teams.AI-${VERSION}-arm64.dmg"
|
||||
["Claude-Agent-Teams-UI-x64.dmg"]="Agent.Teams.AI-${VERSION}-x64.dmg"
|
||||
["Claude-Agent-Teams-UI-Setup.exe"]="Agent.Teams.AI.Setup.${VERSION}.exe"
|
||||
|
|
@ -547,13 +691,16 @@ jobs:
|
|||
["Claude-Agent-Teams-UI.pacman"]="agent-teams-ai-${VERSION}.pacman"
|
||||
)
|
||||
|
||||
# Download versioned files and re-upload with stable names
|
||||
for STABLE_NAME in "${!FILES[@]}"; do
|
||||
VERSIONED_NAME="${FILES[$STABLE_NAME]}"
|
||||
echo "Downloading ${VERSIONED_NAME} -> ${STABLE_NAME}"
|
||||
curl -fSL -o "${TMP_DIR}/${VERSIONED_NAME}" "${DOWNLOAD_BASE}/${VERSIONED_NAME}"
|
||||
cp "${TMP_DIR}/${VERSIONED_NAME}" "${TMP_DIR}/${STABLE_NAME}"
|
||||
gh release upload "v${VERSION}" "${TMP_DIR}/${STABLE_NAME}" --repo "$REPO" --clobber
|
||||
for ALIAS_NAME in "${!COMPATIBILITY_ALIASES[@]}"; do
|
||||
VERSIONED_NAME="${COMPATIBILITY_ALIASES[$ALIAS_NAME]}"
|
||||
echo "Uploading compatibility alias: ${ALIAS_NAME} -> ${VERSIONED_NAME}"
|
||||
gh release download "${TAG}" \
|
||||
--repo "$REPO" \
|
||||
--pattern "${VERSIONED_NAME}" \
|
||||
--dir "$TMP_DIR" \
|
||||
--clobber
|
||||
cp "${TMP_DIR}/${VERSIONED_NAME}" "${TMP_DIR}/${ALIAS_NAME}"
|
||||
gh release upload "${TAG}" "${TMP_DIR}/${ALIAS_NAME}" --repo "$REPO" --clobber
|
||||
done
|
||||
|
||||
- name: Publish canonical updater metadata
|
||||
|
|
@ -561,8 +708,8 @@ jobs:
|
|||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
TAG="v${VERSION}"
|
||||
VERSION="${RELEASE_TAG#v}"
|
||||
TAG="${RELEASE_TAG}"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
RELEASE_DATE="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
|
|
@ -578,35 +725,41 @@ jobs:
|
|||
|
||||
download_asset() {
|
||||
local name="$1"
|
||||
curl -fSL -o "$name" "https://github.com/${REPO}/releases/download/${TAG}/${name}"
|
||||
gh release download "${TAG}" \
|
||||
--repo "${REPO}" \
|
||||
--pattern "$name" \
|
||||
--dir . \
|
||||
--clobber
|
||||
}
|
||||
|
||||
# Canonical Windows feed
|
||||
download_asset "Claude-Agent-Teams-UI-Setup.exe"
|
||||
WIN_SHA="$(sha512_base64 "Claude-Agent-Teams-UI-Setup.exe")"
|
||||
WIN_SIZE="$(file_size "Claude-Agent-Teams-UI-Setup.exe")"
|
||||
WIN_ASSET="Agent.Teams.AI.Setup.${VERSION}.exe"
|
||||
download_asset "${WIN_ASSET}"
|
||||
WIN_SHA="$(sha512_base64 "${WIN_ASSET}")"
|
||||
WIN_SIZE="$(file_size "${WIN_ASSET}")"
|
||||
cat > latest.yml <<EOF
|
||||
version: ${VERSION}
|
||||
files:
|
||||
- url: Claude-Agent-Teams-UI-Setup.exe
|
||||
- url: ${WIN_ASSET}
|
||||
sha512: ${WIN_SHA}
|
||||
size: ${WIN_SIZE}
|
||||
path: Claude-Agent-Teams-UI-Setup.exe
|
||||
path: ${WIN_ASSET}
|
||||
sha512: ${WIN_SHA}
|
||||
releaseDate: '${RELEASE_DATE}'
|
||||
EOF
|
||||
|
||||
# Canonical Linux feed
|
||||
download_asset "Claude-Agent-Teams-UI.AppImage"
|
||||
LINUX_SHA="$(sha512_base64 "Claude-Agent-Teams-UI.AppImage")"
|
||||
LINUX_SIZE="$(file_size "Claude-Agent-Teams-UI.AppImage")"
|
||||
LINUX_ASSET="Agent.Teams.AI-${VERSION}.AppImage"
|
||||
download_asset "${LINUX_ASSET}"
|
||||
LINUX_SHA="$(sha512_base64 "${LINUX_ASSET}")"
|
||||
LINUX_SIZE="$(file_size "${LINUX_ASSET}")"
|
||||
cat > latest-linux.yml <<EOF
|
||||
version: ${VERSION}
|
||||
files:
|
||||
- url: Claude-Agent-Teams-UI.AppImage
|
||||
- url: ${LINUX_ASSET}
|
||||
sha512: ${LINUX_SHA}
|
||||
size: ${LINUX_SIZE}
|
||||
path: Claude-Agent-Teams-UI.AppImage
|
||||
path: ${LINUX_ASSET}
|
||||
sha512: ${LINUX_SHA}
|
||||
releaseDate: '${RELEASE_DATE}'
|
||||
EOF
|
||||
|
|
@ -636,3 +789,16 @@ jobs:
|
|||
EOF
|
||||
|
||||
gh release upload "${TAG}" latest.yml latest-linux.yml latest-mac.yml --repo "${REPO}" --clobber
|
||||
|
||||
- name: Publish release
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') || inputs.publish_release }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${RELEASE_TAG}"
|
||||
gh release edit "${TAG}" --repo "${GITHUB_REPOSITORY}" --draft=false --latest
|
||||
|
||||
- name: Keep release as draft
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && !inputs.publish_release }}
|
||||
run: echo "Draft release ${RELEASE_TAG} is ready. It was not published because publish_release=false."
|
||||
|
|
|
|||
2
.gitignore
vendored
|
|
@ -8,6 +8,7 @@ dist-standalone/
|
|||
out/
|
||||
release/
|
||||
coverage/
|
||||
electron.vite.config.*.mjs
|
||||
|
||||
# Runtime release artifacts are downloaded during dev/release builds.
|
||||
# Keep only the placeholder directory in git.
|
||||
|
|
@ -65,3 +66,4 @@ remotion/*
|
|||
|
||||
# Local reference captures
|
||||
/agent-teams-reference-fix-*.png
|
||||
/.tmp-*
|
||||
|
|
|
|||
17
.mcp.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"playwright-electron": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@playwright/mcp@0.0.75",
|
||||
"--cdp-endpoint",
|
||||
"http://127.0.0.1:9222",
|
||||
"--caps",
|
||||
"devtools",
|
||||
"--console-level",
|
||||
"info"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
61
README.md
|
|
@ -176,29 +176,34 @@ For feature architecture and implementation guidance:
|
|||
|
||||
| Feature | Agent Teams | Gastown | Paperclip | Cursor | Claude Code CLI |
|
||||
|---|---|---|---|---|---|
|
||||
| **Cross-team communication** | ✅ Native cross-team messages | ⚠️ Cross-rig coordination | ⚠️ Company-scoped org work | N/A | ❌ |
|
||||
| **Cross-team communication** | ✅ Messages between separate teams | ⚠️ Coordination across groups | ⚠️ Company-scoped org work | N/A | ❌ |
|
||||
| **Agent-to-agent messaging** | ✅ Native real-time mailbox | ✅ Mailboxes + handoffs | ⚠️ Comments + @mentions | ❌ | ✅ Team mailbox, no UI |
|
||||
| **Linked tasks** | ✅ Cross-refs + dependencies | ⚠️ Beads deps + convoys | ✅ Goals, parents, blockers | ❌ | ✅ Shared task list |
|
||||
| **Session analysis** | ✅ Task logs + token tracking | ⚠️ Session recall, feed, OTEL | ⚠️ Run transcripts + cost audit | ❌ | ⚠️ Usage command, no UI |
|
||||
| **Linked tasks** | ✅ Tasks can link to and block each other | ⚠️ Task deps + grouped work | ✅ Goals, parent tasks, blockers | ❌ | ✅ Shared task list |
|
||||
| **Session analysis** | ✅ Task logs + token usage | ⚠️ Session recall, feed, metrics | ⚠️ Run transcripts + cost audit | ❌ | ⚠️ Usage command, no UI |
|
||||
| **Task attachments** | ✅ Auto-attach, agents read & attach files | ❌ Not task-level | ✅ Docs, attachments, work products | ⚠️ Chat session only | ⚠️ Chat images only |
|
||||
| **Hunk-level review** | ✅ Accept / reject individual hunks | ❌ | ❌ Bring your own review | ✅ | ❌ |
|
||||
| **Built-in code editor** | ✅ With Git support | ❌ | ❌ Control plane, not editor | ✅ Full IDE | ❌ |
|
||||
| **Full autonomy** | ✅ Agents create, assign, review tasks end-to-end | ✅ Mayor, convoys, recovery | ✅ Heartbeats + governance | ⚠️ Background agents, not teams | ✅ Experimental CLI teams |
|
||||
| **Task dependencies (blocked by)** | ✅ Guaranteed ordering | ✅ DAG waves via Beads | ✅ Blockers + execution locks | ❌ | ✅ Team task deps, no UI |
|
||||
| **Review workflow** | ✅ Agents review each other + human review UI | ⚠️ Refinery merge queue | ✅ Approvals + governance | ⚠️ PR/BugBot only | ✅ Team review, no UI |
|
||||
| **Zero setup** | ✅ Guided runtime setup | ❌ Go/Git/Dolt/Beads/tmux | ⚠️ `npx` + embedded Postgres | ✅ | ⚠️ CLI + env flag |
|
||||
| **Full autonomy** | ✅ Agents plan, assign, work, and review | ✅ Coordinator, grouped work, recovery | ✅ Wake-up runs + governance | ⚠️ Background agents, not teams | ✅ Experimental CLI teams |
|
||||
| **Task dependencies** | ✅ Tasks wait for blockers automatically | ✅ Dependency waves | ✅ Blockers + execution locks | ❌ | ✅ Team task deps, no UI |
|
||||
| **Review workflow** | ✅ Agents review each other + human review UI | ⚠️ Merge queue | ✅ Approvals + governance | ⚠️ PR/BugBot only | ✅ Team review, no UI |
|
||||
| **Zero setup** | ✅ Guided runtime setup | ❌ Manual CLI stack | ⚠️ `npx` + local database | ✅ | ⚠️ CLI + env flag |
|
||||
| **Kanban board** | ✅ 5 columns, real-time | ❌ Dashboard, not Kanban | ✅ 7 columns, drag-and-drop | ❌ | ❌ |
|
||||
| **Execution log viewer** | ✅ Tool calls, reasoning, timeline | ⚠️ Feed, OTEL, dashboard | ✅ Run transcripts + ledger | ⚠️ Agent chat + terminal | ❌ |
|
||||
| **Execution logs** | ✅ Tool calls, reasoning, timeline | ⚠️ Feed, metrics, dashboard | ✅ Run transcripts + audit log | ⚠️ Agent chat + terminal | ❌ |
|
||||
| **Live processes** | ✅ View, stop, open URLs in browser | ⚠️ Agent health dashboard | ⚠️ Manual services + previews | ⚠️ Native terminal only | ❌ |
|
||||
| **CPU/RAM per teammate** | ✅ See CPU/RAM history for each live teammate | ⚠️ Shows activity/health, not CPU/RAM | ⚠️ Shows run status/cost, not CPU/RAM | ❌ Remote agent/terminal only | ❌ |
|
||||
| **Per-task code review** | ✅ Accept / reject / comment | ⚠️ Merge queue, no diff UI | ⚠️ PR/work products, no inline diff | ✅ BugBot on PRs | ❌ |
|
||||
| **Flexible autonomy** | ✅ Per-action approvals + notifications | ✅ Gates, escalation, recovery | ✅ Board approvals, pause, terminate | ⚠️ BG agents auto-run commands | ✅ Permissions + hooks |
|
||||
| **Flexible autonomy** | ✅ Per-action approvals + notifications | ✅ Gates, escalation, recovery | ✅ Board approvals, pause, terminate | ⚠️ Background agents auto-run commands | ✅ Permissions + hooks |
|
||||
| **Git worktree isolation** | ✅ Optional | ✅ Core primitive | ✅ Worktrees / branches | ⚠️ Background branches/VMs | ⚠️ Manual worktrees |
|
||||
| **Multi-agent backend** | ✅ Claude, Codex + OpenCode teammates | ✅ Claude, Codex, Gemini, Copilot + more | ✅ BYO agents: Claude, Codex, Cursor/OpenCode, HTTP | ⚠️ Multi-model agents, no team backend | ⚠️ Claude-only experimental teams |
|
||||
| **Mixed AI teammates** | ✅ Claude, Codex, and OpenCode in one team | ✅ Many providers, terminal-first | ✅ Bring your own agents/runtimes | ⚠️ Multi-model agents, no shared team | ⚠️ Claude-only experimental teams |
|
||||
| **Live team map** | ✅ Map of teammates, tasks, blockers, handoffs, activity, logs | ⚠️ Agent tree + feed panels | ⚠️ Org chart/status, not a task/log map | ❌ | ❌ |
|
||||
| **Live teammates** | ✅ Watch teammates work and message them directly | ⚠️ Terminal-based agent sessions | ⚠️ Agents wake up for runs, then sleep | ⚠️ Background agents per task | ⚠️ CLI teams, no desktop view |
|
||||
| **Team workspace** | ✅ Tasks, logs, Kanban, review, and teammates in one app | ⚠️ Mail/feed/dashboard across tools | ⚠️ Board + transcripts, less live teammate view | ⚠️ IDE chats/tasks, not team view | ❌ No desktop UI |
|
||||
| **Teammate launch status** | ✅ Know who started, who is stuck, and who replied | ⚠️ Session health, less clear message status | ⚠️ Run status, not live teammate status | ❌ | ⚠️ CLI mailbox, no visual status |
|
||||
| **Org chart / governance** | ⚠️ Roles + approvals, no org chart | ⚠️ Roles + escalation | ✅ Org chart + board governance | ⚠️ Team admin only | ❌ |
|
||||
| **Budget controls** | ⚠️ Cost/token visibility, no hard caps | ⚠️ Cost tiers + digest, no hard caps | ✅ Per-agent budgets + hard stops | ⚠️ Usage + BG spend limits | ⚠️ `/usage` + workspace limits |
|
||||
| **Price** | **Free OSS UI**, provider access needed | Free OSS, runtime plans needed | Free OSS, self-hosted + infra | Free + paid usage | Claude plan or API usage |
|
||||
|
||||
Fact sources checked on May 16, 2026: [detailed research notes](docs/research/gastown-paperclip-comparison-2026-05-16.md), [Gastown README](https://github.com/gastownhall/gastown), [Gastown provider guide](https://github.com/gastownhall/gastown/blob/main/docs/agent-provider-integration.md), [Gastown scheduler](https://github.com/gastownhall/gastown/blob/main/docs/design/scheduler.md), [Gastown release](https://github.com/gastownhall/gastown/releases/tag/v1.1.0), [Paperclip README](https://github.com/paperclipai/paperclip), [Paperclip adapters](https://github.com/paperclipai/paperclip/blob/master/docs/adapters/overview.md), [Paperclip budgets](https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/costs-and-budgets.md), [Paperclip runtime services](https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/execution-workspaces-and-runtime-services.md), [Paperclip Kanban source](https://github.com/paperclipai/paperclip/blob/master/ui/src/components/KanbanBoard.tsx), [Paperclip work products](https://github.com/paperclipai/paperclip/blob/master/packages/shared/src/validators/work-product.ts), [Paperclip release](https://github.com/paperclipai/paperclip/releases/tag/v2026.513.0), [Cursor Background Agents](https://docs.cursor.com/en/background-agents), [Cursor Diffs & Review](https://docs.cursor.com/en/agent/review), [Cursor Bugbot](https://docs.cursor.com/en/bugbot), [Cursor pricing](https://docs.cursor.com/en/account/usage), [Claude Code agent teams](https://code.claude.com/docs/en/agent-teams), [Claude Code subagents](https://code.claude.com/docs/en/sub-agents), [Claude Code workflows](https://code.claude.com/docs/en/common-workflows), [Claude Code costs](https://code.claude.com/docs/en/costs), [Claude pricing](https://claude.com/pricing).
|
||||
Fact sources checked on May 18, 2026: [detailed research notes](docs/research/gastown-paperclip-comparison-2026-05-16.md), [Gastown README](https://github.com/gastownhall/gastown), [Gastown provider guide](https://github.com/gastownhall/gastown/blob/main/docs/agent-provider-integration.md), [Gastown scheduler](https://github.com/gastownhall/gastown/blob/main/docs/design/scheduler.md), [Gastown dashboard source](https://github.com/gastownhall/gastown/blob/main/internal/web/templates/convoy.html), [Gastown release](https://github.com/gastownhall/gastown/releases/tag/v1.1.0), [Paperclip README](https://github.com/paperclipai/paperclip), [Paperclip adapters](https://github.com/paperclipai/paperclip/blob/master/docs/adapters/overview.md), [Paperclip heartbeat protocol](https://github.com/paperclipai/paperclip/blob/master/docs/guides/agent-developer/heartbeat-protocol.md), [Paperclip org chart](https://paperclip.inc/docs/guides/board-operator/org-structure/), [Paperclip OrgChart source](https://github.com/paperclipai/paperclip/blob/master/ui/src/pages/OrgChart.tsx), [Paperclip budgets](https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/costs-and-budgets.md), [Paperclip runtime services](https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/execution-workspaces-and-runtime-services.md), [Paperclip Kanban source](https://github.com/paperclipai/paperclip/blob/master/ui/src/components/KanbanBoard.tsx), [Paperclip work products](https://github.com/paperclipai/paperclip/blob/master/packages/shared/src/validators/work-product.ts), [Paperclip release](https://github.com/paperclipai/paperclip/releases/tag/v2026.517.0), [Cursor Background Agents](https://docs.cursor.com/en/background-agents), [Cursor Diffs & Review](https://docs.cursor.com/en/agent/review), [Cursor Bugbot](https://docs.cursor.com/en/bugbot), [Cursor pricing](https://docs.cursor.com/en/account/usage), [Claude Code agent teams](https://code.claude.com/docs/en/agent-teams), [Claude Code subagents](https://code.claude.com/docs/en/sub-agents), [Claude Code workflows](https://code.claude.com/docs/en/common-workflows), [Claude Code costs](https://code.claude.com/docs/en/costs), [Claude pricing](https://claude.com/pricing).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -282,6 +287,9 @@ pnpm dev
|
|||
|
||||
`pnpm dev` starts the desktop Electron app. Do not start a browser/web dev server for normal development; that path is limited and is not the supported way to run agent teams locally.
|
||||
|
||||
Use `pnpm dev:mcp` when you want an MCP browser/debugging tool to attach to the current
|
||||
Electron renderer through the local Chrome DevTools Protocol endpoint on `127.0.0.1:9222`.
|
||||
|
||||
The desktop app auto-discovers Claude Code projects from `~/.claude/`.
|
||||
|
||||
### Debug teammate runtimes
|
||||
|
|
@ -313,21 +321,22 @@ local packaging.
|
|||
|
||||
### Scripts
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `pnpm dev` | Desktop app development with hot reload |
|
||||
| `pnpm build` | Production build |
|
||||
| `pnpm typecheck` | TypeScript type checking |
|
||||
| `pnpm lint` | Lint (no auto-fix) |
|
||||
| `pnpm lint:fix` | Lint and auto-fix |
|
||||
| `pnpm format` | Format code with Prettier |
|
||||
| `pnpm test` | Run all tests |
|
||||
| `pnpm test:watch` | Watch mode |
|
||||
| `pnpm test:coverage` | Coverage report |
|
||||
| `pnpm test:coverage:critical` | Critical path coverage |
|
||||
| `pnpm check` | Full quality gate (types + lint + test + build) |
|
||||
| `pnpm fix` | Lint fix + format |
|
||||
| `pnpm quality` | Full check + format check + knip |
|
||||
| Command | Description |
|
||||
| ----------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `pnpm dev` | Desktop app development with hot reload |
|
||||
| `pnpm dev:mcp` | Desktop app development with hot reload and local CDP debugging on port 9222 |
|
||||
| `pnpm build` | Production build |
|
||||
| `pnpm typecheck` | TypeScript type checking |
|
||||
| `pnpm lint` | Lint (no auto-fix) |
|
||||
| `pnpm lint:fix` | Lint and auto-fix |
|
||||
| `pnpm format` | Format code with Prettier |
|
||||
| `pnpm test` | Run all tests |
|
||||
| `pnpm test:watch` | Watch mode |
|
||||
| `pnpm test:coverage` | Coverage report |
|
||||
| `pnpm test:coverage:critical` | Critical path coverage |
|
||||
| `pnpm check` | Full quality gate (types + lint + test + build) |
|
||||
| `pnpm fix` | Lint fix + format |
|
||||
| `pnpm quality` | Full check + format check + knip |
|
||||
|
||||
</details>
|
||||
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 1.2 MiB |
12
docs/team-management/member-runtime-telemetry-reference.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Member Runtime Telemetry Reference
|
||||
|
||||
Design reference for participant-card runtime telemetry:
|
||||
|
||||

|
||||
|
||||
Chosen direction: Variant B.
|
||||
|
||||
- Memory history renders as a subtle green filled micro-area at the bottom of each member row.
|
||||
- CPU history renders as a thin blue line immediately above the memory band.
|
||||
- The strip stays behind row content and uses low contrast so member names, model labels, task pills, and icons remain readable.
|
||||
- Runtime history is owned by the main process and attached to `TeamAgentRuntimeSnapshot`, not accumulated in React components.
|
||||
3068
docs/team-management/opencode-snapshot-first-proof-upgrade-plan.md
Normal file
|
|
@ -44,30 +44,42 @@ function nativeModuleStub(): Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
// Sentry source map upload — only active in CI when SENTRY_AUTH_TOKEN is set.
|
||||
const sentryPlugins = process.env.SENTRY_AUTH_TOKEN
|
||||
? [
|
||||
sentryVitePlugin({
|
||||
org: process.env.SENTRY_ORG ?? 'quant-jump-pro',
|
||||
project: process.env.SENTRY_PROJECT ?? 'electron',
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
release: { name: `agent-teams-ai@${pkg.version}` },
|
||||
sourcemaps: {
|
||||
filesToDeleteAfterUpload: ['./out/renderer/**/*.map', './dist-electron/**/*.map'],
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []
|
||||
const sentrySourceMapTargets = {
|
||||
main: {
|
||||
assets: ['./dist-electron/main/**/*.{js,cjs,mjs,map}'],
|
||||
filesToDeleteAfterUpload: ['./dist-electron/main/**/*.map'],
|
||||
},
|
||||
renderer: {
|
||||
assets: ['./out/renderer/**/*.{js,cjs,mjs,map}'],
|
||||
filesToDeleteAfterUpload: ['./out/renderer/**/*.map'],
|
||||
},
|
||||
} as const
|
||||
|
||||
// Sentry source map upload - only active in CI when SENTRY_AUTH_TOKEN is set.
|
||||
function createSentryPlugins(target: keyof typeof sentrySourceMapTargets): Plugin[] {
|
||||
if (!process.env.SENTRY_AUTH_TOKEN) return []
|
||||
|
||||
return [
|
||||
sentryVitePlugin({
|
||||
org: process.env.SENTRY_ORG ?? 'quant-jump-pro',
|
||||
project: process.env.SENTRY_PROJECT ?? 'electron',
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
telemetry: false,
|
||||
release: { name: `agent-teams-ai@${pkg.version}` },
|
||||
sourcemaps: sentrySourceMapTargets[target],
|
||||
}) as Plugin,
|
||||
]
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [
|
||||
nativeModuleStub(),
|
||||
...sentryPlugins,
|
||||
...createSentryPlugins('main'),
|
||||
],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
// Inject DSN at compile time — process.env.SENTRY_DSN is NOT available
|
||||
// Inject DSN at compile time - process.env.SENTRY_DSN is NOT available
|
||||
// at runtime in packaged Electron apps (only during CI build).
|
||||
'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN ?? ''),
|
||||
},
|
||||
|
|
@ -148,10 +160,14 @@ export default defineConfig({
|
|||
'@renderer': resolve(__dirname, 'src/renderer'),
|
||||
'@shared': resolve(__dirname, 'src/shared'),
|
||||
'@main': resolve(__dirname, 'src/main'),
|
||||
'@radix-ui/react-compose-refs': resolve(
|
||||
__dirname,
|
||||
'src/renderer/vendor/radixComposeRefs.ts'
|
||||
),
|
||||
'@claude-teams/agent-graph': resolve(__dirname, 'packages/agent-graph/src/index.ts')
|
||||
}
|
||||
},
|
||||
plugins: [react(), ...sentryPlugins],
|
||||
plugins: [react(), ...createSentryPlugins('renderer')],
|
||||
build: {
|
||||
sourcemap: 'hidden',
|
||||
rollupOptions: {
|
||||
|
|
|
|||
2
landing/.gitignore
vendored
|
|
@ -3,6 +3,8 @@ node_modules
|
|||
.output
|
||||
.dist
|
||||
.env
|
||||
--host/
|
||||
product-docs/.vitepress/dist/
|
||||
|
||||
# Large video files
|
||||
public/video/*.mp4
|
||||
|
|
|
|||
BIN
landing/assets/images/footer/robot-lead-lounge-v1.webp
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
landing/assets/images/hero/robots/robot-avatar-amber-v1.webp
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
landing/assets/images/hero/robots/robot-avatar-cyan-cat-v1.webp
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
landing/assets/images/hero/robots/robot-avatar-cyan-v1.webp
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
landing/assets/images/hero/robots/robot-avatar-magenta-v1.webp
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
landing/assets/images/hero/robots/robot-avatar-red-flame-v1.webp
Normal file
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 213 KiB |
BIN
landing/assets/images/hero/robots/robot-seated-magenta-v1.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
landing/assets/images/hero/robots/robot-seated-magenta-v1.webp
Normal file
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 570 KiB |
|
|
@ -3,7 +3,7 @@ const { baseURL } = useRuntimeConfig().app;
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink to="/" class="app-logo">
|
||||
<NuxtLink to="/" class="app-logo" :prefetch="false">
|
||||
<img
|
||||
:src="`${baseURL}logo-192.png`"
|
||||
alt="Agent Teams"
|
||||
|
|
|
|||
105
landing/components/common/RobotSpeechBubble.vue
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<script setup lang="ts">
|
||||
type RobotSpeechBubbleTail = "down" | "right";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
tail?: RobotSpeechBubbleTail;
|
||||
}>(), {
|
||||
tail: "down",
|
||||
});
|
||||
|
||||
const bubblePath = computed(() => {
|
||||
if (props.tail === "right") {
|
||||
return "M18 6H79C94 6 104 16 104 30C104 32 104 34 103 35L118 35L99 44C94 50 87 53 79 53H18C9 53 4 44 4 30C4 16 9 6 18 6Z";
|
||||
}
|
||||
|
||||
return "M18 6H76C94 6 108 16 108 30C108 44 94 52 78 52H65L76 66L48 52H18C9 52 4 43 4 29C4 15 9 6 18 6Z";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="robot-speech-bubble"
|
||||
:class="`robot-speech-bubble--tail-${tail}`"
|
||||
>
|
||||
<svg
|
||||
class="robot-speech-bubble__shape"
|
||||
viewBox="0 0 120 70"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
class="robot-speech-bubble__fill"
|
||||
:d="bubblePath"
|
||||
/>
|
||||
</svg>
|
||||
<span class="robot-speech-bubble__text">
|
||||
<slot />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.robot-speech-bubble {
|
||||
position: var(--robot-bubble-position, relative);
|
||||
z-index: var(--robot-bubble-z-index, auto);
|
||||
display: inline-grid;
|
||||
width: max-content;
|
||||
inline-size: max-content;
|
||||
min-width: var(--robot-bubble-min-width, 86px);
|
||||
max-width: var(--robot-bubble-max-width, 184px);
|
||||
max-inline-size: var(--robot-bubble-max-width, 184px);
|
||||
min-height: var(--robot-bubble-min-height, 42px);
|
||||
box-sizing: border-box;
|
||||
color: var(--robot-bubble-color, #07111d);
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: var(--robot-bubble-font-size, 0.66rem);
|
||||
font-weight: 900;
|
||||
line-height: 1.05;
|
||||
letter-spacing: 0;
|
||||
text-shadow: 1px 1px 0 rgba(255, 255, 255, 0.62);
|
||||
pointer-events: none;
|
||||
filter:
|
||||
drop-shadow(0 3px 0 rgba(0, 0, 0, 0.18))
|
||||
drop-shadow(0 0 11px rgba(255, 215, 0, 0.16));
|
||||
}
|
||||
|
||||
.robot-speech-bubble__shape {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.robot-speech-bubble__fill {
|
||||
fill: var(--robot-bubble-fill, #fff09a);
|
||||
stroke: var(--robot-bubble-stroke, #050816);
|
||||
stroke-width: var(--robot-bubble-stroke-width, 4.8);
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.robot-speech-bubble__text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
align-self: center;
|
||||
justify-self: stretch;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: var(--robot-bubble-padding, 8px 16px 16px);
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
word-break: normal;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
text-wrap: normal;
|
||||
}
|
||||
|
||||
.robot-speech-bubble--tail-right .robot-speech-bubble__text {
|
||||
padding: var(--robot-bubble-padding, 8px 24px 8px 13px);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -6,10 +6,11 @@ const { isDark, toggleTheme } = useBrowserTheme();
|
|||
const { trackThemeToggle } = useAnalytics();
|
||||
|
||||
const tooltip = computed(() => isDark.value ? t('theme.light') : t('theme.dark'));
|
||||
const icon = computed(() => isDark.value ? mdiWeatherNight : mdiWeatherSunny);
|
||||
|
||||
const onToggle = () => {
|
||||
toggleTheme();
|
||||
trackThemeToggle(isDark.value ? 'dark' : 'light');
|
||||
const theme = toggleTheme();
|
||||
trackThemeToggle(theme);
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
@ -19,7 +20,7 @@ const onToggle = () => {
|
|||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
:icon="isDark ? mdiWeatherSunny : mdiWeatherNight"
|
||||
:icon="icon"
|
||||
variant="text"
|
||||
size="small"
|
||||
:aria-label="tooltip"
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { HERO_SCENE_VIEWBOX, type HeroConnection } from "~/data/heroScene";
|
||||
|
||||
defineProps<{
|
||||
connections: readonly HeroConnection[];
|
||||
activeConnectionId?: string | null;
|
||||
reducedMotion?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="cyber-connectors"
|
||||
:viewBox="`0 0 ${HERO_SCENE_VIEWBOX.width} ${HERO_SCENE_VIEWBOX.height}`"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g class="cyber-connectors__paths">
|
||||
<template v-for="connection in connections" :key="connection.id">
|
||||
<path
|
||||
class="cyber-connectors__path-glow"
|
||||
:class="[
|
||||
`cyber-connectors__path-glow--${connection.accent}`,
|
||||
{ 'cyber-connectors__path-glow--active': activeConnectionId === connection.id },
|
||||
]"
|
||||
:d="connection.pathDesktop"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
<path
|
||||
:id="`cyber-path-${connection.id}`"
|
||||
class="cyber-connectors__path"
|
||||
:class="[
|
||||
`cyber-connectors__path--${connection.accent}`,
|
||||
{ 'cyber-connectors__path--active': activeConnectionId === connection.id },
|
||||
]"
|
||||
:d="connection.pathDesktop"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
</template>
|
||||
</g>
|
||||
|
||||
<g v-if="!reducedMotion" class="cyber-connectors__packets">
|
||||
<circle
|
||||
v-for="connection in connections"
|
||||
:key="`packet-${connection.id}`"
|
||||
class="cyber-connectors__packet"
|
||||
:class="[
|
||||
`cyber-connectors__packet--${connection.accent}`,
|
||||
{ 'cyber-connectors__packet--active': activeConnectionId === connection.id },
|
||||
]"
|
||||
r="4"
|
||||
>
|
||||
<animateMotion
|
||||
:dur="`${connection.packetDurationMs}ms`"
|
||||
repeatCount="indefinite"
|
||||
:begin="`${connection.packetDelayMs}ms`"
|
||||
>
|
||||
<mpath :href="`#cyber-path-${connection.id}`" />
|
||||
</animateMotion>
|
||||
</circle>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
|
@ -6,7 +6,19 @@ import {
|
|||
mdiShieldCheckOutline,
|
||||
mdiMonitorDashboard,
|
||||
} from "@mdi/js";
|
||||
import { heroFeatureRail } from "~/data/heroScene";
|
||||
import {
|
||||
heroCollaborationFeature,
|
||||
heroFeatureRail,
|
||||
heroReviewerFeatureCard,
|
||||
type HeroMessage,
|
||||
type HeroMessagePhase,
|
||||
} from "~/data/heroScene";
|
||||
|
||||
const props = defineProps<{
|
||||
activeMessage?: HeroMessage | null;
|
||||
phase?: HeroMessagePhase;
|
||||
reducedMotion?: boolean;
|
||||
}>();
|
||||
|
||||
const icons = [
|
||||
mdiRobotOutline,
|
||||
|
|
@ -15,21 +27,85 @@ const icons = [
|
|||
mdiShieldCheckOutline,
|
||||
mdiMonitorDashboard,
|
||||
] as const;
|
||||
|
||||
const reviewerIsSender = computed(() =>
|
||||
props.activeMessage?.from === "reviewer" && props.phase !== "cooldown",
|
||||
);
|
||||
const reviewerIsReceiver = computed(() =>
|
||||
props.activeMessage?.to === "reviewer" && props.phase === "receiver",
|
||||
);
|
||||
const reviewerIsActive = computed(() => reviewerIsSender.value || reviewerIsReceiver.value);
|
||||
const reviewerBubbleText = computed(() => {
|
||||
if (!props.activeMessage || props.reducedMotion) return null;
|
||||
if (props.activeMessage.from === "reviewer" && (props.phase === "sender" || props.phase === "packet")) {
|
||||
return props.activeMessage.text;
|
||||
}
|
||||
if (props.activeMessage.to === "reviewer" && props.phase === "receiver") {
|
||||
return props.activeMessage.response;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cyber-feature-rail cyber-panel">
|
||||
<div
|
||||
v-for="(feature, index) in heroFeatureRail"
|
||||
:key="feature.id"
|
||||
class="cyber-feature-rail__item"
|
||||
<div class="cyber-feature-rail-shell">
|
||||
<img
|
||||
class="cyber-feature-rail__collaboration"
|
||||
:src="heroCollaborationFeature.asset"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="cyber-feature-rail__icon">
|
||||
<v-icon :icon="icons[index]" size="28" />
|
||||
<div
|
||||
class="cyber-feature-rail__reviewer"
|
||||
:class="{
|
||||
'cyber-feature-rail__reviewer--active': reviewerIsActive,
|
||||
'cyber-feature-rail__reviewer--sending': reviewerIsSender,
|
||||
'cyber-feature-rail__reviewer--receiving': reviewerIsReceiver,
|
||||
}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Transition name="cyber-feature-bubble">
|
||||
<RobotSpeechBubble
|
||||
v-if="reviewerBubbleText"
|
||||
class="cyber-feature-rail__reviewer-bubble"
|
||||
tail="down"
|
||||
>
|
||||
{{ reviewerBubbleText }}
|
||||
</RobotSpeechBubble>
|
||||
</Transition>
|
||||
<div class="cyber-feature-rail__reviewer-card cyber-panel">
|
||||
<div class="cyber-feature-rail__reviewer-label">{{ heroReviewerFeatureCard.label }}</div>
|
||||
<ul class="cyber-feature-rail__reviewer-tasks">
|
||||
<li v-for="task in heroReviewerFeatureCard.tasks" :key="task">{{ task }}</li>
|
||||
</ul>
|
||||
<div class="cyber-feature-rail__reviewer-status">
|
||||
<span>Status:</span>
|
||||
<strong>{{ heroReviewerFeatureCard.status }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cyber-feature-rail__copy">
|
||||
<div class="cyber-feature-rail__title">{{ feature.title }}</div>
|
||||
<div class="cyber-feature-rail__text">{{ feature.text }}</div>
|
||||
<img
|
||||
class="cyber-feature-rail__robot"
|
||||
:src="heroReviewerFeatureCard.asset"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
>
|
||||
</div>
|
||||
<div class="cyber-feature-rail cyber-panel">
|
||||
<div
|
||||
v-for="(feature, index) in heroFeatureRail"
|
||||
:key="feature.id"
|
||||
class="cyber-feature-rail__item"
|
||||
>
|
||||
<div class="cyber-feature-rail__icon">
|
||||
<v-icon :icon="icons[index]" size="28" />
|
||||
</div>
|
||||
<div class="cyber-feature-rail__copy">
|
||||
<div class="cyber-feature-rail__title">{{ feature.title }}</div>
|
||||
<div class="cyber-feature-rail__text">{{ feature.text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import type { HeroMessage } from "~/data/heroScene";
|
||||
import type { HeroMessage, HeroMessagePhase } from "~/data/heroScene";
|
||||
|
||||
const props = defineProps<{
|
||||
message: HeroMessage | null;
|
||||
phase: "sender" | "packet" | "receiver" | "cooldown";
|
||||
phase: HeroMessagePhase;
|
||||
reducedMotion?: boolean;
|
||||
}>();
|
||||
|
||||
|
|
@ -17,34 +17,40 @@ const receiverStyle = computed(() => ({
|
|||
"--bubble-y": props.message ? String(props.message.toY) : "0",
|
||||
}));
|
||||
|
||||
const showSender = computed(() => props.message && (props.phase === "sender" || props.phase === "packet"));
|
||||
const showReceiver = computed(() => props.message && props.phase === "receiver");
|
||||
const showSender = computed(() =>
|
||||
props.message && props.message.from !== "reviewer" && (props.phase === "sender" || props.phase === "packet"),
|
||||
);
|
||||
const showReceiver = computed(() =>
|
||||
props.message && props.message.to !== "reviewer" && props.phase === "receiver",
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cyber-messages" aria-hidden="true">
|
||||
<Transition name="cyber-bubble">
|
||||
<div
|
||||
<CyberHeroSpeechBubble
|
||||
v-if="showSender && message && !reducedMotion"
|
||||
class="cyber-message cyber-message--sender cyber-panel"
|
||||
:style="senderStyle"
|
||||
variant="sender"
|
||||
:role="message.from"
|
||||
:bubble-style="senderStyle"
|
||||
>
|
||||
{{ message.text }}
|
||||
</div>
|
||||
</CyberHeroSpeechBubble>
|
||||
</Transition>
|
||||
|
||||
<Transition name="cyber-bubble">
|
||||
<div
|
||||
<CyberHeroSpeechBubble
|
||||
v-if="showReceiver && message && !reducedMotion"
|
||||
class="cyber-message cyber-message--receiver cyber-panel"
|
||||
:style="receiverStyle"
|
||||
variant="receiver"
|
||||
:role="message.to"
|
||||
:bubble-style="receiverStyle"
|
||||
>
|
||||
{{ message.response }}
|
||||
</div>
|
||||
</CyberHeroSpeechBubble>
|
||||
</Transition>
|
||||
|
||||
<div v-if="reducedMotion" class="cyber-message cyber-message--static cyber-panel">
|
||||
<CyberHeroSpeechBubble v-if="reducedMotion" class="cyber-panel" variant="static">
|
||||
Agents coordinate work automatically.
|
||||
</div>
|
||||
</CyberHeroSpeechBubble>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
174
landing/components/hero/CyberHeroMontereyBackground.vue
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
<script setup lang="ts">
|
||||
import type { NeatConfig, NeatController } from "@firecms/neat";
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const isLive = ref(false);
|
||||
|
||||
let gradient: NeatController | null = null;
|
||||
let heroObserver: IntersectionObserver | null = null;
|
||||
let motionQuery: MediaQueryList | null = null;
|
||||
let mobileQuery: MediaQueryList | null = null;
|
||||
let isVisible = false;
|
||||
let isInitializing = false;
|
||||
let initToken = 0;
|
||||
let revealTimer: number | null = null;
|
||||
|
||||
const montereyConfig: NeatConfig = {
|
||||
colors: [
|
||||
{ color: "#130437", enabled: true },
|
||||
{ color: "#B34BD0", enabled: true },
|
||||
{ color: "#210751", enabled: true },
|
||||
{ color: "#3511A5", enabled: true },
|
||||
{ color: "#8F3E8D", enabled: false },
|
||||
{ color: "#FF9A9E", enabled: false },
|
||||
],
|
||||
speed: 4.8,
|
||||
horizontalPressure: 7,
|
||||
verticalPressure: 3,
|
||||
waveFrequencyX: 0,
|
||||
waveFrequencyY: 0,
|
||||
waveAmplitude: 0,
|
||||
shadows: 4,
|
||||
highlights: 0,
|
||||
colorBrightness: 1.92,
|
||||
colorSaturation: 2.18,
|
||||
wireframe: false,
|
||||
colorBlending: 9,
|
||||
backgroundColor: "#030012",
|
||||
backgroundAlpha: 1,
|
||||
grainScale: 6,
|
||||
grainSparsity: 0,
|
||||
grainIntensity: 0.1,
|
||||
grainSpeed: 0,
|
||||
resolution: 0.32,
|
||||
yOffset: 150,
|
||||
flowDistortionA: 0.4,
|
||||
flowDistortionB: 10,
|
||||
flowScale: 3.3,
|
||||
flowEase: 0.37,
|
||||
enableProceduralTexture: false,
|
||||
textureVoidLikelihood: 0.06,
|
||||
textureVoidWidthMin: 10,
|
||||
textureVoidWidthMax: 500,
|
||||
textureBandDensity: 0.8,
|
||||
textureColorBlending: 0.06,
|
||||
textureSeed: 333,
|
||||
textureEase: 0.38,
|
||||
proceduralBackgroundColor: "#003FFF",
|
||||
textureShapeTriangles: 20,
|
||||
textureShapeCircles: 15,
|
||||
textureShapeBars: 15,
|
||||
textureShapeSquiggles: 10,
|
||||
yOffsetWaveMultiplier: 4.5,
|
||||
yOffsetColorMultiplier: 4.8,
|
||||
yOffsetFlowMultiplier: 5.2,
|
||||
flowEnabled: true,
|
||||
};
|
||||
|
||||
function supportsWebGl() {
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("webgl2") || canvas.getContext("webgl");
|
||||
const isSupported = Boolean(context);
|
||||
context?.getExtension("WEBGL_lose_context")?.loseContext();
|
||||
return isSupported;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUseLiveGradient() {
|
||||
return Boolean(
|
||||
canvasRef.value &&
|
||||
isVisible &&
|
||||
!motionQuery?.matches &&
|
||||
!mobileQuery?.matches &&
|
||||
supportsWebGl(),
|
||||
);
|
||||
}
|
||||
|
||||
function destroyGradient() {
|
||||
initToken += 1;
|
||||
if (revealTimer !== null) {
|
||||
window.clearTimeout(revealTimer);
|
||||
revealTimer = null;
|
||||
}
|
||||
gradient?.destroy();
|
||||
gradient = null;
|
||||
isLive.value = false;
|
||||
}
|
||||
|
||||
async function initGradient() {
|
||||
if (gradient || isInitializing || !shouldUseLiveGradient()) return;
|
||||
|
||||
const token = initToken;
|
||||
isInitializing = true;
|
||||
|
||||
try {
|
||||
const { NeatGradient } = await import("@firecms/neat");
|
||||
|
||||
if (token !== initToken || !canvasRef.value || !shouldUseLiveGradient()) return;
|
||||
|
||||
gradient = new NeatGradient({
|
||||
ref: canvasRef.value,
|
||||
...montereyConfig,
|
||||
resolution: window.devicePixelRatio > 1 ? 0.24 : 0.34,
|
||||
});
|
||||
revealTimer = window.setTimeout(() => {
|
||||
revealTimer = null;
|
||||
if (token === initToken && gradient && shouldUseLiveGradient()) {
|
||||
isLive.value = true;
|
||||
}
|
||||
}, 180);
|
||||
} catch (error) {
|
||||
console.warn("Monterey hero background is unavailable", error);
|
||||
destroyGradient();
|
||||
} finally {
|
||||
isInitializing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function syncGradient() {
|
||||
if (shouldUseLiveGradient()) {
|
||||
void initGradient();
|
||||
return;
|
||||
}
|
||||
|
||||
destroyGradient();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
mobileQuery = window.matchMedia("(max-width: 700px)");
|
||||
motionQuery.addEventListener("change", syncGradient);
|
||||
mobileQuery.addEventListener("change", syncGradient);
|
||||
|
||||
heroObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
isVisible = Boolean(entry?.isIntersecting);
|
||||
syncGradient();
|
||||
},
|
||||
{ rootMargin: "160px 0px", threshold: 0.01 },
|
||||
);
|
||||
|
||||
const target = canvasRef.value?.closest(".cyber-hero");
|
||||
if (target) heroObserver.observe(target);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
heroObserver?.disconnect();
|
||||
motionQuery?.removeEventListener("change", syncGradient);
|
||||
mobileQuery?.removeEventListener("change", syncGradient);
|
||||
destroyGradient();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="cyber-hero__monterey"
|
||||
:class="{ 'cyber-hero__monterey--live': isLive }"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<canvas ref="canvasRef" class="cyber-hero__monterey-canvas" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -17,6 +17,8 @@ const rootStyle = computed(() => ({
|
|||
"--agent-y": String(props.agent.desktop.y),
|
||||
"--agent-scale": String(props.agent.desktop.scale),
|
||||
"--agent-depth": String(props.agent.desktop.depth),
|
||||
"--agent-face": String(props.agent.facing ?? 1),
|
||||
"--agent-lean": `${props.agent.lean ?? 0}deg`,
|
||||
"--agent-tablet-x": String(props.agent.tablet.x),
|
||||
"--agent-tablet-y": String(props.agent.tablet.y),
|
||||
"--agent-tablet-scale": String(props.agent.tablet.scale),
|
||||
|
|
|
|||
|
|
@ -1,111 +1,26 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
heroAgents,
|
||||
heroConnections,
|
||||
heroMessages,
|
||||
type HeroAgentRole,
|
||||
type HeroMessage,
|
||||
type HeroMessagePhase,
|
||||
} from "~/data/heroScene";
|
||||
|
||||
type MessagePhase = "sender" | "packet" | "receiver" | "cooldown";
|
||||
const props = defineProps<{
|
||||
message: HeroMessage | null;
|
||||
phase: HeroMessagePhase;
|
||||
reducedMotion?: boolean;
|
||||
}>();
|
||||
|
||||
const activeMessageIndex = ref(0);
|
||||
const phase = ref<MessagePhase>("cooldown");
|
||||
const isVisible = ref(false);
|
||||
const reducedMotion = ref(false);
|
||||
const sceneRef = ref<HTMLElement | null>(null);
|
||||
let timers: number[] = [];
|
||||
let observer: IntersectionObserver | null = null;
|
||||
let motionQuery: MediaQueryList | null = null;
|
||||
|
||||
const activeMessage = computed(() => heroMessages[activeMessageIndex.value] ?? null);
|
||||
const activeConnectionId = computed(() => (phase.value === "cooldown" ? null : activeMessage.value?.connectionId ?? null));
|
||||
const activeSender = computed<HeroAgentRole | null>(() => (phase.value === "cooldown" ? null : activeMessage.value?.from ?? null));
|
||||
const activeSender = computed<HeroAgentRole | null>(() => (props.phase === "cooldown" ? null : props.message?.from ?? null));
|
||||
const activeReceiver = computed<HeroAgentRole | "video" | null>(() => (
|
||||
phase.value === "receiver" ? activeMessage.value?.to ?? null : null
|
||||
props.phase === "receiver" ? props.message?.to ?? null : null
|
||||
));
|
||||
|
||||
function clearTimers() {
|
||||
timers.forEach(window.clearTimeout);
|
||||
timers = [];
|
||||
}
|
||||
|
||||
function setTimer(callback: () => void, delay: number) {
|
||||
const id = window.setTimeout(callback, delay);
|
||||
timers.push(id);
|
||||
}
|
||||
|
||||
function runCycle() {
|
||||
clearTimers();
|
||||
|
||||
if (!isVisible.value || reducedMotion.value) {
|
||||
phase.value = "cooldown";
|
||||
return;
|
||||
}
|
||||
|
||||
phase.value = "sender";
|
||||
setTimer(() => {
|
||||
phase.value = "packet";
|
||||
}, 900);
|
||||
setTimer(() => {
|
||||
phase.value = "receiver";
|
||||
}, 2200);
|
||||
setTimer(() => {
|
||||
phase.value = "cooldown";
|
||||
}, 3900);
|
||||
setTimer(() => {
|
||||
activeMessageIndex.value = (activeMessageIndex.value + 1) % heroMessages.length;
|
||||
runCycle();
|
||||
}, 4700);
|
||||
}
|
||||
|
||||
function syncMotion() {
|
||||
reducedMotion.value = Boolean(motionQuery?.matches);
|
||||
runCycle();
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (document.hidden) {
|
||||
clearTimers();
|
||||
phase.value = "cooldown";
|
||||
return;
|
||||
}
|
||||
runCycle();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
reducedMotion.value = motionQuery.matches;
|
||||
motionQuery.addEventListener("change", syncMotion);
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
isVisible.value = entry.isIntersecting;
|
||||
runCycle();
|
||||
},
|
||||
{ threshold: 0.15 },
|
||||
);
|
||||
|
||||
if (sceneRef.value) observer.observe(sceneRef.value);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimers();
|
||||
observer?.disconnect();
|
||||
motionQuery?.removeEventListener("change", syncMotion);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="sceneRef" class="cyber-scene">
|
||||
<div class="cyber-scene">
|
||||
<div class="cyber-scene__floor" aria-hidden="true" />
|
||||
<CyberHeroConnectors
|
||||
class="cyber-scene__connectors"
|
||||
:connections="heroConnections"
|
||||
:active-connection-id="activeConnectionId"
|
||||
:reduced-motion="reducedMotion"
|
||||
/>
|
||||
|
||||
<CyberHeroVideoFrame class="cyber-scene__video" />
|
||||
|
||||
|
|
@ -121,7 +36,7 @@ onUnmounted(() => {
|
|||
|
||||
<CyberHeroMessageBubbles
|
||||
class="cyber-scene__messages"
|
||||
:message="activeMessage"
|
||||
:message="message"
|
||||
:phase="phase"
|
||||
:reduced-motion="reducedMotion"
|
||||
/>
|
||||
|
|
|
|||
25
landing/components/hero/CyberHeroSpeechBubble.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import type { CSSProperties } from "vue";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
variant?: "sender" | "receiver" | "static";
|
||||
role?: string | null;
|
||||
bubbleStyle?: CSSProperties;
|
||||
}>(), {
|
||||
variant: "sender",
|
||||
role: null,
|
||||
bubbleStyle: undefined,
|
||||
});
|
||||
|
||||
const bubbleClasses = computed(() => [
|
||||
"cyber-message",
|
||||
`cyber-message--${props.variant}`,
|
||||
props.role ? `cyber-message--role-${props.role}` : null,
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="bubbleClasses" :style="bubbleStyle">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -11,17 +11,7 @@
|
|||
<span>Live demo</span>
|
||||
</div>
|
||||
<div class="cyber-video-frame__content">
|
||||
<ClientOnly>
|
||||
<Suspense>
|
||||
<LazyHeroDemoVideo />
|
||||
<template #fallback>
|
||||
<div class="cyber-video-frame__fallback" />
|
||||
</template>
|
||||
</Suspense>
|
||||
<template #fallback>
|
||||
<div class="cyber-video-frame__fallback" />
|
||||
</template>
|
||||
</ClientOnly>
|
||||
<HeroDemoVideo />
|
||||
</div>
|
||||
<div class="cyber-video-frame__corner cyber-video-frame__corner--tl" aria-hidden="true" />
|
||||
<div class="cyber-video-frame__corner cyber-video-frame__corner--tr" aria-hidden="true" />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import robotLeadLounge from "~/assets/images/footer/robot-lead-lounge-v1.webp";
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { repoUrl } = useGithubRepo();
|
||||
const { baseURL } = useRuntimeConfig().app;
|
||||
|
|
@ -11,6 +13,19 @@ const docsHref = computed(() => {
|
|||
|
||||
<template>
|
||||
<footer class="app-footer">
|
||||
<div class="app-footer__robot-stage">
|
||||
<RobotSpeechBubble class="app-footer__robot-bubble" tail="down">
|
||||
{{ t('footer.robotBubble') }}
|
||||
</RobotSpeechBubble>
|
||||
<img
|
||||
class="app-footer__robot"
|
||||
:src="robotLeadLounge"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
>
|
||||
</div>
|
||||
<v-container class="app-footer__inner">
|
||||
<span class="app-footer__copy"
|
||||
>{{ t('footer.copyright', { year }) }} · {{ t('footer.tagline') }}</span
|
||||
|
|
@ -28,8 +43,45 @@ const docsHref = computed(() => {
|
|||
|
||||
<style scoped>
|
||||
.app-footer {
|
||||
position: relative;
|
||||
border-top: 1px solid var(--at-c-border);
|
||||
padding: 20px 0;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.app-footer__robot-stage {
|
||||
position: absolute;
|
||||
right: clamp(24px, 7vw, 112px);
|
||||
bottom: calc(100% - 5px);
|
||||
z-index: 2;
|
||||
width: clamp(178px, 16vw, 236px);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
transform: translateY(3px) rotate(-1deg);
|
||||
transform-origin: 54% bottom;
|
||||
filter:
|
||||
drop-shadow(0 14px 18px rgba(0, 0, 0, 0.52))
|
||||
drop-shadow(0 0 14px rgba(130, 255, 0, 0.2));
|
||||
}
|
||||
|
||||
.app-footer__robot {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.app-footer__robot-bubble {
|
||||
--robot-bubble-position: absolute;
|
||||
--robot-bubble-min-width: 82px;
|
||||
--robot-bubble-max-width: 116px;
|
||||
--robot-bubble-min-height: 50px;
|
||||
--robot-bubble-font-size: 0.62rem;
|
||||
--robot-bubble-padding: 9px 13px 16px;
|
||||
|
||||
top: -28px;
|
||||
left: -18px;
|
||||
transform: rotate(-2deg);
|
||||
transform-origin: 72% 74%;
|
||||
}
|
||||
|
||||
.app-footer__inner {
|
||||
|
|
@ -91,6 +143,10 @@ const docsHref = computed(() => {
|
|||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.app-footer__robot-stage {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-footer__inner {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
|
|
|||
|
|
@ -8,26 +8,113 @@ const menuOpen = ref(false);
|
|||
|
||||
const withBase = (path: string) => `${baseURL.replace(/\/?$/, '/')}${path.replace(/^\/+/, '')}`;
|
||||
const docsHref = computed(() => withBase(locale.value === 'ru' ? 'docs/ru/' : 'docs/'));
|
||||
const isRu = computed(() => locale.value === 'ru');
|
||||
|
||||
const navItems = computed(() => [
|
||||
{ href: '#screenshots', label: t('nav.screenshots') },
|
||||
{ href: docsHref.value, label: t('nav.docs') },
|
||||
{ href: '#download', label: t('nav.download') },
|
||||
{ href: '#comparison', label: t('nav.comparison') },
|
||||
{ href: '#pricing', label: t('nav.pricing') },
|
||||
{ href: '#faq', label: t('nav.faq') },
|
||||
{ href: '#screenshots', label: t('nav.screenshots'), shortLabel: isRu.value ? 'Скрины' : 'Shots' },
|
||||
{ href: docsHref.value, label: 'Docs', shortLabel: 'Docs' },
|
||||
{ href: '#download', label: t('nav.download'), shortLabel: isRu.value ? 'Скачать' : 'Get' },
|
||||
{ href: '#comparison', label: t('nav.comparison'), shortLabel: isRu.value ? 'Сравн.' : 'Compare' },
|
||||
{ href: '#pricing', label: t('nav.pricing'), shortLabel: isRu.value ? 'Беспл.' : 'Free' },
|
||||
{ href: '#faq', label: t('nav.faq'), shortLabel: 'FAQ' },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<v-container class="app-header__inner">
|
||||
<svg
|
||||
class="app-header__hud"
|
||||
viewBox="0 0 2048 128"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="hud-cyan-magenta" x1="0" y1="0" x2="2048" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#00eaff" />
|
||||
<stop offset="0.28" stop-color="#7a5cff" />
|
||||
<stop offset="0.52" stop-color="#00eaff" />
|
||||
<stop offset="0.75" stop-color="#ff2bff" />
|
||||
<stop offset="1" stop-color="#00eaff" />
|
||||
</linearGradient>
|
||||
<linearGradient id="hud-panel-fill" x1="0" y1="0" x2="2048" y2="128" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#06152a" stop-opacity="0.94" />
|
||||
<stop offset="0.46" stop-color="#020711" stop-opacity="0.86" />
|
||||
<stop offset="1" stop-color="#07101e" stop-opacity="0.94" />
|
||||
</linearGradient>
|
||||
<filter id="hud-glow" x="-8%" y="-60%" width="116%" height="220%">
|
||||
<feGaussianBlur stdDeviation="3.6" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<pattern id="hud-dot-grid" width="14" height="14" patternUnits="userSpaceOnUse">
|
||||
<circle cx="2" cy="2" r="1.2" fill="#00eaff" opacity="0.16" />
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<g class="app-header__hud-fill">
|
||||
<path d="M22 26H384L438 64L384 102H22Z" />
|
||||
<path d="M456 36H1516L1546 52H1568V76H1546L1516 94H456Z" />
|
||||
<path d="M1608 26H2026V102H1608L1568 86V42Z" />
|
||||
</g>
|
||||
|
||||
<path class="app-header__hud-dots" d="M116 38H424V96H116Z" />
|
||||
|
||||
<g class="app-header__hud-lines app-header__hud-lines--back">
|
||||
<path d="M20 26H384L438 64L384 102H20Z" />
|
||||
<path d="M456 36H1516L1546 52H1568V76H1546L1516 94H456Z" />
|
||||
<path d="M1608 26H2028V102H1608L1568 86V42Z" />
|
||||
<path d="M34 18H372L388 30" />
|
||||
<path d="M462 24H920L944 38H1512" />
|
||||
<path d="M1620 18H2010L2034 38V91L2006 110H1614L1576 96" />
|
||||
<path d="M490 106H862L880 118H1196L1214 106H1512" />
|
||||
</g>
|
||||
|
||||
<g class="app-header__hud-lines app-header__hud-lines--front">
|
||||
<path d="M56 20H368" />
|
||||
<path d="M28 86L54 108H178" />
|
||||
<path d="M374 102H438L458 84" />
|
||||
<path d="M520 42H842L858 52" />
|
||||
<path d="M934 24L1186 24L1166 42H956Z" />
|
||||
<path d="M1248 42H1490L1504 52" />
|
||||
<path d="M1604 98H1868" />
|
||||
<path d="M1888 106H1994L2018 88" />
|
||||
</g>
|
||||
|
||||
<g class="app-header__hud-ticks">
|
||||
<path d="M644 62V78" />
|
||||
<path d="M884 62V78" />
|
||||
<path d="M1138 62V78" />
|
||||
<path d="M1288 62V78" />
|
||||
<path d="M1400 62V78" />
|
||||
<path d="M1532 62V78" />
|
||||
<path d="M1870 62V78" />
|
||||
</g>
|
||||
|
||||
<g class="app-header__hud-microdots">
|
||||
<circle v-for="x in [894, 914, 934, 954, 974, 994, 1014, 1034, 1054, 1074, 1094, 1114]" :key="x" :cx="x" cy="24" r="2.2" />
|
||||
<circle v-for="x in [370, 380, 390]" :key="`l-${x}`" :cx="x" cy="102" r="2.4" />
|
||||
<circle v-for="x in [1892, 1902, 1912]" :key="`r-${x}`" :cx="x" cy="102" r="2.4" />
|
||||
<circle cx="512" cy="26" r="2.8" />
|
||||
<circle cx="524" cy="26" r="2.8" />
|
||||
</g>
|
||||
|
||||
<g class="app-header__hud-energy">
|
||||
<path d="M28 26H384L438 64L384 102H28" />
|
||||
<path d="M456 36H1516L1546 52H1568V76H1546L1516 94H456" />
|
||||
<path d="M1608 26H2026V102H1608L1568 86V42" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div class="app-header__brand-frame">
|
||||
<AppLogo />
|
||||
</div>
|
||||
<nav class="app-header__nav">
|
||||
<v-btn v-for="item in navItems" :key="item.href" variant="text" :href="item.href">
|
||||
{{ item.label }}
|
||||
<span class="app-header__nav-label app-header__nav-label--full">{{ item.label }}</span>
|
||||
<span class="app-header__nav-label app-header__nav-label--short">{{ item.shortLabel }}</span>
|
||||
</v-btn>
|
||||
</nav>
|
||||
<div class="app-header__spacer" />
|
||||
|
|
@ -39,9 +126,10 @@ const navItems = computed(() => [
|
|||
:href="repoUrl"
|
||||
target="_blank"
|
||||
class="app-header__github-btn"
|
||||
:prepend-icon="mdiGithub"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
GitHub
|
||||
<v-icon :icon="mdiGithub" class="app-header__github-icon" />
|
||||
<span class="app-header__github-text">GitHub</span>
|
||||
</v-btn>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
|
@ -93,21 +181,27 @@ const navItems = computed(() => [
|
|||
<style scoped>
|
||||
.app-header {
|
||||
--header-cyan: var(--cyber-cyan);
|
||||
--header-violet: var(--cyber-violet);
|
||||
--header-magenta: var(--cyber-magenta);
|
||||
--header-height: 128px;
|
||||
--header-panel-height: 86px;
|
||||
--header-action-size: clamp(54px, 3.25vw, 66px);
|
||||
--header-github-width: clamp(150px, 9.7vw, 204px);
|
||||
--header-brand-icon: clamp(58px, 4.1vw, 76px);
|
||||
--header-brand-text: clamp(24px, 1.55vw, 34px);
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: var(--at-z-header);
|
||||
height: 92px;
|
||||
height: var(--header-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(2, 5, 13, 0.98), rgba(2, 5, 13, 0.72) 74%, rgba(2, 5, 13, 0.16)),
|
||||
rgba(2, 5, 13, 0.72);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
box-shadow: 0 16px 42px rgba(0, 0, 0, 0.26);
|
||||
background: transparent;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app-header::before,
|
||||
|
|
@ -116,133 +210,213 @@ const navItems = computed(() => [
|
|||
}
|
||||
|
||||
.v-theme--light .app-header {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(244, 250, 255, 0.96), rgba(244, 250, 255, 0.8) 74%, rgba(244, 250, 255, 0.2)),
|
||||
rgba(244, 250, 255, 0.86);
|
||||
border-bottom-color: rgba(0, 168, 204, 0.34);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.v-theme--dark .app-header {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(2, 5, 13, 0.98), rgba(2, 5, 13, 0.72) 74%, rgba(2, 5, 13, 0.16)),
|
||||
rgba(2, 5, 13, 0.72);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.app-header__inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: clamp(386px, 26.3vw, 538px) minmax(560px, 1fr) clamp(366px, 23.2vw, 474px);
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
width: min(1680px, 100vw);
|
||||
width: min(2048px, calc(100vw - 18px));
|
||||
max-width: none !important;
|
||||
height: 100%;
|
||||
padding-inline: 0 !important;
|
||||
}
|
||||
|
||||
.app-header__hud {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.app-header__hud-fill path {
|
||||
fill: url("#hud-panel-fill");
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
.app-header__hud-dots {
|
||||
fill: url("#hud-dot-grid");
|
||||
opacity: 0.56;
|
||||
}
|
||||
|
||||
.app-header__hud-lines path,
|
||||
.app-header__hud-ticks path,
|
||||
.app-header__hud-energy path {
|
||||
fill: none;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.app-header__hud-lines--back path {
|
||||
stroke: url("#hud-cyan-magenta");
|
||||
stroke-width: 2;
|
||||
opacity: 0.66;
|
||||
filter: url("#hud-glow");
|
||||
}
|
||||
|
||||
.app-header__hud-lines--front path {
|
||||
stroke: rgba(0, 234, 255, 0.9);
|
||||
stroke-width: 1.3;
|
||||
opacity: 0.84;
|
||||
}
|
||||
|
||||
.app-header__hud-ticks path {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-header__hud-microdots circle {
|
||||
fill: url("#hud-cyan-magenta");
|
||||
filter: url("#hud-glow");
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.app-header__hud-energy path {
|
||||
stroke: url("#hud-cyan-magenta");
|
||||
stroke-width: 3.2;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 92 620;
|
||||
stroke-dashoffset: 0;
|
||||
opacity: 0.82;
|
||||
filter: url("#hud-glow");
|
||||
animation: headerHudFlow 8.5s linear infinite;
|
||||
}
|
||||
|
||||
.app-header__brand-frame {
|
||||
position: relative;
|
||||
grid-column: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
isolation: isolate;
|
||||
height: 76px;
|
||||
min-width: 358px;
|
||||
padding: 0 74px 0 52px;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: var(--header-panel-height);
|
||||
min-width: 0;
|
||||
padding: 0 64px 0 clamp(38px, 4.2vw, 82px);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
clip-path: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app-header__brand-frame::before,
|
||||
.app-header__brand-frame::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
clip-path: polygon(0 0, calc(100% - 54px) 0, 100% 50%, calc(100% - 54px) 100%, 0 100%, 0 0);
|
||||
}
|
||||
|
||||
.app-header__brand-frame::before {
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
background: linear-gradient(110deg, rgba(0, 234, 255, 0.92), rgba(47, 125, 255, 0.5) 58%, rgba(0, 234, 255, 0.82));
|
||||
filter: drop-shadow(0 0 16px rgba(0, 234, 255, 0.42));
|
||||
}
|
||||
|
||||
.app-header__brand-frame::after {
|
||||
inset: 1px;
|
||||
z-index: -1;
|
||||
background:
|
||||
linear-gradient(110deg, rgba(5, 14, 31, 0.98), rgba(2, 6, 16, 0.95) 64%, rgba(0, 234, 255, 0.08)),
|
||||
rgba(2, 6, 16, 0.96);
|
||||
overflow: hidden;
|
||||
contain: paint;
|
||||
clip-path: polygon(0 18%, 80% 18%, 100% 50%, 80% 82%, 0 82%);
|
||||
}
|
||||
|
||||
.app-header__brand-frame :deep(.app-logo) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
gap: 12px;
|
||||
gap: clamp(14px, 1.4vw, 28px);
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
display: inline-grid;
|
||||
grid-template-columns: var(--header-brand-icon) max-content;
|
||||
grid-template-rows: var(--header-brand-icon);
|
||||
align-content: center;
|
||||
justify-content: start;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.app-header__brand-frame :deep(.app-logo__img) {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
width: var(--header-brand-icon);
|
||||
height: var(--header-brand-icon);
|
||||
border-radius: 17px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08) inset,
|
||||
0 0 22px rgba(139, 92, 255, 0.36);
|
||||
0 0 0 1px rgba(139, 92, 255, 0.34) inset,
|
||||
0 0 28px rgba(139, 92, 255, 0.44),
|
||||
0 0 38px rgba(0, 234, 255, 0.12);
|
||||
}
|
||||
|
||||
.app-header__brand-frame :deep(.app-logo__text) {
|
||||
font-size: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
font-size: var(--header-brand-text);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.app-header__nav {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-self: center;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: clamp(22px, 2.7vw, 46px);
|
||||
height: 76px;
|
||||
margin-left: -28px;
|
||||
padding: 0 clamp(34px, 4.4vw, 74px) 0 clamp(70px, 5.6vw, 104px);
|
||||
}
|
||||
--nav-pad-start: clamp(34px, 4cqw, 56px);
|
||||
--nav-pad-end: clamp(38px, 4.5cqw, 68px);
|
||||
|
||||
.app-header__nav::before,
|
||||
.app-header__nav::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(90deg, rgba(0, 234, 255, 0.6), rgba(0, 234, 255, 0.24) 36%, rgba(139, 92, 255, 0.5) 58%, rgba(0, 234, 255, 0.58));
|
||||
opacity: 0.86;
|
||||
box-shadow: 0 0 14px rgba(0, 234, 255, 0.18);
|
||||
top: calc((var(--header-height) - var(--header-panel-height, 86px)) / 2);
|
||||
left: calc(456 / 2048 * 100%);
|
||||
right: calc((2048 - 1568) / 2048 * 100%);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
z-index: 1;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
height: var(--header-panel-height, 86px);
|
||||
padding: 0 var(--nav-pad-end) 0 var(--nav-pad-start);
|
||||
overflow: hidden;
|
||||
container-type: inline-size;
|
||||
contain: paint;
|
||||
clip-path: polygon(0 17%, 94% 17%, 97.4% 36%, 100% 36%, 100% 64%, 97.4% 64%, 94% 83%, 0 83%);
|
||||
}
|
||||
|
||||
.app-header__nav::before {
|
||||
top: 8px;
|
||||
}
|
||||
|
||||
.app-header__nav::after {
|
||||
bottom: 7px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-header__nav :deep(.v-btn) {
|
||||
height: 48px !important;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 64px !important;
|
||||
min-width: 0 !important;
|
||||
padding-inline: clamp(4px, 0.8cqw, 12px) !important;
|
||||
border-radius: 0;
|
||||
color: rgba(244, 247, 255, 0.9) !important;
|
||||
color: rgba(244, 247, 255, 0.88) !important;
|
||||
font-family: var(--at-font-mono);
|
||||
font-size: 13px !important;
|
||||
font-size: clamp(12px, 1.45cqw, 17px) !important;
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: 0.08em !important;
|
||||
letter-spacing: clamp(0.03em, 0.12vw, 0.07em) !important;
|
||||
text-transform: uppercase !important;
|
||||
text-shadow: 0 0 16px rgba(244, 247, 255, 0.16);
|
||||
}
|
||||
|
||||
.app-header__nav :deep(.v-btn:not(:last-child)::after) {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
background: linear-gradient(180deg, transparent, rgba(0, 234, 255, 0.62), transparent);
|
||||
filter: drop-shadow(0 0 8px rgba(0, 234, 255, 0.34));
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-header__nav :deep(.v-btn__content) {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-header__nav-label {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-header__nav-label--short {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-header__nav :deep(.v-btn:hover) {
|
||||
|
|
@ -255,55 +429,105 @@ const navItems = computed(() => [
|
|||
}
|
||||
|
||||
.app-header__desktop-actions {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
position: absolute;
|
||||
top: calc((var(--header-height) - var(--header-panel-height)) / 2);
|
||||
right: calc((2048 - 2026) / 2048 * 100%);
|
||||
left: calc(1568 / 2048 * 100%);
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
var(--header-action-size)
|
||||
var(--header-github-width)
|
||||
var(--header-action-size);
|
||||
z-index: 1;
|
||||
gap: clamp(10px, 1.15vw, 22px);
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
justify-content: flex-end;
|
||||
isolation: isolate;
|
||||
height: 76px;
|
||||
min-width: 328px;
|
||||
padding: 0 32px 0 58px;
|
||||
justify-content: center;
|
||||
justify-items: center;
|
||||
height: var(--header-panel-height);
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
clip-path: none;
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
contain: paint;
|
||||
clip-path: polygon(8% 18%, 100% 18%, 100% 82%, 8% 82%, 0 64%, 0 36%);
|
||||
}
|
||||
|
||||
.app-header__desktop-actions::before,
|
||||
.app-header__desktop-actions::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
clip-path: polygon(42px 0, 100% 0, 100% 100%, 42px 100%, 0 50%);
|
||||
.app-header__desktop-actions :deep(.v-btn) {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
margin: 0 !important;
|
||||
line-height: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.app-header__desktop-actions::before {
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
background: linear-gradient(250deg, rgba(0, 234, 255, 0.92), rgba(47, 125, 255, 0.46) 48%, rgba(0, 234, 255, 0.72));
|
||||
filter: drop-shadow(0 0 16px rgba(0, 234, 255, 0.34));
|
||||
.app-header__desktop-actions :deep(.v-btn:not(.app-header__github-btn)) {
|
||||
width: var(--header-action-size) !important;
|
||||
min-width: var(--header-action-size) !important;
|
||||
height: var(--header-action-size) !important;
|
||||
min-height: var(--header-action-size) !important;
|
||||
padding-inline: 0 !important;
|
||||
color: rgba(244, 247, 255, 0.9) !important;
|
||||
}
|
||||
|
||||
.app-header__desktop-actions::after {
|
||||
inset: 1px;
|
||||
z-index: -1;
|
||||
background:
|
||||
linear-gradient(250deg, rgba(5, 14, 31, 0.98), rgba(2, 6, 16, 0.94) 68%, rgba(0, 234, 255, 0.08)),
|
||||
rgba(2, 6, 16, 0.96);
|
||||
.app-header__desktop-actions :deep(.v-btn__content),
|
||||
.app-header__desktop-actions :deep(.v-icon) {
|
||||
margin: 0 !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-header__desktop-actions :deep(.v-btn__overlay),
|
||||
.app-header__desktop-actions :deep(.v-btn__underlay) {
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.app-header__desktop-actions :deep(.language-switcher__flag-icon) {
|
||||
display: block;
|
||||
width: calc(var(--header-action-size) - 14px);
|
||||
height: calc(var(--header-action-size) - 14px);
|
||||
border-radius: 50%;
|
||||
filter: drop-shadow(0 0 12px rgba(47, 125, 255, 0.34));
|
||||
}
|
||||
|
||||
.app-header__github-btn {
|
||||
min-height: 36px !important;
|
||||
border-color: rgba(0, 234, 255, 0.58) !important;
|
||||
width: var(--header-github-width) !important;
|
||||
height: var(--header-action-size) !important;
|
||||
min-height: var(--header-action-size) !important;
|
||||
min-width: var(--header-github-width) !important;
|
||||
padding-inline: clamp(14px, 1vw, 20px) !important;
|
||||
border-color: rgba(0, 234, 255, 0.76) !important;
|
||||
border-radius: 6px !important;
|
||||
color: var(--header-cyan) !important;
|
||||
font-family: var(--at-font-mono);
|
||||
font-weight: 800 !important;
|
||||
font-size: 12px !important;
|
||||
font-size: clamp(14px, 1vw, 19px) !important;
|
||||
letter-spacing: 0.08em !important;
|
||||
text-transform: uppercase !important;
|
||||
box-shadow: 0 0 16px rgba(0, 234, 255, 0.12);
|
||||
background: rgba(0, 234, 255, 0.035) !important;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 234, 255, 0.1) inset,
|
||||
0 0 20px rgba(0, 234, 255, 0.18);
|
||||
}
|
||||
|
||||
.app-header__github-btn :deep(.v-btn__content) {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-header__github-icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.app-header__github-text {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-header__github-btn:hover {
|
||||
|
|
@ -316,21 +540,222 @@ const navItems = computed(() => [
|
|||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 959px) {
|
||||
@keyframes headerHudFlow {
|
||||
to {
|
||||
stroke-dashoffset: -712;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1439px) {
|
||||
.app-header {
|
||||
--header-height: 104px;
|
||||
--header-panel-height: 72px;
|
||||
--header-action-size: 54px;
|
||||
--header-github-width: 124px;
|
||||
--header-brand-icon: 48px;
|
||||
--header-brand-text: 16px;
|
||||
}
|
||||
|
||||
.app-header__inner {
|
||||
grid-template-columns: 296px minmax(0, 1fr) 286px;
|
||||
width: min(100vw - 16px, 1440px);
|
||||
}
|
||||
|
||||
.app-header__brand-frame {
|
||||
height: 72px;
|
||||
padding-left: 38px;
|
||||
padding-right: 38px;
|
||||
}
|
||||
|
||||
.app-header__brand-frame :deep(.app-logo__text) {
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.app-header__nav {
|
||||
--nav-pad-start: clamp(30px, 4.5cqw, 46px);
|
||||
--nav-pad-end: clamp(42px, 5.8cqw, 58px);
|
||||
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
.app-header__nav :deep(.v-btn) {
|
||||
height: 56px !important;
|
||||
padding-inline: 3px !important;
|
||||
font-size: clamp(11.4px, 1.7cqw, 12px) !important;
|
||||
letter-spacing: 0.02em !important;
|
||||
}
|
||||
|
||||
.app-header__desktop-actions {
|
||||
height: var(--header-panel-height);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-header__github-btn {
|
||||
font-size: 13px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.app-header {
|
||||
--header-github-width: 104px;
|
||||
--header-brand-text: 14px;
|
||||
}
|
||||
|
||||
.app-header__inner {
|
||||
grid-template-columns: 260px minmax(0, 1fr) 232px;
|
||||
}
|
||||
|
||||
.app-header__brand-frame {
|
||||
padding-left: 26px;
|
||||
padding-right: 34px;
|
||||
}
|
||||
|
||||
.app-header__nav {
|
||||
--nav-pad-start: 24px;
|
||||
--nav-pad-end: 18px;
|
||||
}
|
||||
|
||||
.app-header__nav :deep(.v-btn) {
|
||||
font-size: 9px !important;
|
||||
letter-spacing: 0.04em !important;
|
||||
}
|
||||
|
||||
.app-header__desktop-actions {
|
||||
right: calc((2048 - 2026) / 2048 * 100%);
|
||||
left: calc(1568 / 2048 * 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1279px) and (min-width: 768px) {
|
||||
.app-header {
|
||||
--header-height: 88px;
|
||||
--header-panel-height: 64px;
|
||||
--header-action-size: clamp(40px, 5vw, 48px);
|
||||
--header-brand-icon: clamp(38px, 4.8vw, 44px);
|
||||
--header-brand-text: clamp(11px, 1.35vw, 14px);
|
||||
}
|
||||
|
||||
.app-header__inner {
|
||||
grid-template-columns: clamp(176px, 22vw, 260px) minmax(0, 1fr) clamp(148px, 18vw, 210px);
|
||||
width: min(100vw - 12px, 1239px);
|
||||
}
|
||||
|
||||
.app-header__brand-frame {
|
||||
height: 64px;
|
||||
padding-left: clamp(18px, 2.4vw, 30px);
|
||||
padding-right: clamp(22px, 3vw, 34px);
|
||||
}
|
||||
|
||||
.app-header__brand-frame :deep(.app-logo) {
|
||||
gap: clamp(8px, 1.2vw, 12px);
|
||||
}
|
||||
|
||||
.app-header__brand-frame :deep(.app-logo__text) {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.app-header__nav {
|
||||
--nav-pad-start: clamp(14px, 3.4cqw, 28px);
|
||||
--nav-pad-end: clamp(18px, 4cqw, 34px);
|
||||
|
||||
top: 12px;
|
||||
left: calc(456 / 2048 * 100%);
|
||||
right: calc((2048 - 1568) / 2048 * 100%);
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.app-header__nav :deep(.v-btn) {
|
||||
height: 48px !important;
|
||||
padding-inline: 2px !important;
|
||||
font-size: clamp(10.8px, 1.7cqw, 11.4px) !important;
|
||||
letter-spacing: 0.01em !important;
|
||||
}
|
||||
|
||||
.app-header__nav::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-header__nav :deep(.v-btn:not(:last-child)::after) {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.app-header__desktop-actions {
|
||||
height: 64px;
|
||||
gap: clamp(6px, 0.9vw, 10px);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 1279px) and (min-width: 768px) {
|
||||
.app-header {
|
||||
--header-github-width: var(--header-action-size);
|
||||
}
|
||||
|
||||
.app-header__nav-label--full {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-header__nav-label--short {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.app-header__github-btn {
|
||||
padding-inline: 0 !important;
|
||||
}
|
||||
|
||||
.app-header__github-btn :deep(.v-btn__content) {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.app-header__github-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.app-header {
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.app-header__inner {
|
||||
display: flex;
|
||||
width: min(100% - 32px, 680px);
|
||||
}
|
||||
|
||||
.app-header__hud {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-header__brand-frame {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-self: center;
|
||||
height: 48px;
|
||||
padding: 0 42px 0 12px;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.app-header__brand-frame::before,
|
||||
.app-header__brand-frame::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
clip-path: polygon(0 0, calc(100% - 44px) 0, 100% 50%, calc(100% - 44px) 100%, 0 100%, 0 0);
|
||||
}
|
||||
|
||||
.app-header__brand-frame::before {
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
background: linear-gradient(110deg, rgba(0, 234, 255, 0.92), rgba(47, 125, 255, 0.5) 58%, rgba(0, 234, 255, 0.82));
|
||||
filter: drop-shadow(0 0 16px rgba(0, 234, 255, 0.42));
|
||||
}
|
||||
|
||||
.app-header__brand-frame::after {
|
||||
inset: 1px;
|
||||
z-index: -1;
|
||||
background:
|
||||
linear-gradient(110deg, rgba(5, 14, 31, 0.98), rgba(2, 6, 16, 0.95) 64%, rgba(0, 234, 255, 0.08)),
|
||||
rgba(2, 6, 16, 0.96);
|
||||
}
|
||||
|
||||
.app-header__brand-frame :deep(.app-logo__img) {
|
||||
|
|
@ -353,6 +778,8 @@ const navItems = computed(() => [
|
|||
|
||||
.app-header__mobile-actions {
|
||||
display: flex;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
|
|
@ -363,6 +790,12 @@ const navItems = computed(() => [
|
|||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-header__hud-energy path {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-menu-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import robotAvatarCyan from "~/assets/images/hero/robots/robot-avatar-cyan-v1.webp";
|
||||
|
||||
const { t } = useI18n()
|
||||
const comparisonRobotRef = ref<HTMLElement | null>(null)
|
||||
const showComparisonRobotBubble = ref(false)
|
||||
let comparisonRobotObserver: IntersectionObserver | null = null
|
||||
|
||||
interface CellValue {
|
||||
status: string
|
||||
|
|
@ -19,8 +24,8 @@ interface ComparisonRow {
|
|||
const rows = computed<ComparisonRow[]>(() => [
|
||||
{
|
||||
feature: t('comparison.features.crossTeam'),
|
||||
us: { status: 'yes' },
|
||||
gastown: { status: 'partial', note: 'Cross-rig coordination' },
|
||||
us: { status: 'yes', note: 'Messages between separate teams' },
|
||||
gastown: { status: 'partial', note: 'Coordination across groups' },
|
||||
paperclip: { status: 'partial', note: 'Company-scoped org work' },
|
||||
cursor: { status: 'na' },
|
||||
claudeCli: { status: 'no' },
|
||||
|
|
@ -35,16 +40,16 @@ const rows = computed<ComparisonRow[]>(() => [
|
|||
},
|
||||
{
|
||||
feature: t('comparison.features.linkedTasks'),
|
||||
us: { status: 'yes', note: 'Cross-refs + dependencies' },
|
||||
gastown: { status: 'partial', note: 'Beads deps + convoys' },
|
||||
paperclip: { status: 'yes', note: 'Goals, parents, blockers' },
|
||||
us: { status: 'yes', note: 'Tasks can link to and block each other' },
|
||||
gastown: { status: 'partial', note: 'Task deps + grouped work' },
|
||||
paperclip: { status: 'yes', note: 'Goals, parent tasks, blockers' },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'yes', note: 'Shared task list' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.sessionAnalysis'),
|
||||
us: { status: 'yes', note: 'Task logs + token tracking' },
|
||||
gastown: { status: 'partial', note: 'Session recall, feed, OTEL' },
|
||||
us: { status: 'yes', note: 'Task logs + token usage' },
|
||||
gastown: { status: 'partial', note: 'Session recall, feed, metrics' },
|
||||
paperclip: { status: 'partial', note: 'Run transcripts + cost audit' },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'partial', note: 'Usage command, no UI' },
|
||||
|
|
@ -75,16 +80,16 @@ const rows = computed<ComparisonRow[]>(() => [
|
|||
},
|
||||
{
|
||||
feature: t('comparison.features.fullAutonomy'),
|
||||
us: { status: 'yes', note: 'Create, assign, review end-to-end' },
|
||||
gastown: { status: 'yes', note: 'Mayor, convoys, recovery' },
|
||||
paperclip: { status: 'yes', note: 'Heartbeats + governance' },
|
||||
us: { status: 'yes', note: 'Plan, assign, work, and review' },
|
||||
gastown: { status: 'yes', note: 'Coordinator, grouped work, recovery' },
|
||||
paperclip: { status: 'yes', note: 'Wake-up runs + governance' },
|
||||
cursor: { status: 'partial', note: 'Background agents, not teams' },
|
||||
claudeCli: { status: 'yes', note: 'Experimental CLI teams' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.taskDeps'),
|
||||
us: { status: 'yes', note: 'Guaranteed ordering' },
|
||||
gastown: { status: 'yes', note: 'DAG waves via Beads' },
|
||||
us: { status: 'yes', note: 'Tasks wait for blockers automatically' },
|
||||
gastown: { status: 'yes', note: 'Dependency waves' },
|
||||
paperclip: { status: 'yes', note: 'Blockers + execution locks' },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'yes', note: 'Team task deps, no UI' },
|
||||
|
|
@ -92,7 +97,7 @@ const rows = computed<ComparisonRow[]>(() => [
|
|||
{
|
||||
feature: t('comparison.features.reviewWorkflow'),
|
||||
us: { status: 'yes', note: 'Agents review each other' },
|
||||
gastown: { status: 'partial', note: 'Refinery merge queue' },
|
||||
gastown: { status: 'partial', note: 'Merge queue' },
|
||||
paperclip: { status: 'yes', note: 'Approvals + governance' },
|
||||
cursor: { status: 'partial', note: 'PR/BugBot only' },
|
||||
claudeCli: { status: 'yes', note: 'Team review, no UI' },
|
||||
|
|
@ -100,8 +105,8 @@ const rows = computed<ComparisonRow[]>(() => [
|
|||
{
|
||||
feature: t('comparison.features.zeroSetup'),
|
||||
us: { status: 'yes', note: 'Guided runtime setup' },
|
||||
gastown: { status: 'no', note: 'Go/Git/Dolt/Beads/tmux' },
|
||||
paperclip: { status: 'partial', note: 'npx + embedded Postgres' },
|
||||
gastown: { status: 'no', note: 'Manual CLI stack' },
|
||||
paperclip: { status: 'partial', note: 'npx + local database' },
|
||||
cursor: { status: 'yes' },
|
||||
claudeCli: { status: 'partial', note: 'CLI + env flag' },
|
||||
},
|
||||
|
|
@ -116,8 +121,8 @@ const rows = computed<ComparisonRow[]>(() => [
|
|||
{
|
||||
feature: t('comparison.features.execLog'),
|
||||
us: { status: 'yes', note: 'Tool calls, reasoning, timeline' },
|
||||
gastown: { status: 'partial', note: 'Feed, OTEL, dashboard' },
|
||||
paperclip: { status: 'yes', note: 'Run transcripts + ledger' },
|
||||
gastown: { status: 'partial', note: 'Feed, metrics, dashboard' },
|
||||
paperclip: { status: 'yes', note: 'Run transcripts + audit log' },
|
||||
cursor: { status: 'partial', note: 'Agent chat + terminal' },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
|
|
@ -129,6 +134,14 @@ const rows = computed<ComparisonRow[]>(() => [
|
|||
cursor: { status: 'partial', note: 'Native terminal only' },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.runtimeLoad'),
|
||||
us: { status: 'yes', note: 'CPU/RAM history for each live teammate' },
|
||||
gastown: { status: 'partial', note: 'Activity/health, not CPU/RAM' },
|
||||
paperclip: { status: 'partial', note: 'Run status/cost, not CPU/RAM' },
|
||||
cursor: { status: 'no', note: 'Remote agent/terminal only' },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.perTaskReview'),
|
||||
us: { status: 'yes', note: 'Accept / reject / comment' },
|
||||
|
|
@ -142,7 +155,7 @@ const rows = computed<ComparisonRow[]>(() => [
|
|||
us: { status: 'yes', note: 'Per-action approvals + notifications' },
|
||||
gastown: { status: 'yes', note: 'Gates, escalation, recovery' },
|
||||
paperclip: { status: 'yes', note: 'Board approvals, pause, terminate' },
|
||||
cursor: { status: 'partial', note: 'BG agents auto-run commands' },
|
||||
cursor: { status: 'partial', note: 'Background agents auto-run commands' },
|
||||
claudeCli: { status: 'yes', note: 'Permissions + hooks' },
|
||||
},
|
||||
{
|
||||
|
|
@ -155,12 +168,44 @@ const rows = computed<ComparisonRow[]>(() => [
|
|||
},
|
||||
{
|
||||
feature: t('comparison.features.multiAgent'),
|
||||
us: { status: 'yes', note: 'Claude, Codex + OpenCode teammates' },
|
||||
gastown: { status: 'yes', note: 'Claude, Codex, Gemini, Copilot + more' },
|
||||
paperclip: { status: 'yes', note: 'BYO agents: Claude, Codex, Cursor/OpenCode, HTTP' },
|
||||
cursor: { status: 'partial', note: 'Multi-model agents, no team backend' },
|
||||
us: { status: 'yes', note: 'Claude, Codex, and OpenCode in one team' },
|
||||
gastown: { status: 'yes', note: 'Many providers, terminal-first' },
|
||||
paperclip: { status: 'yes', note: 'Bring your own agents/runtimes' },
|
||||
cursor: { status: 'partial', note: 'Multi-model agents, no shared team' },
|
||||
claudeCli: { status: 'partial', note: 'Claude-only experimental teams' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.liveWorkGraph'),
|
||||
us: { status: 'yes', note: 'Teammates, tasks, blockers, handoffs, activity, logs' },
|
||||
gastown: { status: 'partial', note: 'Agent tree + feed panels' },
|
||||
paperclip: { status: 'partial', note: 'Org chart/status, not a task/log map' },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.liveTeam'),
|
||||
us: { status: 'yes', note: 'Watch teammates work and message them directly' },
|
||||
gastown: { status: 'partial', note: 'Terminal-based agent sessions' },
|
||||
paperclip: { status: 'partial', note: 'Agents wake up for runs, then sleep' },
|
||||
cursor: { status: 'partial', note: 'Background agents per task' },
|
||||
claudeCli: { status: 'partial', note: 'CLI teams, no desktop view' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.teamWorkspace'),
|
||||
us: { status: 'yes', note: 'Tasks, logs, Kanban, review, and teammates in one app' },
|
||||
gastown: { status: 'partial', note: 'Mail/feed/dashboard across tools' },
|
||||
paperclip: { status: 'partial', note: 'Board + transcripts, less live teammate view' },
|
||||
cursor: { status: 'partial', note: 'IDE chats/tasks, not team view' },
|
||||
claudeCli: { status: 'no', note: 'No desktop UI' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.launchProof'),
|
||||
us: { status: 'yes', note: 'Know who started, who is stuck, and who replied' },
|
||||
gastown: { status: 'partial', note: 'Session health, less clear message status' },
|
||||
paperclip: { status: 'partial', note: 'Run status, not live teammate status' },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'partial', note: 'CLI mailbox, no visual status' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.orgGovernance'),
|
||||
us: { status: 'partial', note: 'Roles + approvals, no org chart' },
|
||||
|
|
@ -175,7 +220,7 @@ const rows = computed<ComparisonRow[]>(() => [
|
|||
gastown: { status: 'partial', note: 'Cost tiers + digest, no hard caps' },
|
||||
paperclip: { status: 'yes', note: 'Per-agent budgets + hard stops' },
|
||||
cursor: { status: 'partial', note: 'Usage + BG spend limits' },
|
||||
claudeCli: { status: 'partial', note: '/cost + workspace limits' },
|
||||
claudeCli: { status: 'partial', note: '/usage + workspace limits' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.price'),
|
||||
|
|
@ -195,6 +240,91 @@ const competitors = [
|
|||
{ key: 'claudeCli', name: 'Claude Code CLI' },
|
||||
]
|
||||
|
||||
const sourceLinks = [
|
||||
{
|
||||
label: 'detailed research notes',
|
||||
href: 'https://github.com/777genius/agent-teams-ai/blob/main/docs/research/gastown-paperclip-comparison-2026-05-16.md',
|
||||
},
|
||||
{ label: 'Gastown README', href: 'https://github.com/gastownhall/gastown' },
|
||||
{
|
||||
label: 'Gastown provider guide',
|
||||
href: 'https://github.com/gastownhall/gastown/blob/main/docs/agent-provider-integration.md',
|
||||
},
|
||||
{
|
||||
label: 'Gastown scheduler',
|
||||
href: 'https://github.com/gastownhall/gastown/blob/main/docs/design/scheduler.md',
|
||||
},
|
||||
{
|
||||
label: 'Gastown dashboard source',
|
||||
href: 'https://github.com/gastownhall/gastown/blob/main/internal/web/templates/convoy.html',
|
||||
},
|
||||
{ label: 'Gastown release', href: 'https://github.com/gastownhall/gastown/releases/tag/v1.1.0' },
|
||||
{ label: 'Paperclip README', href: 'https://github.com/paperclipai/paperclip' },
|
||||
{
|
||||
label: 'Paperclip adapters',
|
||||
href: 'https://github.com/paperclipai/paperclip/blob/master/docs/adapters/overview.md',
|
||||
},
|
||||
{
|
||||
label: 'Paperclip heartbeat protocol',
|
||||
href: 'https://github.com/paperclipai/paperclip/blob/master/docs/guides/agent-developer/heartbeat-protocol.md',
|
||||
},
|
||||
{ label: 'Paperclip org chart', href: 'https://paperclip.inc/docs/guides/board-operator/org-structure/' },
|
||||
{
|
||||
label: 'Paperclip OrgChart source',
|
||||
href: 'https://github.com/paperclipai/paperclip/blob/master/ui/src/pages/OrgChart.tsx',
|
||||
},
|
||||
{
|
||||
label: 'Paperclip budgets',
|
||||
href: 'https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/costs-and-budgets.md',
|
||||
},
|
||||
{
|
||||
label: 'Paperclip runtime services',
|
||||
href: 'https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/execution-workspaces-and-runtime-services.md',
|
||||
},
|
||||
{
|
||||
label: 'Paperclip Kanban source',
|
||||
href: 'https://github.com/paperclipai/paperclip/blob/master/ui/src/components/KanbanBoard.tsx',
|
||||
},
|
||||
{
|
||||
label: 'Paperclip work products',
|
||||
href: 'https://github.com/paperclipai/paperclip/blob/master/packages/shared/src/validators/work-product.ts',
|
||||
},
|
||||
{ label: 'Paperclip release', href: 'https://github.com/paperclipai/paperclip/releases/tag/v2026.517.0' },
|
||||
{ label: 'Cursor Background Agents', href: 'https://docs.cursor.com/en/background-agents' },
|
||||
{ label: 'Cursor Diffs & Review', href: 'https://docs.cursor.com/en/agent/review' },
|
||||
{ label: 'Cursor Bugbot', href: 'https://docs.cursor.com/en/bugbot' },
|
||||
{ label: 'Cursor pricing', href: 'https://docs.cursor.com/en/account/usage' },
|
||||
{ label: 'Claude Code agent teams', href: 'https://code.claude.com/docs/en/agent-teams' },
|
||||
{ label: 'Claude Code subagents', href: 'https://code.claude.com/docs/en/sub-agents' },
|
||||
{ label: 'Claude Code workflows', href: 'https://code.claude.com/docs/en/common-workflows' },
|
||||
{ label: 'Claude Code costs', href: 'https://code.claude.com/docs/en/costs' },
|
||||
{ label: 'Claude pricing', href: 'https://claude.com/pricing' },
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
if (!comparisonRobotRef.value) return
|
||||
|
||||
comparisonRobotObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry?.isIntersecting) return
|
||||
showComparisonRobotBubble.value = true
|
||||
comparisonRobotObserver?.disconnect()
|
||||
comparisonRobotObserver = null
|
||||
},
|
||||
{
|
||||
rootMargin: '0px 0px -12% 0px',
|
||||
threshold: 0.35,
|
||||
},
|
||||
)
|
||||
|
||||
comparisonRobotObserver.observe(comparisonRobotRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
comparisonRobotObserver?.disconnect()
|
||||
comparisonRobotObserver = null
|
||||
})
|
||||
|
||||
function getCellClass(cell: CellValue): string {
|
||||
switch (cell.status) {
|
||||
case 'yes': return 'comparison-table__cell--yes'
|
||||
|
|
@ -234,6 +364,29 @@ function getStatusIcon(status: string): string {
|
|||
</div>
|
||||
|
||||
<div class="comparison-table__wrap">
|
||||
<span
|
||||
ref="comparisonRobotRef"
|
||||
class="comparison-table__robot"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Transition name="comparison-robot-bubble">
|
||||
<RobotSpeechBubble
|
||||
v-if="showComparisonRobotBubble"
|
||||
class="comparison-table__robot-bubble"
|
||||
tail="right"
|
||||
>
|
||||
{{ t("comparison.robotBubble") }}
|
||||
</RobotSpeechBubble>
|
||||
</Transition>
|
||||
<img
|
||||
class="comparison-table__robot-image"
|
||||
:src="robotAvatarCyan"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
>
|
||||
</span>
|
||||
<table class="comparison-table">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
@ -298,6 +451,15 @@ function getStatusIcon(status: string): string {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="comparison-section__sources">
|
||||
Fact sources checked on May 18, 2026:
|
||||
<template v-for="(source, index) in sourceLinks" :key="source.href">
|
||||
<a :href="source.href" target="_blank" rel="noopener noreferrer">
|
||||
{{ source.label }}
|
||||
</a><span v-if="index < sourceLinks.length - 1">, </span>
|
||||
</template>.
|
||||
</p>
|
||||
</v-container>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -305,6 +467,7 @@ function getStatusIcon(status: string): string {
|
|||
<style scoped>
|
||||
.comparison-section {
|
||||
position: relative;
|
||||
--comparison-sticky-header-offset: 76px;
|
||||
}
|
||||
|
||||
.comparison-section__header {
|
||||
|
|
@ -345,6 +508,129 @@ function getStatusIcon(status: string): string {
|
|||
z-index: 1;
|
||||
}
|
||||
|
||||
.comparison-table__robot {
|
||||
position: absolute;
|
||||
right: clamp(28px, 7vw, 96px);
|
||||
bottom: calc(100% - 5px);
|
||||
z-index: 4;
|
||||
width: clamp(82px, 7.2vw, 124px);
|
||||
height: auto;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
transform: translateY(4px) rotate(-0.5deg);
|
||||
transform-origin: center bottom;
|
||||
animation: comparisonRobotIdle 5.2s ease-in-out infinite;
|
||||
filter:
|
||||
drop-shadow(0 18px 22px rgba(0, 0, 0, 0.5))
|
||||
drop-shadow(0 0 18px rgba(0, 234, 255, 0.26));
|
||||
}
|
||||
|
||||
.comparison-table__robot-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
transform:
|
||||
scaleX(-1)
|
||||
rotate(2deg);
|
||||
transform-origin: center bottom;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.comparison-table__robot::selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.comparison-table__robot-bubble {
|
||||
--robot-bubble-position: absolute;
|
||||
--robot-bubble-min-width: 96px;
|
||||
--robot-bubble-max-width: 190px;
|
||||
--robot-bubble-min-height: 42px;
|
||||
--robot-bubble-font-size: 0.66rem;
|
||||
--robot-bubble-padding: 8px 26px 8px 13px;
|
||||
|
||||
top: 10px;
|
||||
right: calc(100% + 12px);
|
||||
transform: rotate(-5deg);
|
||||
transform-origin: right bottom;
|
||||
animation: comparisonRobotBubbleFloat 2.6s ease-in-out 0.42s infinite;
|
||||
}
|
||||
|
||||
.comparison-robot-bubble-enter-active,
|
||||
.comparison-robot-bubble-leave-active {
|
||||
transition:
|
||||
opacity 0.26s ease,
|
||||
filter 0.26s ease;
|
||||
}
|
||||
|
||||
.comparison-robot-bubble-enter-active {
|
||||
animation: comparisonRobotBubblePop 0.52s cubic-bezier(0.18, 0.9, 0.2, 1.24);
|
||||
}
|
||||
|
||||
.comparison-robot-bubble-enter-from,
|
||||
.comparison-robot-bubble-leave-to {
|
||||
opacity: 0;
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
@keyframes comparisonRobotIdle {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate3d(0, 4px, 0) rotate(-0.55deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate3d(1px, 3px, 0) rotate(0.75deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes comparisonRobotBubblePop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(14px, 18px, 0) scale(0.48) rotate(-13deg);
|
||||
}
|
||||
|
||||
58% {
|
||||
opacity: 1;
|
||||
transform: translate3d(-3px, -4px, 0) scale(1.1) rotate(-4deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1) rotate(-5deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes comparisonRobotBubbleFloat {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate3d(0, 0, 0) rotate(-5deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate3d(0, -2px, 0) rotate(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
.comparison-section__sources {
|
||||
max-width: 1040px;
|
||||
margin: 18px auto 0;
|
||||
color: rgba(136, 146, 176, 0.82);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.65;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.comparison-section__sources a {
|
||||
color: #00d4e6;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.comparison-section__sources a:hover {
|
||||
color: #00f0ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.comparison-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
|
@ -354,12 +640,13 @@ function getStatusIcon(status: string): string {
|
|||
|
||||
/* Header */
|
||||
.comparison-table thead {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
z-index: 2;
|
||||
position: static;
|
||||
}
|
||||
|
||||
.comparison-table__th {
|
||||
position: sticky;
|
||||
top: var(--comparison-sticky-header-offset);
|
||||
z-index: 3;
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
|
|
@ -382,7 +669,7 @@ function getStatusIcon(status: string): string {
|
|||
.comparison-table__th--highlight {
|
||||
color: #00f0ff;
|
||||
background: rgba(0, 18, 20, 0.97);
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.comparison-table__th--highlight::after {
|
||||
|
|
@ -545,6 +832,18 @@ function getStatusIcon(status: string): string {
|
|||
border-color: rgba(0, 180, 200, 0.2);
|
||||
}
|
||||
|
||||
.v-theme--light .comparison-section__sources {
|
||||
color: rgba(71, 85, 105, 0.82);
|
||||
}
|
||||
|
||||
.v-theme--light .comparison-section__sources a {
|
||||
color: #0891b2;
|
||||
}
|
||||
|
||||
.v-theme--light .comparison-section__sources a:hover {
|
||||
color: #0e7490;
|
||||
}
|
||||
|
||||
.v-theme--light .comparison-table__th {
|
||||
color: #64748b;
|
||||
border-bottom-color: rgba(0, 0, 0, 0.08);
|
||||
|
|
@ -624,6 +923,10 @@ function getStatusIcon(status: string): string {
|
|||
|
||||
/* Responsive */
|
||||
@media (max-width: 960px) {
|
||||
.comparison-section {
|
||||
--comparison-sticky-header-offset: 60px;
|
||||
}
|
||||
|
||||
.comparison-table__wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
|
@ -641,6 +944,12 @@ function getStatusIcon(status: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
.comparison-section {
|
||||
--comparison-sticky-header-offset: 124px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.comparison-section__title {
|
||||
font-size: 1.6rem;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { mdiApple, mdiMicrosoftWindows, mdiPenguin, mdiDownload, mdiCheckCircle } from '@mdi/js';
|
||||
import robotAvatarSeatedMagenta from '~/assets/images/hero/robots/robot-avatar-seated-magenta-v1.webp';
|
||||
import { downloadAssets } from '~/data/downloads';
|
||||
import type { DownloadOs, DownloadArch } from '~/data/downloads';
|
||||
|
||||
|
|
@ -8,12 +9,219 @@ const { t, locale } = useI18n();
|
|||
const downloadStore = useDownloadStore();
|
||||
const { data: releaseData, resolve } = useReleaseDownloads();
|
||||
const { trackDownloadClick } = useAnalytics();
|
||||
const { repoUrl, releaseDownloadUrl } = useGithubRepo();
|
||||
const { releaseDownloadUrl } = useGithubRepo();
|
||||
const isMounted = ref(false);
|
||||
const showLinuxRobotMessage = ref(false);
|
||||
const showFallingLinuxRobot = ref(false);
|
||||
const isLinuxRobotDetached = ref(false);
|
||||
const hasLinuxRobotDeparted = ref(false);
|
||||
const linuxRobotFlightState = ref<'idle' | 'falling' | 'landed'>('idle');
|
||||
const fallingLinuxRobotStyle = ref<Record<string, string>>({});
|
||||
let linuxRobotObserver: IntersectionObserver | null = null;
|
||||
let linuxRobotFallRaf = 0;
|
||||
let linuxRobotFallTimer: number | null = null;
|
||||
let lastLinuxRobotScrollY = 0;
|
||||
let faqLandingResizeObserver: ResizeObserver | null = null;
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function getPageRect(element: HTMLElement) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left + window.scrollX,
|
||||
top: rect.top + window.scrollY,
|
||||
right: rect.right + window.scrollX,
|
||||
bottom: rect.bottom + window.scrollY,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
}
|
||||
|
||||
function clearLinuxRobotFallTimer() {
|
||||
if (linuxRobotFallTimer === null) return;
|
||||
window.clearTimeout(linuxRobotFallTimer);
|
||||
linuxRobotFallTimer = null;
|
||||
}
|
||||
|
||||
function resetLinuxRobotFall(options: { keepSourceHidden?: boolean } = {}) {
|
||||
clearLinuxRobotFallTimer();
|
||||
showFallingLinuxRobot.value = false;
|
||||
isLinuxRobotDetached.value = options.keepSourceHidden || hasLinuxRobotDeparted.value;
|
||||
linuxRobotFlightState.value = 'idle';
|
||||
fallingLinuxRobotStyle.value = {};
|
||||
}
|
||||
|
||||
function getLinuxRobotFallMetrics() {
|
||||
const sourceRobot = document.querySelector<HTMLElement>('.download-section__card-robot');
|
||||
const downloadSection = document.querySelector<HTMLElement>('#download');
|
||||
const faqTarget = document.querySelector<HTMLElement>('[data-faq-landing-target]');
|
||||
|
||||
if (!sourceRobot || !downloadSection || !faqTarget) return null;
|
||||
|
||||
const sourceViewport = sourceRobot.getBoundingClientRect();
|
||||
const download = getPageRect(downloadSection);
|
||||
const target = getPageRect(faqTarget);
|
||||
const robotWidth = clamp(sourceViewport.width, 92, 112);
|
||||
const robotHeight = sourceViewport.height * (robotWidth / sourceViewport.width);
|
||||
const landedPageX = target.left + target.width * 0.5 - robotWidth * 0.5;
|
||||
const landedPageY = target.top - robotHeight * 0.58;
|
||||
|
||||
return {
|
||||
sourceViewport,
|
||||
download,
|
||||
landedPageX,
|
||||
landedPageY,
|
||||
robotWidth,
|
||||
};
|
||||
}
|
||||
|
||||
function getLinuxRobotLandedStyle(metrics: NonNullable<ReturnType<typeof getLinuxRobotFallMetrics>>) {
|
||||
return {
|
||||
left: `${metrics.landedPageX}px`,
|
||||
top: `${metrics.landedPageY}px`,
|
||||
width: `${metrics.robotWidth}px`,
|
||||
opacity: '1',
|
||||
transform: 'translate3d(0, 0, 0) rotate(-5deg) scale(0.95)',
|
||||
};
|
||||
}
|
||||
|
||||
function finishLinuxRobotFall() {
|
||||
clearLinuxRobotFallTimer();
|
||||
if (linuxRobotFlightState.value !== 'falling') return;
|
||||
|
||||
const metrics = getLinuxRobotFallMetrics();
|
||||
if (!metrics) {
|
||||
resetLinuxRobotFall();
|
||||
return;
|
||||
}
|
||||
|
||||
linuxRobotFlightState.value = 'landed';
|
||||
showFallingLinuxRobot.value = true;
|
||||
isLinuxRobotDetached.value = true;
|
||||
fallingLinuxRobotStyle.value = getLinuxRobotLandedStyle(metrics);
|
||||
}
|
||||
|
||||
function launchLinuxRobotFall(metrics: NonNullable<ReturnType<typeof getLinuxRobotFallMetrics>>) {
|
||||
clearLinuxRobotFallTimer();
|
||||
|
||||
const endX = metrics.landedPageX - window.scrollX;
|
||||
const endY = metrics.landedPageY - window.scrollY;
|
||||
const startX = metrics.sourceViewport.left;
|
||||
const startY = metrics.sourceViewport.top;
|
||||
|
||||
linuxRobotFlightState.value = 'falling';
|
||||
showFallingLinuxRobot.value = true;
|
||||
isLinuxRobotDetached.value = true;
|
||||
hasLinuxRobotDeparted.value = true;
|
||||
fallingLinuxRobotStyle.value = {
|
||||
left: `${startX}px`,
|
||||
top: `${startY}px`,
|
||||
width: `${metrics.robotWidth}px`,
|
||||
opacity: '1',
|
||||
'--fall-x': `${endX - startX}px`,
|
||||
'--fall-y': `${endY - startY}px`,
|
||||
};
|
||||
|
||||
linuxRobotFallTimer = window.setTimeout(finishLinuxRobotFall, 3000);
|
||||
}
|
||||
|
||||
function scheduleLinuxRobotFallUpdate() {
|
||||
if (linuxRobotFallRaf) return;
|
||||
linuxRobotFallRaf = window.requestAnimationFrame(updateLinuxRobotFall);
|
||||
}
|
||||
|
||||
function updateLinuxRobotFall() {
|
||||
linuxRobotFallRaf = 0;
|
||||
const currentScrollY = window.scrollY;
|
||||
const scrollingUp = currentScrollY < lastLinuxRobotScrollY - 4;
|
||||
lastLinuxRobotScrollY = currentScrollY;
|
||||
|
||||
if (window.innerWidth <= 960 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
resetLinuxRobotFall();
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = getLinuxRobotFallMetrics();
|
||||
if (!metrics) {
|
||||
resetLinuxRobotFall();
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportHeight = window.innerHeight;
|
||||
const startScroll = metrics.download.bottom - viewportHeight * 0.72;
|
||||
|
||||
if (linuxRobotFlightState.value === 'landed') {
|
||||
fallingLinuxRobotStyle.value = getLinuxRobotLandedStyle(metrics);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentScrollY < startScroll) {
|
||||
resetLinuxRobotFall({ keepSourceHidden: hasLinuxRobotDeparted.value });
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrollingUp) {
|
||||
resetLinuxRobotFall({ keepSourceHidden: hasLinuxRobotDeparted.value });
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasLinuxRobotDeparted.value && linuxRobotFlightState.value === 'idle') return;
|
||||
|
||||
if (linuxRobotFlightState.value === 'idle') {
|
||||
launchLinuxRobotFall(metrics);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
isMounted.value = true;
|
||||
downloadStore.init();
|
||||
|
||||
nextTick(() => {
|
||||
const linuxCard = document.querySelector<HTMLElement>('[data-download-os="linux"]');
|
||||
const faqTarget = document.querySelector<HTMLElement>('[data-faq-landing-target]');
|
||||
if (!linuxCard) return;
|
||||
|
||||
linuxRobotObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry?.isIntersecting) return;
|
||||
showLinuxRobotMessage.value = true;
|
||||
linuxRobotObserver?.disconnect();
|
||||
linuxRobotObserver = null;
|
||||
},
|
||||
{
|
||||
rootMargin: '0px 0px -18% 0px',
|
||||
threshold: 0.45,
|
||||
},
|
||||
);
|
||||
|
||||
linuxRobotObserver.observe(linuxCard);
|
||||
|
||||
if (faqTarget) {
|
||||
faqLandingResizeObserver = new ResizeObserver(scheduleLinuxRobotFallUpdate);
|
||||
faqLandingResizeObserver.observe(faqTarget);
|
||||
}
|
||||
|
||||
scheduleLinuxRobotFallUpdate();
|
||||
});
|
||||
|
||||
window.addEventListener('scroll', scheduleLinuxRobotFallUpdate, { passive: true });
|
||||
window.addEventListener('resize', scheduleLinuxRobotFallUpdate);
|
||||
window.visualViewport?.addEventListener('resize', scheduleLinuxRobotFallUpdate);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
linuxRobotObserver?.disconnect();
|
||||
linuxRobotObserver = null;
|
||||
faqLandingResizeObserver?.disconnect();
|
||||
faqLandingResizeObserver = null;
|
||||
if (linuxRobotFallRaf) window.cancelAnimationFrame(linuxRobotFallRaf);
|
||||
linuxRobotFallRaf = 0;
|
||||
clearLinuxRobotFallTimer();
|
||||
window.removeEventListener('scroll', scheduleLinuxRobotFallUpdate);
|
||||
window.removeEventListener('resize', scheduleLinuxRobotFallUpdate);
|
||||
window.visualViewport?.removeEventListener('resize', scheduleLinuxRobotFallUpdate);
|
||||
});
|
||||
|
||||
const platformIcons: Record<string, string> = {
|
||||
|
|
@ -68,12 +276,6 @@ const releaseDate = computed(() => {
|
|||
});
|
||||
});
|
||||
|
||||
const devBranchUrl = computed(() => `${repoUrl.value}/tree/dev`);
|
||||
const devBranchNote = computed(() =>
|
||||
locale.value === 'ru'
|
||||
? 'Самая свежая версия доступна в ветке dev - можно развернуть локально.'
|
||||
: 'Freshest version is available on the dev branch - clone and run it locally.',
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -91,16 +293,45 @@ const devBranchNote = computed(() =>
|
|||
v-for="(asset, index) in visibleAssets"
|
||||
:key="asset.id"
|
||||
class="download-section__card"
|
||||
:class="{ 'download-section__card--active': downloadStore.selectedId === asset.id }"
|
||||
:class="{
|
||||
'download-section__card--active': downloadStore.selectedId === asset.id,
|
||||
'download-section__card--with-robot': asset.os === 'linux',
|
||||
'download-section__card--robot-flying': asset.os === 'linux' && isLinuxRobotDetached,
|
||||
}"
|
||||
:style="{
|
||||
'--delay': `${index * 0.1}s`,
|
||||
'--accent': platformColors[asset.os] || '#00f0ff',
|
||||
}"
|
||||
:data-download-os="asset.os"
|
||||
@click="downloadStore.setSelected(asset.id)"
|
||||
>
|
||||
<!-- Card glow effect -->
|
||||
<div class="download-section__card-glow" />
|
||||
|
||||
<span
|
||||
v-if="asset.os === 'linux'"
|
||||
class="download-section__card-robot-seat"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Transition name="download-robot-bubble">
|
||||
<RobotSpeechBubble
|
||||
v-if="showLinuxRobotMessage"
|
||||
class="download-section__card-robot-bubble"
|
||||
tail="right"
|
||||
>
|
||||
Готов начать!
|
||||
</RobotSpeechBubble>
|
||||
</Transition>
|
||||
<img
|
||||
class="download-section__card-robot"
|
||||
:src="robotAvatarSeatedMagenta"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
>
|
||||
</span>
|
||||
|
||||
<!-- Platform icon -->
|
||||
<div class="download-section__card-icon-wrap">
|
||||
<v-icon
|
||||
|
|
@ -145,19 +376,29 @@ const devBranchNote = computed(() =>
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
class="download-section__dev-note"
|
||||
:href="devBranchUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{{ devBranchNote }}
|
||||
</a>
|
||||
|
||||
<p v-if="isMounted && releaseVersion" class="download-section__release-info">
|
||||
v{{ releaseVersion }} · {{ releaseDate }}
|
||||
</p>
|
||||
</v-container>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showFallingLinuxRobot"
|
||||
class="download-section__falling-robot"
|
||||
:class="`download-section__falling-robot--${linuxRobotFlightState}`"
|
||||
:style="fallingLinuxRobotStyle"
|
||||
aria-hidden="true"
|
||||
@animationend.self="finishLinuxRobotFall"
|
||||
>
|
||||
<img
|
||||
class="download-section__falling-robot-image"
|
||||
:src="robotAvatarSeatedMagenta"
|
||||
alt=""
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
>
|
||||
</div>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
|
@ -230,6 +471,11 @@ const devBranchNote = computed(() =>
|
|||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
.download-section__card--with-robot {
|
||||
overflow: visible;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.download-section__card:hover {
|
||||
transform: translateY(-6px);
|
||||
border-color: rgba(0, 240, 255, 0.2);
|
||||
|
|
@ -248,6 +494,212 @@ const devBranchNote = computed(() =>
|
|||
z-index: 2;
|
||||
}
|
||||
|
||||
.download-section__card-robot-seat {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: calc(100% - 68px);
|
||||
z-index: 4;
|
||||
width: 108px;
|
||||
pointer-events: none;
|
||||
transform: rotate(-5deg);
|
||||
transform-origin: center bottom;
|
||||
filter:
|
||||
drop-shadow(0 12px 18px rgba(0, 0, 0, 0.48))
|
||||
drop-shadow(0 0 14px rgba(255, 43, 255, 0.24));
|
||||
}
|
||||
|
||||
.download-section__card--robot-flying .download-section__card-robot-seat {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.download-section__falling-robot {
|
||||
--fall-x: 0px;
|
||||
--fall-y: 120vh;
|
||||
|
||||
position: fixed;
|
||||
z-index: 28;
|
||||
pointer-events: none;
|
||||
transform-origin: center 72%;
|
||||
will-change: left, top, transform, opacity;
|
||||
filter:
|
||||
drop-shadow(0 18px 26px rgba(0, 0, 0, 0.52))
|
||||
drop-shadow(0 0 18px rgba(255, 43, 255, 0.28));
|
||||
}
|
||||
|
||||
.download-section__falling-robot--falling {
|
||||
animation: downloadFallingRobotDrop 2.85s cubic-bezier(0.34, 0, 0.74, 0.28) forwards;
|
||||
}
|
||||
|
||||
.download-section__falling-robot--landed {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.download-section__falling-robot::after {
|
||||
position: absolute;
|
||||
left: 18%;
|
||||
right: 18%;
|
||||
bottom: 28%;
|
||||
height: 12px;
|
||||
content: "";
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 43, 255, 0.16);
|
||||
filter: blur(10px);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.download-section__falling-robot-image {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
transform:
|
||||
scaleX(-1)
|
||||
rotate(-4deg);
|
||||
transform-origin: center bottom;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.download-section__falling-robot--falling .download-section__falling-robot-image {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.download-section__falling-robot--landed .download-section__falling-robot-image {
|
||||
animation: downloadFallingRobotLanded 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes downloadFallingRobotDrop {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) rotate(-6deg) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.96;
|
||||
transform:
|
||||
translate3d(var(--fall-x), var(--fall-y), 0)
|
||||
rotate(355deg)
|
||||
scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes downloadFallingRobotFlutter {
|
||||
0%,
|
||||
100% {
|
||||
transform:
|
||||
translate3d(0, 0, 0)
|
||||
scaleX(-1)
|
||||
rotate(-5deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform:
|
||||
translate3d(0, 4px, 0)
|
||||
scaleX(-1)
|
||||
rotate(4deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes downloadFallingRobotLanded {
|
||||
0%,
|
||||
100% {
|
||||
transform:
|
||||
translate3d(0, 0, 0)
|
||||
scaleX(-1)
|
||||
rotate(-4deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform:
|
||||
translate3d(0, -2px, 0)
|
||||
scaleX(-1)
|
||||
rotate(-3deg);
|
||||
}
|
||||
}
|
||||
|
||||
.download-section__card-robot-bubble {
|
||||
--robot-bubble-position: absolute;
|
||||
--robot-bubble-min-width: 98px;
|
||||
--robot-bubble-max-width: 170px;
|
||||
--robot-bubble-min-height: 42px;
|
||||
--robot-bubble-font-size: 0.66rem;
|
||||
--robot-bubble-padding: 8px 26px 8px 13px;
|
||||
|
||||
top: 12px;
|
||||
right: calc(100% - 18px);
|
||||
transform: rotate(-5deg);
|
||||
transform-origin: right bottom;
|
||||
animation: downloadRobotBubbleFloat 2.6s ease-in-out 0.42s infinite;
|
||||
}
|
||||
|
||||
.download-robot-bubble-enter-active,
|
||||
.download-robot-bubble-leave-active {
|
||||
transition:
|
||||
opacity 0.26s ease,
|
||||
filter 0.26s ease;
|
||||
}
|
||||
|
||||
.download-robot-bubble-enter-active {
|
||||
animation: downloadRobotBubblePop 0.52s cubic-bezier(0.18, 0.9, 0.2, 1.24);
|
||||
}
|
||||
|
||||
.download-robot-bubble-enter-from,
|
||||
.download-robot-bubble-leave-to {
|
||||
opacity: 0;
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
.download-robot-bubble-leave-active {
|
||||
animation: downloadRobotBubbleExit 0.22s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes downloadRobotBubblePop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(14px, 18px, 0) scale(0.48) rotate(-13deg);
|
||||
}
|
||||
|
||||
58% {
|
||||
opacity: 1;
|
||||
transform: translate3d(-3px, -4px, 0) scale(1.1) rotate(-4deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1) rotate(-5deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes downloadRobotBubbleFloat {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate3d(0, 0, 0) rotate(-5deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate3d(0, -3px, 0) rotate(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes downloadRobotBubbleExit {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translate3d(8px, 8px, 0) scale(0.85) rotate(-8deg);
|
||||
}
|
||||
}
|
||||
|
||||
.download-section__card-robot {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
height: auto;
|
||||
transform:
|
||||
scaleX(-1)
|
||||
rotate(-4deg);
|
||||
transform-origin: center bottom;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.download-section__card--active:hover {
|
||||
transform: scale(1.08);
|
||||
border-color: rgba(57, 255, 20, 0.5);
|
||||
|
|
@ -395,38 +847,6 @@ const devBranchNote = computed(() =>
|
|||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.download-section__dev-note {
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
max-width: min(620px, calc(100vw - 32px));
|
||||
margin: 18px auto 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(0, 240, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 240, 255, 0.035);
|
||||
color: #00f0ff;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.55;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
opacity: 0.82;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background 0.2s ease,
|
||||
color 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.download-section__dev-note:hover {
|
||||
border-color: rgba(57, 255, 20, 0.24);
|
||||
background: rgba(57, 255, 20, 0.045);
|
||||
color: #39ff14;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes downloadFadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
|
@ -502,6 +922,26 @@ const devBranchNote = computed(() =>
|
|||
gap: 20px;
|
||||
}
|
||||
|
||||
.download-section__card-robot-seat {
|
||||
right: 18px;
|
||||
bottom: calc(100% - 54px);
|
||||
width: 84px;
|
||||
}
|
||||
|
||||
.download-section__card-robot-bubble {
|
||||
top: 8px;
|
||||
right: calc(100% - 14px);
|
||||
--robot-bubble-min-width: 88px;
|
||||
--robot-bubble-font-size: 0.6rem;
|
||||
--robot-bubble-padding: 7px 23px 7px 11px;
|
||||
}
|
||||
|
||||
.download-section__card-robot {
|
||||
transform:
|
||||
scaleX(-1)
|
||||
rotate(-4deg);
|
||||
}
|
||||
|
||||
.download-section__card--active {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
|
@ -557,6 +997,10 @@ const devBranchNote = computed(() =>
|
|||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.download-section__card-robot-seat {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.download-section__card-icon-wrap {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ const faqIcons = [
|
|||
</div>
|
||||
|
||||
<div class="faq-section__decoration">
|
||||
<div class="faq-section__deco-circle">
|
||||
<div class="faq-section__deco-circle" data-faq-landing-target>
|
||||
<v-icon size="40" class="faq-section__deco-icon" :icon="mdiFrequentlyAskedQuestions" />
|
||||
</div>
|
||||
<div class="faq-section__deco-ring faq-section__deco-ring--1" />
|
||||
|
|
|
|||
|
|
@ -2,33 +2,38 @@
|
|||
import {
|
||||
mdiBookOpenPageVariantOutline,
|
||||
mdiDownload,
|
||||
mdiPlayCircleOutline,
|
||||
} from "@mdi/js";
|
||||
import { heroMessages, type HeroMessagePhase } from "~/data/heroScene";
|
||||
|
||||
const { content } = useLandingContent();
|
||||
const { t, locale } = useI18n();
|
||||
const { baseURL } = useRuntimeConfig().app;
|
||||
const heroRef = ref<HTMLElement | null>(null);
|
||||
const activeHeroMessageIndex = ref(0);
|
||||
const heroMessagePhase = ref<HeroMessagePhase>("cooldown");
|
||||
const isHeroVisible = ref(false);
|
||||
const heroReducedMotion = ref(false);
|
||||
let heroMessageTimers: number[] = [];
|
||||
let heroMessageObserver: IntersectionObserver | null = null;
|
||||
let heroMotionQuery: MediaQueryList | null = null;
|
||||
|
||||
const downloadStore = useDownloadStore();
|
||||
const { resolve, data: releaseData } = useReleaseDownloads();
|
||||
const { repoUrl, latestReleaseUrl, releaseDownloadUrl } = useGithubRepo();
|
||||
const { latestReleaseUrl, releaseDownloadUrl } = useGithubRepo();
|
||||
const withBase = (path: string) => `${baseURL.replace(/\/?$/, "/")}${path.replace(/^\/+/, "")}`;
|
||||
|
||||
useCyberHeroParallax(heroRef);
|
||||
|
||||
const releaseVersion = computed(() => releaseData.value?.version || null);
|
||||
const releaseDate = computed(() => {
|
||||
const raw = releaseData.value?.pubDate;
|
||||
if (!raw) return null;
|
||||
return new Date(raw).toLocaleDateString("en-US", {
|
||||
if (!releaseData.value?.pubDate) return "";
|
||||
return new Date(releaseData.value.pubDate).toLocaleDateString(locale.value, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
});
|
||||
|
||||
onMounted(() => downloadStore.init());
|
||||
const activeHeroMessage = computed(() => heroMessages[activeHeroMessageIndex.value] ?? null);
|
||||
|
||||
const heroDownloadUrl = computed(() => {
|
||||
const asset = downloadStore.selectedAsset;
|
||||
|
|
@ -37,17 +42,87 @@ const heroDownloadUrl = computed(() => {
|
|||
return resolve(asset.os, arch)?.url || releaseDownloadUrl(asset.fileName);
|
||||
});
|
||||
|
||||
const devBranchUrl = computed(() => `${repoUrl.value}/tree/dev`);
|
||||
const docsHref = computed(() => withBase(locale.value === "ru" ? "docs/ru/" : "docs/"));
|
||||
const devBranchNote = computed(() =>
|
||||
locale.value === "ru"
|
||||
? "Самая свежая версия в ветке dev - можно развернуть локально."
|
||||
: "Freshest version is on the dev branch - clone and run it locally.",
|
||||
);
|
||||
|
||||
function clearHeroMessageTimers() {
|
||||
heroMessageTimers.forEach(window.clearTimeout);
|
||||
heroMessageTimers = [];
|
||||
}
|
||||
|
||||
function setHeroMessageTimer(callback: () => void, delay: number) {
|
||||
const id = window.setTimeout(callback, delay);
|
||||
heroMessageTimers.push(id);
|
||||
}
|
||||
|
||||
function runHeroMessageCycle() {
|
||||
clearHeroMessageTimers();
|
||||
|
||||
if (!isHeroVisible.value || heroReducedMotion.value || heroMessages.length === 0) {
|
||||
heroMessagePhase.value = "cooldown";
|
||||
return;
|
||||
}
|
||||
|
||||
heroMessagePhase.value = "sender";
|
||||
setHeroMessageTimer(() => {
|
||||
heroMessagePhase.value = "packet";
|
||||
}, 900);
|
||||
setHeroMessageTimer(() => {
|
||||
heroMessagePhase.value = "receiver";
|
||||
}, 2200);
|
||||
setHeroMessageTimer(() => {
|
||||
heroMessagePhase.value = "cooldown";
|
||||
}, 3900);
|
||||
setHeroMessageTimer(() => {
|
||||
activeHeroMessageIndex.value = (activeHeroMessageIndex.value + 1) % heroMessages.length;
|
||||
runHeroMessageCycle();
|
||||
}, 4700);
|
||||
}
|
||||
|
||||
function syncHeroMotion() {
|
||||
heroReducedMotion.value = Boolean(heroMotionQuery?.matches);
|
||||
runHeroMessageCycle();
|
||||
}
|
||||
|
||||
function onHeroVisibilityChange() {
|
||||
if (document.hidden) {
|
||||
clearHeroMessageTimers();
|
||||
heroMessagePhase.value = "cooldown";
|
||||
return;
|
||||
}
|
||||
|
||||
runHeroMessageCycle();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
downloadStore.init();
|
||||
|
||||
heroMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
heroReducedMotion.value = heroMotionQuery.matches;
|
||||
heroMotionQuery.addEventListener("change", syncHeroMotion);
|
||||
document.addEventListener("visibilitychange", onHeroVisibilityChange);
|
||||
|
||||
heroMessageObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
isHeroVisible.value = Boolean(entry?.isIntersecting);
|
||||
runHeroMessageCycle();
|
||||
},
|
||||
{ threshold: 0.15 },
|
||||
);
|
||||
|
||||
if (heroRef.value) heroMessageObserver.observe(heroRef.value);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearHeroMessageTimers();
|
||||
heroMessageObserver?.disconnect();
|
||||
heroMotionQuery?.removeEventListener("change", syncHeroMotion);
|
||||
document.removeEventListener("visibilitychange", onHeroVisibilityChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="hero" ref="heroRef" class="hero-section cyber-hero section anchor-offset" data-cyber-hero>
|
||||
<CyberHeroMontereyBackground />
|
||||
<div class="cyber-hero__background" aria-hidden="true" />
|
||||
<div class="cyber-hero__wash" aria-hidden="true" />
|
||||
<div class="cyber-hero__gridlines" aria-hidden="true" />
|
||||
|
|
@ -56,13 +131,13 @@ const devBranchNote = computed(() =>
|
|||
<v-container class="cyber-hero__container">
|
||||
<div class="cyber-hero__layout">
|
||||
<div class="cyber-hero__copy">
|
||||
<h1 class="cyber-hero__title">
|
||||
<span>Agent{{ " " }}</span>
|
||||
<h1 class="cyber-hero__title" aria-label="Agent Teams">
|
||||
<span>Agent</span>
|
||||
<span class="cyber-hero__title-accent">Teams</span>
|
||||
</h1>
|
||||
|
||||
<p class="cyber-hero__slogan cyber-panel">
|
||||
YOU'RE THE CTO, AGENTS ARE YOUR TEAM.
|
||||
Get a lot done by doing very little
|
||||
</p>
|
||||
|
||||
<p class="cyber-hero__description">
|
||||
|
|
@ -80,15 +155,6 @@ const devBranchNote = computed(() =>
|
|||
>
|
||||
{{ t("hero.downloadNow") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="large"
|
||||
href="#hero-demo"
|
||||
class="cyber-hero__action cyber-hero__action--watch"
|
||||
:prepend-icon="mdiPlayCircleOutline"
|
||||
>
|
||||
{{ t("hero.watchDemo") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="large"
|
||||
|
|
@ -100,26 +166,33 @@ const devBranchNote = computed(() =>
|
|||
</v-btn>
|
||||
</div>
|
||||
|
||||
<a
|
||||
<p
|
||||
v-if="releaseVersion"
|
||||
class="cyber-hero__terminal-note cyber-panel"
|
||||
:href="devBranchUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<span class="cyber-hero__terminal-lines">
|
||||
<span>> {{ devBranchNote }}</span>
|
||||
<span>> Team ready. What shall we build today?</span>
|
||||
<span class="cyber-hero__release">
|
||||
v{{ releaseVersion }}
|
||||
<span v-if="releaseDate" class="cyber-hero__release-date">
|
||||
· {{ releaseDate }}
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="releaseVersion" class="cyber-hero__release">
|
||||
v{{ releaseVersion }}<template v-if="releaseDate"> - {{ releaseDate }}</template>
|
||||
</span>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CyberHeroScene class="cyber-hero__scene" />
|
||||
<CyberHeroScene
|
||||
class="cyber-hero__scene"
|
||||
:message="activeHeroMessage"
|
||||
:phase="heroMessagePhase"
|
||||
:reduced-motion="heroReducedMotion"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CyberHeroFeatureStrip class="cyber-hero__feature-strip" />
|
||||
<CyberHeroFeatureStrip
|
||||
class="cyber-hero__feature-strip"
|
||||
:active-message="activeHeroMessage"
|
||||
:phase="heroMessagePhase"
|
||||
:reduced-motion="heroReducedMotion"
|
||||
/>
|
||||
</v-container>
|
||||
</section>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -399,7 +399,10 @@ function slideNext() {
|
|||
}
|
||||
|
||||
.screenshots-lightbox__nav {
|
||||
flex-shrink: 0;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 2;
|
||||
transform: translateY(-50%);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
|
|
@ -417,12 +420,20 @@ function slideNext() {
|
|||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.screenshots-lightbox__nav--prev {
|
||||
left: clamp(16px, 4vw, 48px);
|
||||
}
|
||||
|
||||
.screenshots-lightbox__nav--next {
|
||||
right: clamp(16px, 4vw, 48px);
|
||||
}
|
||||
|
||||
.screenshots-lightbox__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
max-width: 90vw;
|
||||
max-width: min(90vw, calc(100vw - 160px));
|
||||
max-height: 85vh;
|
||||
}
|
||||
|
||||
|
|
@ -517,6 +528,10 @@ function slideNext() {
|
|||
display: none;
|
||||
}
|
||||
|
||||
.screenshots-lightbox__content {
|
||||
max-width: 96vw;
|
||||
}
|
||||
|
||||
.screenshots-lightbox {
|
||||
padding: 60px 8px 20px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { nextTick, ref, onMounted, onUnmounted } from 'vue';
|
||||
import { mdiPlay, mdiPause, mdiVolumeHigh, mdiVolumeOff, mdiFullscreen } from '@mdi/js';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
|
@ -9,20 +9,51 @@ const containerRef = ref<HTMLElement | null>(null);
|
|||
const isPlaying = ref(false);
|
||||
const isMuted = ref(true);
|
||||
const showControls = ref(true);
|
||||
const isLoaded = ref(false);
|
||||
const isLoaded = ref(true);
|
||||
const hasError = ref(false);
|
||||
const progress = ref(0);
|
||||
const loadProgress = ref(0);
|
||||
const hideTimer = ref<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
let intObserver: IntersectionObserver | null = null;
|
||||
let loadFallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function clearLoadFallback() {
|
||||
if (!loadFallbackTimer) return;
|
||||
clearTimeout(loadFallbackTimer);
|
||||
loadFallbackTimer = null;
|
||||
}
|
||||
|
||||
function markLoaded() {
|
||||
if (hasError.value) return;
|
||||
isLoaded.value = true;
|
||||
clearLoadFallback();
|
||||
updateLoadProgress();
|
||||
}
|
||||
|
||||
function markError() {
|
||||
hasError.value = true;
|
||||
clearLoadFallback();
|
||||
}
|
||||
|
||||
function onVideoEnded() {
|
||||
const video = videoRef.value;
|
||||
isPlaying.value = false;
|
||||
showControls.value = true;
|
||||
progress.value = 0;
|
||||
if (video) video.currentTime = 0;
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
const video = videoRef.value;
|
||||
if (!video) return;
|
||||
if (video.paused) {
|
||||
video.play();
|
||||
isPlaying.value = true;
|
||||
markLoaded();
|
||||
video.play()
|
||||
.then(() => {
|
||||
isPlaying.value = true;
|
||||
})
|
||||
.catch(markError);
|
||||
} else {
|
||||
video.pause();
|
||||
isPlaying.value = false;
|
||||
|
|
@ -93,19 +124,25 @@ function onMouseLeave() {
|
|||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
const video = videoRef.value;
|
||||
if (video) {
|
||||
// canplay fires earlier than loadeddata — enough to show first frame
|
||||
video.addEventListener('canplay', () => { isLoaded.value = true; }, { once: true });
|
||||
video.addEventListener('error', () => { hasError.value = true; });
|
||||
isMuted.value = video.muted;
|
||||
video.addEventListener('loadedmetadata', markLoaded, { once: true });
|
||||
video.addEventListener('loadeddata', markLoaded, { once: true });
|
||||
video.addEventListener('canplay', markLoaded, { once: true });
|
||||
video.addEventListener('canplaythrough', markLoaded, { once: true });
|
||||
video.addEventListener('error', markError);
|
||||
video.addEventListener('progress', updateLoadProgress);
|
||||
video.addEventListener('ended', () => {
|
||||
isPlaying.value = false;
|
||||
showControls.value = true;
|
||||
progress.value = 0;
|
||||
video.currentTime = 0;
|
||||
});
|
||||
video.addEventListener('ended', onVideoEnded);
|
||||
|
||||
if (video.readyState >= HTMLMediaElement.HAVE_METADATA) {
|
||||
markLoaded();
|
||||
} else {
|
||||
video.load();
|
||||
loadFallbackTimer = setTimeout(markLoaded, 1800);
|
||||
}
|
||||
}
|
||||
|
||||
intObserver = new IntersectionObserver(
|
||||
|
|
@ -122,8 +159,15 @@ onMounted(() => {
|
|||
|
||||
onUnmounted(() => {
|
||||
if (hideTimer.value) clearTimeout(hideTimer.value);
|
||||
clearLoadFallback();
|
||||
if (intObserver) { intObserver.disconnect(); intObserver = null; }
|
||||
videoRef.value?.removeEventListener('loadedmetadata', markLoaded);
|
||||
videoRef.value?.removeEventListener('loadeddata', markLoaded);
|
||||
videoRef.value?.removeEventListener('canplay', markLoaded);
|
||||
videoRef.value?.removeEventListener('canplaythrough', markLoaded);
|
||||
videoRef.value?.removeEventListener('error', markError);
|
||||
videoRef.value?.removeEventListener('progress', updateLoadProgress);
|
||||
videoRef.value?.removeEventListener('ended', onVideoEnded);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -163,7 +207,7 @@ onUnmounted(() => {
|
|||
ref="videoRef"
|
||||
class="hero-video__player"
|
||||
:class="{ 'hero-video__player--loaded': isLoaded }"
|
||||
preload="auto"
|
||||
preload="metadata"
|
||||
poster="/screenshots/2.jpg"
|
||||
muted
|
||||
playsinline
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { computed, watch, onUnmounted } from "vue";
|
||||
import { computed, getCurrentInstance, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import type { Ref } from "vue";
|
||||
import { useThemeStore } from "~/stores/theme";
|
||||
|
||||
type ThemeName = "light" | "dark";
|
||||
|
||||
type VuetifyThemeInstance = {
|
||||
global: {
|
||||
name: Ref<string>;
|
||||
|
|
@ -10,40 +12,109 @@ type VuetifyThemeInstance = {
|
|||
change?: (name: string) => void;
|
||||
};
|
||||
|
||||
function isThemeName(value: string | null | undefined): value is ThemeName {
|
||||
return value === "dark" || value === "light";
|
||||
}
|
||||
|
||||
export const useBrowserTheme = () => {
|
||||
const themeStore = useThemeStore();
|
||||
const { $vuetifyTheme } = useNuxtApp();
|
||||
const vuetifyTheme = $vuetifyTheme as VuetifyThemeInstance | null;
|
||||
const documentTheme = ref<ThemeName | null>(null);
|
||||
let mediaQueryHandler: ((event: MediaQueryListEvent) => void) | null = null;
|
||||
let mediaQuery: MediaQueryList | null = null;
|
||||
let themeClassObserver: MutationObserver | null = null;
|
||||
|
||||
const applyVuetifyTheme = (name: "light" | "dark") => {
|
||||
if (!vuetifyTheme) return;
|
||||
if (typeof vuetifyTheme.change === "function") {
|
||||
vuetifyTheme.change(name);
|
||||
} else {
|
||||
vuetifyTheme.global.name.value = name;
|
||||
}
|
||||
const getDocumentTheme = (): ThemeName | null => {
|
||||
if (!import.meta.client) return null;
|
||||
|
||||
const appClass = document.querySelector(".v-application")?.classList;
|
||||
if (appClass?.contains("v-theme--dark")) return "dark";
|
||||
if (appClass?.contains("v-theme--light")) return "light";
|
||||
return null;
|
||||
};
|
||||
|
||||
const applyTheme = (name: "light" | "dark") => {
|
||||
themeStore.setTheme(name, true);
|
||||
const refreshDocumentTheme = () => {
|
||||
documentTheme.value = getDocumentTheme();
|
||||
return documentTheme.value;
|
||||
};
|
||||
|
||||
const applyDocumentTheme = (name: ThemeName) => {
|
||||
if (!import.meta.client) return;
|
||||
|
||||
document.querySelectorAll(".v-application").forEach((app) => {
|
||||
app.classList.toggle("v-theme--dark", name === "dark");
|
||||
app.classList.toggle("v-theme--light", name === "light");
|
||||
});
|
||||
documentTheme.value = name;
|
||||
};
|
||||
|
||||
const getAppliedTheme = (): ThemeName => {
|
||||
const domTheme = getDocumentTheme() ?? documentTheme.value;
|
||||
if (domTheme) return domTheme;
|
||||
|
||||
const vuetifyName = vuetifyTheme?.global.name.value;
|
||||
if (isThemeName(vuetifyName)) return vuetifyName;
|
||||
|
||||
return themeStore.current;
|
||||
};
|
||||
|
||||
const syncStoreFromAppliedTheme = () => {
|
||||
const appliedTheme = getAppliedTheme();
|
||||
if (themeStore.current !== appliedTheme) {
|
||||
themeStore.setTheme(appliedTheme, false);
|
||||
}
|
||||
return appliedTheme;
|
||||
};
|
||||
|
||||
const applyVuetifyTheme = (name: ThemeName) => {
|
||||
if (!vuetifyTheme) return;
|
||||
|
||||
if (vuetifyTheme.change) {
|
||||
vuetifyTheme.change(name);
|
||||
return;
|
||||
}
|
||||
|
||||
vuetifyTheme.global.name.value = name;
|
||||
};
|
||||
|
||||
const applyTheme = (name: ThemeName, fromUser = true) => {
|
||||
applyVuetifyTheme(name);
|
||||
applyDocumentTheme(name);
|
||||
themeStore.setTheme(name, fromUser);
|
||||
return name;
|
||||
};
|
||||
|
||||
const observeDocumentTheme = () => {
|
||||
if (!import.meta.client || themeClassObserver) return;
|
||||
|
||||
const app = document.querySelector(".v-application");
|
||||
if (!app) return;
|
||||
|
||||
refreshDocumentTheme();
|
||||
themeClassObserver = new MutationObserver(() => {
|
||||
refreshDocumentTheme();
|
||||
});
|
||||
themeClassObserver.observe(app, { attributes: true, attributeFilter: ["class"] });
|
||||
};
|
||||
|
||||
const initTheme = () => {
|
||||
if (!import.meta.client) return;
|
||||
const initialTheme = themeStore.getInitialTheme();
|
||||
themeStore.setTheme(initialTheme, false);
|
||||
applyVuetifyTheme(initialTheme);
|
||||
applyTheme(initialTheme, false);
|
||||
|
||||
if (mediaQuery && mediaQueryHandler) {
|
||||
mediaQuery.removeEventListener("change", mediaQueryHandler);
|
||||
mediaQuery = null;
|
||||
mediaQueryHandler = null;
|
||||
}
|
||||
|
||||
if (!themeStore.userSelected) {
|
||||
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mediaQueryHandler = (event: MediaQueryListEvent) => {
|
||||
if (!themeStore.userSelected) {
|
||||
const newTheme = event.matches ? "dark" : "light";
|
||||
themeStore.setTheme(newTheme, false);
|
||||
applyVuetifyTheme(newTheme);
|
||||
applyTheme(newTheme, false);
|
||||
}
|
||||
};
|
||||
mediaQuery.addEventListener("change", mediaQueryHandler);
|
||||
|
|
@ -51,25 +122,54 @@ export const useBrowserTheme = () => {
|
|||
};
|
||||
|
||||
const toggleTheme = () => {
|
||||
applyTheme(themeStore.current === "dark" ? "light" : "dark");
|
||||
const appliedTheme = syncStoreFromAppliedTheme();
|
||||
return applyTheme(appliedTheme === "dark" ? "light" : "dark");
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
if (mediaQuery && mediaQueryHandler) {
|
||||
mediaQuery.removeEventListener("change", mediaQueryHandler);
|
||||
}
|
||||
});
|
||||
if (getCurrentInstance()) {
|
||||
onMounted(() => {
|
||||
refreshDocumentTheme();
|
||||
observeDocumentTheme();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (mediaQuery && mediaQueryHandler) {
|
||||
mediaQuery.removeEventListener("change", mediaQueryHandler);
|
||||
}
|
||||
themeClassObserver?.disconnect();
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => themeStore.current,
|
||||
(value) => {
|
||||
applyVuetifyTheme(value as "light" | "dark");
|
||||
applyVuetifyTheme(value);
|
||||
}
|
||||
);
|
||||
|
||||
if (vuetifyTheme) {
|
||||
watch(
|
||||
() => vuetifyTheme.global.name.value,
|
||||
(value) => {
|
||||
if (isThemeName(value) && themeStore.current !== value) {
|
||||
themeStore.setTheme(value, false);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const currentTheme = computed(() => {
|
||||
if (documentTheme.value) return documentTheme.value;
|
||||
|
||||
const vuetifyName = vuetifyTheme?.global.name.value;
|
||||
return isThemeName(vuetifyName) ? vuetifyName : themeStore.current;
|
||||
});
|
||||
|
||||
const isDark = computed(() => currentTheme.value === "dark");
|
||||
|
||||
return {
|
||||
currentTheme: computed(() => themeStore.current),
|
||||
isDark: computed(() => themeStore.current === "dark"),
|
||||
currentTheme,
|
||||
isDark,
|
||||
initTheme,
|
||||
toggleTheme
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,25 +1,15 @@
|
|||
import type { Ref } from "vue";
|
||||
import { nextTick, onMounted, onUnmounted } from "vue";
|
||||
|
||||
type PointerState = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
|
||||
let rafId = 0;
|
||||
let bounds: DOMRect | null = null;
|
||||
let reduceMotion: MediaQueryList | null = null;
|
||||
let canHover: MediaQueryList | null = null;
|
||||
let observer: IntersectionObserver | null = null;
|
||||
let isVisible = true;
|
||||
|
||||
const pointer: PointerState = { x: 0, y: 0 };
|
||||
let scrollOffset = 0;
|
||||
|
||||
const shouldRun = () => {
|
||||
if (reduceMotion?.matches) return false;
|
||||
if (canHover && !canHover.matches) return false;
|
||||
return window.innerWidth >= 768 && isVisible;
|
||||
};
|
||||
|
||||
|
|
@ -28,20 +18,17 @@ export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
|
|||
const root = rootRef.value;
|
||||
if (!root) return;
|
||||
|
||||
root.style.setProperty("--hero-pointer-x", "0");
|
||||
root.style.setProperty("--hero-pointer-y", "0");
|
||||
root.style.setProperty("--hero-tilt-x", "0");
|
||||
root.style.setProperty("--hero-tilt-y", "0");
|
||||
|
||||
if (!shouldRun()) {
|
||||
root.style.setProperty("--hero-pointer-x", "0");
|
||||
root.style.setProperty("--hero-pointer-y", "0");
|
||||
root.style.setProperty("--hero-scroll", "0");
|
||||
root.style.setProperty("--hero-tilt-x", "0");
|
||||
root.style.setProperty("--hero-tilt-y", "0");
|
||||
return;
|
||||
}
|
||||
|
||||
root.style.setProperty("--hero-pointer-x", pointer.x.toFixed(4));
|
||||
root.style.setProperty("--hero-pointer-y", pointer.y.toFixed(4));
|
||||
root.style.setProperty("--hero-scroll", scrollOffset.toFixed(2));
|
||||
root.style.setProperty("--hero-tilt-x", pointer.x.toFixed(4));
|
||||
root.style.setProperty("--hero-tilt-y", pointer.y.toFixed(4));
|
||||
};
|
||||
|
||||
const requestWrite = () => {
|
||||
|
|
@ -49,29 +36,6 @@ export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
|
|||
rafId = requestAnimationFrame(writeVars);
|
||||
};
|
||||
|
||||
const updateBounds = () => {
|
||||
bounds = rootRef.value?.getBoundingClientRect() ?? null;
|
||||
};
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
if (!shouldRun()) return;
|
||||
if (!bounds) updateBounds();
|
||||
if (!bounds) return;
|
||||
|
||||
const nextX = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
|
||||
const nextY = ((event.clientY - bounds.top) / bounds.height) * 2 - 1;
|
||||
|
||||
pointer.x = Math.max(-1, Math.min(1, nextX));
|
||||
pointer.y = Math.max(-1, Math.min(1, nextY));
|
||||
requestWrite();
|
||||
};
|
||||
|
||||
const onPointerLeave = () => {
|
||||
pointer.x = 0;
|
||||
pointer.y = 0;
|
||||
requestWrite();
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
const root = rootRef.value;
|
||||
if (!root || !shouldRun()) return;
|
||||
|
|
@ -81,7 +45,6 @@ export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
|
|||
};
|
||||
|
||||
const onResize = () => {
|
||||
updateBounds();
|
||||
requestWrite();
|
||||
};
|
||||
|
||||
|
|
@ -91,7 +54,6 @@ export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
|
|||
if (!root) return;
|
||||
|
||||
reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
canHover = window.matchMedia("(hover: hover) and (pointer: fine)");
|
||||
observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
isVisible = entry.isIntersecting;
|
||||
|
|
@ -101,25 +63,17 @@ export function useCyberHeroParallax(rootRef: Ref<HTMLElement | null>) {
|
|||
);
|
||||
|
||||
observer.observe(root);
|
||||
updateBounds();
|
||||
root.addEventListener("pointermove", onPointerMove, { passive: true });
|
||||
root.addEventListener("pointerleave", onPointerLeave, { passive: true });
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
window.addEventListener("resize", onResize, { passive: true });
|
||||
reduceMotion.addEventListener("change", requestWrite);
|
||||
canHover.addEventListener("change", requestWrite);
|
||||
requestWrite();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
const root = rootRef.value;
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
observer?.disconnect();
|
||||
root?.removeEventListener("pointermove", onPointerMove);
|
||||
root?.removeEventListener("pointerleave", onPointerLeave);
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
window.removeEventListener("resize", onResize);
|
||||
reduceMotion?.removeEventListener("change", requestWrite);
|
||||
canHover?.removeEventListener("change", requestWrite);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import robotAmber from "~/assets/images/hero/robots/robot-amber-v1.webp";
|
||||
import robotCyan from "~/assets/images/hero/robots/robot-cyan-v1.webp";
|
||||
import robotMagenta from "~/assets/images/hero/robots/robot-magenta-v1.webp";
|
||||
|
||||
export const HERO_SCENE_VIEWBOX = {
|
||||
width: 1600,
|
||||
height: 900,
|
||||
} as const;
|
||||
import robotAvatarCyan from "~/assets/images/hero/robots/robot-avatar-cyan-cat-v1.webp";
|
||||
import robotAvatarReviewerTeal from "~/assets/images/hero/robots/robot-avatar-reviewer-teal-v1.webp";
|
||||
import robotAvatarSeatedMagenta from "~/assets/images/hero/robots/robot-avatar-seated-magenta-v1.webp";
|
||||
import robotAvatarYellow from "~/assets/images/hero/robots/robot-avatar-yellow-star-v1.webp";
|
||||
import robotRedPurpleHandshake from "~/assets/images/hero/robots/robot-red-purple-handshake-v1.webp";
|
||||
|
||||
export const HERO_SCENE_BREAKPOINTS = {
|
||||
desktop: 1200,
|
||||
|
|
@ -25,6 +22,7 @@ export type HeroAgentRole =
|
|||
| "fixer";
|
||||
|
||||
export type HeroAccent = "cyan" | "magenta" | "violet" | "amber" | "red";
|
||||
export type HeroMessagePhase = "sender" | "packet" | "receiver" | "cooldown";
|
||||
|
||||
export type HeroCardSide = "left" | "right" | "bottom";
|
||||
|
||||
|
|
@ -41,6 +39,8 @@ export type HeroAgent = {
|
|||
label: string;
|
||||
asset: string;
|
||||
accent: HeroAccent;
|
||||
facing?: -1 | 1;
|
||||
lean?: number;
|
||||
priority?: boolean;
|
||||
desktop: HeroAgentPosition;
|
||||
tablet: HeroAgentPosition;
|
||||
|
|
@ -53,21 +53,10 @@ export type HeroAgent = {
|
|||
tasks: string[];
|
||||
};
|
||||
|
||||
export type HeroConnection = {
|
||||
id: string;
|
||||
from: HeroAgentRole | "video";
|
||||
to: HeroAgentRole | "video";
|
||||
accent: Extract<HeroAccent, "cyan" | "magenta" | "amber">;
|
||||
pathDesktop: string;
|
||||
packetDelayMs: number;
|
||||
packetDurationMs: number;
|
||||
};
|
||||
|
||||
export type HeroMessage = {
|
||||
id: string;
|
||||
from: HeroAgentRole;
|
||||
to: HeroAgentRole | "video";
|
||||
connectionId: string;
|
||||
text: string;
|
||||
response: string;
|
||||
fromX: number;
|
||||
|
|
@ -80,11 +69,13 @@ export const heroAgents: readonly HeroAgent[] = [
|
|||
{
|
||||
id: "planner",
|
||||
label: "Planner",
|
||||
asset: robotCyan,
|
||||
accent: "cyan",
|
||||
asset: robotAvatarSeatedMagenta,
|
||||
accent: "magenta",
|
||||
facing: 1,
|
||||
lean: -2,
|
||||
priority: true,
|
||||
desktop: { x: 34, y: 12, scale: 0.66, depth: 0.35, card: "right" },
|
||||
tablet: { x: 18, y: 11, scale: 0.55, depth: 0.22, card: "bottom" },
|
||||
desktop: { x: 32.65, y: 19.45, scale: 0.44, depth: 0.35, card: "right" },
|
||||
tablet: { x: 20, y: 31, scale: 0.44, depth: 0.22, card: "bottom" },
|
||||
mobile: { visible: true, order: 1, compactLabel: "Plan" },
|
||||
status: "Planning",
|
||||
tasks: ["Analyze requirements", "Break down tasks", "Create plan"],
|
||||
|
|
@ -92,256 +83,116 @@ export const heroAgents: readonly HeroAgent[] = [
|
|||
{
|
||||
id: "lead",
|
||||
label: "Lead",
|
||||
asset: robotCyan,
|
||||
asset: robotAvatarCyan,
|
||||
accent: "cyan",
|
||||
facing: -1,
|
||||
lean: 2,
|
||||
priority: true,
|
||||
desktop: { x: 55, y: 9, scale: 0.62, depth: 0.32, card: "right" },
|
||||
tablet: { x: 50, y: 8, scale: 0.52, depth: 0.2, card: "bottom" },
|
||||
desktop: { x: 58.9, y: 32.76, scale: 0.48, depth: 0.32, card: "right" },
|
||||
tablet: { x: 50, y: 31, scale: 0.42, depth: 0.2, card: "bottom" },
|
||||
mobile: { visible: true, order: 2, compactLabel: "Lead" },
|
||||
status: "Leading",
|
||||
tasks: ["Define architecture", "Set priorities", "Coordinate team"],
|
||||
},
|
||||
{
|
||||
id: "reviewer",
|
||||
label: "Reviewer",
|
||||
asset: robotMagenta,
|
||||
accent: "magenta",
|
||||
priority: true,
|
||||
desktop: { x: 75, y: 13, scale: 0.58, depth: 0.34, card: "left" },
|
||||
tablet: { x: 82, y: 12, scale: 0.48, depth: 0.22, card: "bottom" },
|
||||
mobile: { visible: true, order: 3, compactLabel: "Review" },
|
||||
status: "Reviewing",
|
||||
tasks: ["Review code", "Check quality", "Request changes"],
|
||||
},
|
||||
{
|
||||
id: "researcher",
|
||||
label: "Researcher",
|
||||
asset: robotCyan,
|
||||
accent: "violet",
|
||||
desktop: { x: 27, y: 39, scale: 0.48, depth: 0.45, card: "right" },
|
||||
tablet: { x: 16, y: 45, scale: 0.44, depth: 0.25, card: "bottom" },
|
||||
mobile: { visible: false },
|
||||
status: "Researching",
|
||||
tasks: ["Research options", "Compare solutions", "Summarize findings"],
|
||||
},
|
||||
{
|
||||
id: "developer",
|
||||
label: "Developer",
|
||||
asset: robotCyan,
|
||||
accent: "cyan",
|
||||
desktop: { x: 74, y: 34, scale: 0.5, depth: 0.52, card: "left" },
|
||||
tablet: { x: 88, y: 44, scale: 0.42, depth: 0.26, card: "bottom" },
|
||||
mobile: { visible: false },
|
||||
asset: robotAvatarYellow,
|
||||
accent: "amber",
|
||||
facing: 1,
|
||||
lean: -1,
|
||||
priority: true,
|
||||
desktop: { x: 72, y: 32.4, scale: 0.48, depth: 0.34, card: "right" },
|
||||
tablet: { x: 80, y: 31, scale: 0.4, depth: 0.22, card: "bottom" },
|
||||
mobile: { visible: true, order: 3, compactLabel: "Code" },
|
||||
status: "Coding",
|
||||
tasks: ["Write code", "Implement feature", "Commit changes"],
|
||||
},
|
||||
{
|
||||
id: "tester",
|
||||
label: "Tester",
|
||||
asset: robotMagenta,
|
||||
accent: "magenta",
|
||||
desktop: { x: 72, y: 59, scale: 0.48, depth: 0.58, card: "left" },
|
||||
tablet: { x: 76, y: 77, scale: 0.4, depth: 0.28, card: "bottom" },
|
||||
mobile: { visible: false },
|
||||
status: "Testing",
|
||||
tasks: ["Write tests", "Run tests", "Report issues"],
|
||||
},
|
||||
{
|
||||
id: "docs",
|
||||
label: "Docs",
|
||||
asset: robotMagenta,
|
||||
accent: "violet",
|
||||
desktop: { x: 30, y: 64, scale: 0.43, depth: 0.55, card: "right" },
|
||||
tablet: { x: 25, y: 78, scale: 0.36, depth: 0.28, card: "bottom" },
|
||||
mobile: { visible: false },
|
||||
status: "Writing",
|
||||
tasks: ["Write docs", "API reference", "Examples"],
|
||||
},
|
||||
{
|
||||
id: "ops",
|
||||
label: "Ops",
|
||||
asset: robotAmber,
|
||||
accent: "amber",
|
||||
desktop: { x: 43, y: 84, scale: 0.46, depth: 0.7, card: "right" },
|
||||
tablet: { x: 42, y: 83, scale: 0.38, depth: 0.34, card: "bottom" },
|
||||
mobile: { visible: false },
|
||||
status: "Deploying",
|
||||
tasks: ["Deploy services", "Monitor health", "Manage infra"],
|
||||
},
|
||||
{
|
||||
id: "security",
|
||||
label: "Security",
|
||||
asset: robotAmber,
|
||||
accent: "red",
|
||||
desktop: { x: 63, y: 85, scale: 0.42, depth: 0.68, card: "right" },
|
||||
tablet: { x: 60, y: 82, scale: 0.34, depth: 0.32, card: "bottom" },
|
||||
mobile: { visible: false },
|
||||
status: "Secure",
|
||||
tasks: ["Scan dependencies", "Check permissions", "Security review"],
|
||||
},
|
||||
{
|
||||
id: "fixer",
|
||||
label: "Fixer",
|
||||
asset: robotAmber,
|
||||
accent: "amber",
|
||||
desktop: { x: 69, y: 83, scale: 0.42, depth: 0.72, card: "left" },
|
||||
tablet: { x: 90, y: 82, scale: 0.36, depth: 0.34, card: "bottom" },
|
||||
mobile: { visible: false },
|
||||
status: "Fixing",
|
||||
tasks: ["Fix issues", "Refactor code", "Optimize"],
|
||||
tasks: ["Implement feature", "Update code", "Run checks"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const heroConnections: readonly HeroConnection[] = [
|
||||
{
|
||||
id: "planner-lead",
|
||||
from: "planner",
|
||||
to: "lead",
|
||||
accent: "cyan",
|
||||
pathDesktop: "M 545 195 C 680 210, 735 185, 860 190",
|
||||
packetDelayMs: 0,
|
||||
packetDurationMs: 4200,
|
||||
},
|
||||
{
|
||||
id: "lead-reviewer",
|
||||
from: "lead",
|
||||
to: "reviewer",
|
||||
accent: "magenta",
|
||||
pathDesktop: "M 950 205 C 1050 185, 1130 190, 1265 220",
|
||||
packetDelayMs: 700,
|
||||
packetDurationMs: 3900,
|
||||
},
|
||||
{
|
||||
id: "developer-reviewer",
|
||||
from: "developer",
|
||||
to: "reviewer",
|
||||
accent: "magenta",
|
||||
pathDesktop: "M 1390 370 C 1325 320, 1305 270, 1260 230",
|
||||
packetDelayMs: 500,
|
||||
packetDurationMs: 3400,
|
||||
},
|
||||
{
|
||||
id: "researcher-video",
|
||||
from: "researcher",
|
||||
to: "video",
|
||||
accent: "cyan",
|
||||
pathDesktop: "M 520 425 C 625 410, 680 405, 755 420",
|
||||
packetDelayMs: 1100,
|
||||
packetDurationMs: 4400,
|
||||
},
|
||||
{
|
||||
id: "video-tester",
|
||||
from: "video",
|
||||
to: "tester",
|
||||
accent: "magenta",
|
||||
pathDesktop: "M 1290 540 C 1365 555, 1410 575, 1480 615",
|
||||
packetDelayMs: 1300,
|
||||
packetDurationMs: 4100,
|
||||
},
|
||||
{
|
||||
id: "tester-lead",
|
||||
from: "tester",
|
||||
to: "lead",
|
||||
accent: "cyan",
|
||||
pathDesktop: "M 1450 625 C 1365 650, 1170 642, 1030 630 C 940 620, 880 585, 850 515",
|
||||
packetDelayMs: 1800,
|
||||
packetDurationMs: 5200,
|
||||
},
|
||||
{
|
||||
id: "ops-security",
|
||||
from: "ops",
|
||||
to: "security",
|
||||
accent: "amber",
|
||||
pathDesktop: "M 745 740 C 835 725, 910 725, 1000 742",
|
||||
packetDelayMs: 2200,
|
||||
packetDurationMs: 4600,
|
||||
},
|
||||
{
|
||||
id: "security-fixer",
|
||||
from: "security",
|
||||
to: "fixer",
|
||||
accent: "amber",
|
||||
pathDesktop: "M 1100 745 C 1185 725, 1270 730, 1375 755",
|
||||
packetDelayMs: 2600,
|
||||
packetDurationMs: 4600,
|
||||
},
|
||||
] as const;
|
||||
export const heroReviewerFeatureCard = {
|
||||
label: "Reviewer",
|
||||
asset: robotAvatarReviewerTeal,
|
||||
accent: "cyan",
|
||||
status: "Reviewing",
|
||||
tasks: ["Review code", "Check quality", "Request changes"],
|
||||
} as const;
|
||||
|
||||
export const heroCollaborationFeature = {
|
||||
asset: robotRedPurpleHandshake,
|
||||
} as const;
|
||||
|
||||
export const heroMessages: readonly HeroMessage[] = [
|
||||
{
|
||||
id: "code-review",
|
||||
id: "plan-ready",
|
||||
from: "planner",
|
||||
to: "lead",
|
||||
text: "Plan ready.",
|
||||
response: "Priority set.",
|
||||
fromX: 29.2,
|
||||
fromY: 13,
|
||||
toX: 58.8,
|
||||
toY: 8.6,
|
||||
},
|
||||
{
|
||||
id: "build-ready",
|
||||
from: "lead",
|
||||
to: "developer",
|
||||
text: "Build scope set.",
|
||||
response: "Coding started.",
|
||||
fromX: 58.8,
|
||||
fromY: 8.6,
|
||||
toX: 72,
|
||||
toY: 7,
|
||||
},
|
||||
{
|
||||
id: "review-build",
|
||||
from: "developer",
|
||||
to: "reviewer",
|
||||
connectionId: "developer-reviewer",
|
||||
text: "Code ready. Request review.",
|
||||
response: "Review started.",
|
||||
fromX: 78,
|
||||
fromY: 43,
|
||||
toX: 73,
|
||||
toY: 20,
|
||||
text: "Review build.",
|
||||
response: "Checking quality.",
|
||||
fromX: 72,
|
||||
fromY: 7,
|
||||
toX: 84,
|
||||
toY: 82,
|
||||
},
|
||||
{
|
||||
id: "tests-passed",
|
||||
from: "tester",
|
||||
to: "lead",
|
||||
connectionId: "tester-lead",
|
||||
text: "Tests passed. Looks good.",
|
||||
response: "Ship it.",
|
||||
fromX: 78,
|
||||
fromY: 62,
|
||||
toX: 58,
|
||||
toY: 21,
|
||||
},
|
||||
{
|
||||
id: "research-ready",
|
||||
from: "researcher",
|
||||
to: "video",
|
||||
connectionId: "researcher-video",
|
||||
text: "Findings ready.",
|
||||
response: "Plan updated.",
|
||||
fromX: 32,
|
||||
fromY: 45,
|
||||
toX: 50,
|
||||
toY: 53,
|
||||
},
|
||||
{
|
||||
id: "ops-secure",
|
||||
from: "ops",
|
||||
to: "security",
|
||||
connectionId: "ops-security",
|
||||
text: "Deployed to staging.",
|
||||
response: "Dependencies checked.",
|
||||
fromX: 44,
|
||||
fromY: 72,
|
||||
toX: 62,
|
||||
toY: 74,
|
||||
id: "review-pass",
|
||||
from: "reviewer",
|
||||
to: "developer",
|
||||
text: "Review passed.",
|
||||
response: "Ready to ship.",
|
||||
fromX: 84,
|
||||
fromY: 82,
|
||||
toX: 72,
|
||||
toY: 7,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const heroFeatureRail = [
|
||||
{
|
||||
id: "autonomous",
|
||||
title: "Autonomous Team",
|
||||
text: "Specialized agents coordinate work together.",
|
||||
title: "Give the Team a Goal",
|
||||
text: "Agents break it into tasks and start moving without babysitting.",
|
||||
},
|
||||
{
|
||||
id: "kanban",
|
||||
title: "Kanban at Lightspeed",
|
||||
text: "Tasks move as agents build, review, and test.",
|
||||
title: "Kanban That Updates Itself",
|
||||
text: "Cards shift as agents build, test, review, and unblock each other.",
|
||||
},
|
||||
{
|
||||
id: "developers",
|
||||
title: "Built for Developers",
|
||||
text: "Open source, extensible, and API-first.",
|
||||
title: "Bring Your AI Stack",
|
||||
text: "Claude, Codex, and OpenCode teammates in one desktop cockpit.",
|
||||
},
|
||||
{
|
||||
id: "secure",
|
||||
title: "Secure by Default",
|
||||
text: "Your code and data stay protected.",
|
||||
title: "Stay in the Loop",
|
||||
text: "Jump in with comments, approvals, direct messages, or quick actions.",
|
||||
},
|
||||
{
|
||||
id: "local",
|
||||
title: "Local First",
|
||||
text: "Runs on your machine. Your data stays yours.",
|
||||
title: "Your Machine, Your Code",
|
||||
text: "Local-first workflow with task logs, process control, and Git visibility.",
|
||||
},
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@
|
|||
"sectionTitle": "كيف نقارن",
|
||||
"sectionSubtitle": "مقارنة تفصيلية للمميزات مع أدوات البرمجة بالذكاء الاصطناعي الأخرى.",
|
||||
"feature": "الميزة",
|
||||
"robotBubble": "احكم بنفسك",
|
||||
"features": {
|
||||
"crossTeam": "التواصل بين الفرق",
|
||||
"agentMessaging": "مراسلة بين الوكلاء",
|
||||
|
|
@ -77,12 +78,17 @@
|
|||
"reviewWorkflow": "سير عمل المراجعة",
|
||||
"zeroSetup": "بدون إعداد",
|
||||
"kanban": "لوحة كانبان",
|
||||
"execLog": "عارض سجلات التنفيذ",
|
||||
"execLog": "سجلات التنفيذ",
|
||||
"liveProcesses": "عمليات مباشرة",
|
||||
"runtimeLoad": "CPU/RAM لكل زميل",
|
||||
"perTaskReview": "مراجعة كود لكل مهمة",
|
||||
"flexAutonomy": "استقلالية مرنة",
|
||||
"worktree": "عزل Git worktree",
|
||||
"multiAgent": "خلفية متعددة الوكلاء",
|
||||
"multiAgent": "زملاء AI مختلطون",
|
||||
"liveWorkGraph": "خريطة فريق حية",
|
||||
"liveTeam": "زملاء مباشرين",
|
||||
"teamWorkspace": "مساحة عمل الفريق",
|
||||
"launchProof": "حالة تشغيل الزملاء",
|
||||
"orgGovernance": "الهيكل التنظيمي / الحوكمة",
|
||||
"budgetControls": "ضوابط الميزانية",
|
||||
"price": "السعر"
|
||||
|
|
@ -98,6 +104,7 @@
|
|||
"footer": {
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "تنسيق وكلاء الذكاء الاصطناعي للمطورين",
|
||||
"robotBubble": "أنا أنتظر",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
"docs": "التوثيق"
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@
|
|||
"sectionTitle": "Wie wir im Vergleich abschneiden",
|
||||
"sectionSubtitle": "Funktionsvergleich mit anderen KI-Coding-Tools.",
|
||||
"feature": "Funktion",
|
||||
"robotBubble": "Urteile selbst",
|
||||
"features": {
|
||||
"crossTeam": "Teamübergreifende Kommunikation",
|
||||
"agentMessaging": "Agent-zu-Agent-Messaging",
|
||||
|
|
@ -77,12 +78,17 @@
|
|||
"reviewWorkflow": "Review-Workflow",
|
||||
"zeroSetup": "Keine Einrichtung",
|
||||
"kanban": "Kanban-Board",
|
||||
"execLog": "Ausführungsprotokoll",
|
||||
"execLog": "Ausführungslogs",
|
||||
"liveProcesses": "Live-Prozesse",
|
||||
"runtimeLoad": "CPU/RAM pro Teammitglied",
|
||||
"perTaskReview": "Code-Review pro Aufgabe",
|
||||
"flexAutonomy": "Flexible Autonomie",
|
||||
"worktree": "Git-Worktree-Isolation",
|
||||
"multiAgent": "Multi-Agenten-Backend",
|
||||
"multiAgent": "Gemischte KI-Teammitglieder",
|
||||
"liveWorkGraph": "Live-Teamkarte",
|
||||
"liveTeam": "Live-Teammitglieder",
|
||||
"teamWorkspace": "Team-Workspace",
|
||||
"launchProof": "Startstatus der Teammitglieder",
|
||||
"orgGovernance": "Organigramm / Governance",
|
||||
"budgetControls": "Budgetkontrollen",
|
||||
"price": "Preis"
|
||||
|
|
@ -98,6 +104,7 @@
|
|||
"footer": {
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "KI-Agenten-Orchestrierung für Entwickler",
|
||||
"robotBubble": "Ich warte",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
"docs": "Dokumentation"
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@
|
|||
"sectionTitle": "How we compare",
|
||||
"sectionSubtitle": "Feature-by-feature comparison with other AI coding tools.",
|
||||
"feature": "Feature",
|
||||
"robotBubble": "Judge for yourself",
|
||||
"features": {
|
||||
"crossTeam": "Cross-team communication",
|
||||
"agentMessaging": "Agent-to-agent messaging",
|
||||
|
|
@ -77,12 +78,17 @@
|
|||
"reviewWorkflow": "Review workflow",
|
||||
"zeroSetup": "Zero setup",
|
||||
"kanban": "Kanban board",
|
||||
"execLog": "Execution log viewer",
|
||||
"execLog": "Execution logs",
|
||||
"liveProcesses": "Live processes",
|
||||
"runtimeLoad": "CPU/RAM per teammate",
|
||||
"perTaskReview": "Per-task code review",
|
||||
"flexAutonomy": "Flexible autonomy",
|
||||
"worktree": "Git worktree isolation",
|
||||
"multiAgent": "Multi-agent backend",
|
||||
"multiAgent": "Mixed AI teammates",
|
||||
"liveWorkGraph": "Live team map",
|
||||
"liveTeam": "Live teammates",
|
||||
"teamWorkspace": "Team workspace",
|
||||
"launchProof": "Teammate launch status",
|
||||
"orgGovernance": "Org chart / governance",
|
||||
"budgetControls": "Budget controls",
|
||||
"price": "Price"
|
||||
|
|
@ -98,6 +104,7 @@
|
|||
"footer": {
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "AI agent orchestration for developers",
|
||||
"robotBubble": "I'm waiting",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
"docs": "Documentation"
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@
|
|||
"sectionTitle": "Cómo nos comparamos",
|
||||
"sectionSubtitle": "Comparación detallada de funciones con otras herramientas de programación con IA.",
|
||||
"feature": "Función",
|
||||
"robotBubble": "Juzga tú",
|
||||
"features": {
|
||||
"crossTeam": "Comunicación entre equipos",
|
||||
"agentMessaging": "Mensajería entre agentes",
|
||||
|
|
@ -77,12 +78,17 @@
|
|||
"reviewWorkflow": "Flujo de revisión",
|
||||
"zeroSetup": "Sin configuración",
|
||||
"kanban": "Tablero Kanban",
|
||||
"execLog": "Visor de logs de ejecución",
|
||||
"execLog": "Logs de ejecución",
|
||||
"liveProcesses": "Procesos en vivo",
|
||||
"runtimeLoad": "CPU/RAM por compañero",
|
||||
"perTaskReview": "Revisión de código por tarea",
|
||||
"flexAutonomy": "Autonomía flexible",
|
||||
"worktree": "Aislamiento Git worktree",
|
||||
"multiAgent": "Backend multi-agente",
|
||||
"multiAgent": "Compañeros de IA mixtos",
|
||||
"liveWorkGraph": "Mapa del equipo en vivo",
|
||||
"liveTeam": "Compañeros en vivo",
|
||||
"teamWorkspace": "Espacio de trabajo del equipo",
|
||||
"launchProof": "Estado de inicio de compañeros",
|
||||
"orgGovernance": "Organigrama / gobernanza",
|
||||
"budgetControls": "Controles de presupuesto",
|
||||
"price": "Precio"
|
||||
|
|
@ -98,6 +104,7 @@
|
|||
"footer": {
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "Orquestación de agentes IA para desarrolladores",
|
||||
"robotBubble": "Estoy esperando",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
"docs": "Documentación"
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@
|
|||
"sectionTitle": "Comment nous nous comparons",
|
||||
"sectionSubtitle": "Comparaison fonctionnalité par fonctionnalité avec d'autres outils IA.",
|
||||
"feature": "Fonctionnalité",
|
||||
"robotBubble": "Juge par toi-même",
|
||||
"features": {
|
||||
"crossTeam": "Communication inter-équipes",
|
||||
"agentMessaging": "Messagerie entre agents",
|
||||
|
|
@ -77,12 +78,17 @@
|
|||
"reviewWorkflow": "Flux de revue",
|
||||
"zeroSetup": "Zéro configuration",
|
||||
"kanban": "Tableau Kanban",
|
||||
"execLog": "Journal d'exécution",
|
||||
"execLog": "Logs d'exécution",
|
||||
"liveProcesses": "Processus en direct",
|
||||
"runtimeLoad": "CPU/RAM par coéquipier",
|
||||
"perTaskReview": "Revue de code par tâche",
|
||||
"flexAutonomy": "Autonomie flexible",
|
||||
"worktree": "Isolation Git worktree",
|
||||
"multiAgent": "Backend multi-agents",
|
||||
"multiAgent": "Coéquipiers IA mixtes",
|
||||
"liveWorkGraph": "Carte d'équipe en direct",
|
||||
"liveTeam": "Coéquipiers en direct",
|
||||
"teamWorkspace": "Espace de travail d'équipe",
|
||||
"launchProof": "Statut de lancement des coéquipiers",
|
||||
"orgGovernance": "Organigramme / gouvernance",
|
||||
"budgetControls": "Contrôle budgétaire",
|
||||
"price": "Prix"
|
||||
|
|
@ -98,6 +104,7 @@
|
|||
"footer": {
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "Orchestration d'agents IA pour développeurs",
|
||||
"robotBubble": "J'attends",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
"docs": "Documentation"
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@
|
|||
"sectionTitle": "तुलना करें",
|
||||
"sectionSubtitle": "अन्य AI कोडिंग टूल्स के साथ सुविधा-दर-सुविधा तुलना।",
|
||||
"feature": "सुविधा",
|
||||
"robotBubble": "खुद फैसला करें",
|
||||
"features": {
|
||||
"crossTeam": "क्रॉस-टीम संचार",
|
||||
"agentMessaging": "एजेंट-टू-एजेंट मैसेजिंग",
|
||||
|
|
@ -77,12 +78,17 @@
|
|||
"reviewWorkflow": "रिव्यू वर्कफ़्लो",
|
||||
"zeroSetup": "शून्य सेटअप",
|
||||
"kanban": "कानबन बोर्ड",
|
||||
"execLog": "एक्ज़ीक्यूशन लॉग व्यूअर",
|
||||
"execLog": "एक्ज़ीक्यूशन लॉग",
|
||||
"liveProcesses": "लाइव प्रोसेस",
|
||||
"runtimeLoad": "हर टीममेट का CPU/RAM",
|
||||
"perTaskReview": "प्रति-कार्य कोड रिव्यू",
|
||||
"flexAutonomy": "लचीली स्वायत्तता",
|
||||
"worktree": "Git worktree आइसोलेशन",
|
||||
"multiAgent": "मल्टी-एजेंट बैकएंड",
|
||||
"multiAgent": "मिक्स्ड AI टीममेट्स",
|
||||
"liveWorkGraph": "लाइव टीम मैप",
|
||||
"liveTeam": "लाइव टीममेट्स",
|
||||
"teamWorkspace": "टीम वर्कस्पेस",
|
||||
"launchProof": "टीममेट लॉन्च स्टेटस",
|
||||
"orgGovernance": "ऑर्ग चार्ट / गवर्नेंस",
|
||||
"budgetControls": "बजट नियंत्रण",
|
||||
"price": "कीमत"
|
||||
|
|
@ -98,6 +104,7 @@
|
|||
"footer": {
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "डेवलपर्स के लिए AI एजेंट ऑर्केस्ट्रेशन",
|
||||
"robotBubble": "मैं इंतज़ार कर रहा हूँ",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
"docs": "दस्तावेज़"
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@
|
|||
"sectionTitle": "他ツールとの比較",
|
||||
"sectionSubtitle": "他のAIコーディングツールとの機能比較。",
|
||||
"feature": "機能",
|
||||
"robotBubble": "自分で判断して",
|
||||
"features": {
|
||||
"crossTeam": "チーム間コミュニケーション",
|
||||
"agentMessaging": "エージェント間メッセージング",
|
||||
|
|
@ -77,12 +78,17 @@
|
|||
"reviewWorkflow": "レビューワークフロー",
|
||||
"zeroSetup": "ゼロ設定",
|
||||
"kanban": "カンバンボード",
|
||||
"execLog": "実行ログビューア",
|
||||
"execLog": "実行ログ",
|
||||
"liveProcesses": "ライブプロセス",
|
||||
"runtimeLoad": "チームメイトごとのCPU/RAM",
|
||||
"perTaskReview": "タスク別コードレビュー",
|
||||
"flexAutonomy": "柔軟な自律性",
|
||||
"worktree": "Git worktree分離",
|
||||
"multiAgent": "マルチエージェントバックエンド",
|
||||
"multiAgent": "混在AIチームメイト",
|
||||
"liveWorkGraph": "ライブチームマップ",
|
||||
"liveTeam": "ライブチームメイト",
|
||||
"teamWorkspace": "チームワークスペース",
|
||||
"launchProof": "チームメイトの起動状態",
|
||||
"orgGovernance": "組織図 / ガバナンス",
|
||||
"budgetControls": "予算管理",
|
||||
"price": "価格"
|
||||
|
|
@ -98,6 +104,7 @@
|
|||
"footer": {
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "開発者向けAIエージェントオーケストレーション",
|
||||
"robotBubble": "待ってるよ",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
"docs": "ドキュメント"
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@
|
|||
"sectionTitle": "Como nos comparamos",
|
||||
"sectionSubtitle": "Comparação detalhada de recursos com outras ferramentas de programação com IA.",
|
||||
"feature": "Recurso",
|
||||
"robotBubble": "Julgue você",
|
||||
"features": {
|
||||
"crossTeam": "Comunicação entre equipes",
|
||||
"agentMessaging": "Mensagens entre agentes",
|
||||
|
|
@ -77,12 +78,17 @@
|
|||
"reviewWorkflow": "Fluxo de revisão",
|
||||
"zeroSetup": "Sem configuração",
|
||||
"kanban": "Quadro Kanban",
|
||||
"execLog": "Visualizador de logs",
|
||||
"execLog": "Logs de execução",
|
||||
"liveProcesses": "Processos ao vivo",
|
||||
"runtimeLoad": "CPU/RAM por colega",
|
||||
"perTaskReview": "Revisão de código por tarefa",
|
||||
"flexAutonomy": "Autonomia flexível",
|
||||
"worktree": "Isolamento Git worktree",
|
||||
"multiAgent": "Backend multi-agente",
|
||||
"multiAgent": "Colegas de IA mistos",
|
||||
"liveWorkGraph": "Mapa da equipe ao vivo",
|
||||
"liveTeam": "Colegas ao vivo",
|
||||
"teamWorkspace": "Espaço de trabalho da equipe",
|
||||
"launchProof": "Status de início dos colegas",
|
||||
"orgGovernance": "Organograma / governança",
|
||||
"budgetControls": "Controles de orçamento",
|
||||
"price": "Preço"
|
||||
|
|
@ -98,6 +104,7 @@
|
|||
"footer": {
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "Orquestração de agentes IA para desenvolvedores",
|
||||
"robotBubble": "Estou esperando",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
"docs": "Documentação"
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@
|
|||
"sectionTitle": "Сравнение с конкурентами",
|
||||
"sectionSubtitle": "Подробное сравнение возможностей с другими AI-инструментами для разработки.",
|
||||
"feature": "Возможность",
|
||||
"robotBubble": "Суди сам",
|
||||
"features": {
|
||||
"crossTeam": "Межкомандная коммуникация",
|
||||
"agentMessaging": "Обмен сообщениями между агентами",
|
||||
|
|
@ -77,12 +78,17 @@
|
|||
"reviewWorkflow": "Процесс ревью",
|
||||
"zeroSetup": "Без настройки",
|
||||
"kanban": "Канбан-доска",
|
||||
"execLog": "Просмотр логов выполнения",
|
||||
"execLog": "Логи выполнения",
|
||||
"liveProcesses": "Живые процессы",
|
||||
"runtimeLoad": "CPU/RAM каждого участника",
|
||||
"perTaskReview": "Код-ревью по задачам",
|
||||
"flexAutonomy": "Гибкая автономность",
|
||||
"worktree": "Изоляция Git worktree",
|
||||
"multiAgent": "Мультиагентный бэкенд",
|
||||
"multiAgent": "Смешанные AI-участники",
|
||||
"liveWorkGraph": "Живая карта команды",
|
||||
"liveTeam": "Живые участники",
|
||||
"teamWorkspace": "Рабочее место команды",
|
||||
"launchProof": "Статус запуска участников",
|
||||
"orgGovernance": "Оргструктура / управление",
|
||||
"budgetControls": "Бюджетные лимиты",
|
||||
"price": "Цена"
|
||||
|
|
@ -98,6 +104,7 @@
|
|||
"footer": {
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "Оркестрация ИИ-агентов для разработчиков",
|
||||
"robotBubble": "Я жду",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
"docs": "Документация"
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@
|
|||
"sectionTitle": "功能对比",
|
||||
"sectionSubtitle": "与其他 AI 编程工具的逐项功能对比。",
|
||||
"feature": "功能",
|
||||
"robotBubble": "你来判断",
|
||||
"features": {
|
||||
"crossTeam": "跨团队通信",
|
||||
"agentMessaging": "智能体间消息",
|
||||
|
|
@ -77,12 +78,17 @@
|
|||
"reviewWorkflow": "审查流程",
|
||||
"zeroSetup": "零配置",
|
||||
"kanban": "看板",
|
||||
"execLog": "执行日志查看器",
|
||||
"execLog": "执行日志",
|
||||
"liveProcesses": "实时进程",
|
||||
"runtimeLoad": "每个队友的 CPU/RAM",
|
||||
"perTaskReview": "按任务代码审查",
|
||||
"flexAutonomy": "灵活自主",
|
||||
"worktree": "Git worktree 隔离",
|
||||
"multiAgent": "多智能体后端",
|
||||
"multiAgent": "混合 AI 队友",
|
||||
"liveWorkGraph": "实时团队地图",
|
||||
"liveTeam": "实时队友",
|
||||
"teamWorkspace": "团队工作区",
|
||||
"launchProof": "队友启动状态",
|
||||
"orgGovernance": "组织架构 / 治理",
|
||||
"budgetControls": "预算控制",
|
||||
"price": "价格"
|
||||
|
|
@ -98,6 +104,7 @@
|
|||
"footer": {
|
||||
"copyright": "© {year} Agent Teams",
|
||||
"tagline": "面向开发者的 AI 智能体编排",
|
||||
"robotBubble": "我在等你",
|
||||
"links": {
|
||||
"github": "GitHub",
|
||||
"docs": "文档"
|
||||
|
|
|
|||
7
landing/package-lock.json
generated
|
|
@ -6,6 +6,7 @@
|
|||
"": {
|
||||
"name": "agent-teams-landing",
|
||||
"dependencies": {
|
||||
"@firecms/neat": "^0.8.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@nuxtjs/i18n": "^9.5.6",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
|
|
@ -1314,6 +1315,12 @@
|
|||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@firecms/neat": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@firecms/neat/-/neat-0.8.0.tgz",
|
||||
"integrity": "sha512-gwvYd63voJa+ZtEt6SW3toJwVx9smisKuXE7vsXvZtlGPzsWpR1lzaltIsijuPkg8Qj/ybS2tEdsttWE7g2KsA==",
|
||||
"license": "MIT AND Commons-Clause"
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"format:fix": "prettier . --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@firecms/neat": "^0.8.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@nuxtjs/i18n": "^9.5.6",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ export default defineNuxtPlugin({
|
|||
const { initTheme } = useBrowserTheme();
|
||||
const { initLocale } = useLocation();
|
||||
|
||||
// Run after hydration to avoid SSR/CSR mismatches.
|
||||
initTheme();
|
||||
|
||||
nuxtApp.hook("app:mounted", () => {
|
||||
initTheme();
|
||||
initLocale();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import "vuetify/styles";
|
|||
import { createVuetify } from "vuetify";
|
||||
import { aliases, mdi } from "vuetify/iconsets/mdi-svg";
|
||||
|
||||
type ThemeName = "light" | "dark";
|
||||
|
||||
const brand = {
|
||||
cyan: "#00f0ff",
|
||||
magenta: "#ff00ff",
|
||||
|
|
@ -11,9 +13,28 @@ const brand = {
|
|||
darkSurface: "#12121a"
|
||||
};
|
||||
|
||||
function isThemeName(value: string | null | undefined): value is ThemeName {
|
||||
return value === "dark" || value === "light";
|
||||
}
|
||||
|
||||
function resolveInitialTheme(cookieTheme: ThemeName | null): ThemeName {
|
||||
if (import.meta.client) {
|
||||
const saved = localStorage.getItem("theme");
|
||||
if (isThemeName(saved)) return saved;
|
||||
if (cookieTheme) return cookieTheme;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
return cookieTheme ?? "light";
|
||||
}
|
||||
|
||||
export default defineNuxtPlugin({
|
||||
name: "vuetify",
|
||||
setup(nuxtApp) {
|
||||
const themeCookie = useCookie<ThemeName | null>("theme");
|
||||
const cookieTheme = isThemeName(themeCookie.value) ? themeCookie.value : null;
|
||||
const defaultTheme = resolveInitialTheme(cookieTheme);
|
||||
|
||||
const vuetify = createVuetify({
|
||||
icons: {
|
||||
defaultSet: "mdi",
|
||||
|
|
@ -21,7 +42,7 @@ export default defineNuxtPlugin({
|
|||
sets: { mdi }
|
||||
},
|
||||
theme: {
|
||||
defaultTheme: "dark",
|
||||
defaultTheme,
|
||||
themes: {
|
||||
light: {
|
||||
colors: {
|
||||
|
|
|
|||
|
|
@ -1,30 +1,60 @@
|
|||
import { defineStore } from "pinia";
|
||||
|
||||
type ThemeName = "light" | "dark";
|
||||
const themeCookieName = "theme";
|
||||
|
||||
function isThemeName(value: string | null | undefined): value is ThemeName {
|
||||
return value === "dark" || value === "light";
|
||||
}
|
||||
|
||||
function getCookieTheme(): ThemeName | null {
|
||||
if (!import.meta.client) return null;
|
||||
|
||||
const cookie = document.cookie
|
||||
.split("; ")
|
||||
.find((item) => item.startsWith(`${themeCookieName}=`));
|
||||
const value = cookie ? decodeURIComponent(cookie.split("=").slice(1).join("=")) : null;
|
||||
return isThemeName(value) ? value : null;
|
||||
}
|
||||
|
||||
function persistTheme(theme: ThemeName) {
|
||||
localStorage.setItem(themeCookieName, theme);
|
||||
document.cookie = `${themeCookieName}=${theme}; Path=/; Max-Age=31536000; SameSite=Lax`;
|
||||
}
|
||||
|
||||
export const useThemeStore = defineStore("theme", {
|
||||
state: () => ({
|
||||
current: "dark" as ThemeName,
|
||||
current: "light" as ThemeName,
|
||||
userSelected: false
|
||||
}),
|
||||
actions: {
|
||||
getInitialTheme(): ThemeName {
|
||||
if (!import.meta.client) return "dark";
|
||||
const saved = localStorage.getItem("theme");
|
||||
if (saved === "dark" || saved === "light") {
|
||||
if (!import.meta.client) return "light";
|
||||
|
||||
const saved = localStorage.getItem(themeCookieName);
|
||||
if (isThemeName(saved)) {
|
||||
this.userSelected = true;
|
||||
persistTheme(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
const cookieTheme = getCookieTheme();
|
||||
if (cookieTheme) {
|
||||
this.userSelected = true;
|
||||
persistTheme(cookieTheme);
|
||||
return cookieTheme;
|
||||
}
|
||||
|
||||
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
return "dark";
|
||||
}
|
||||
return "dark";
|
||||
return "light";
|
||||
},
|
||||
setTheme(theme: ThemeName, fromUser: boolean) {
|
||||
this.current = theme;
|
||||
if (import.meta.client && fromUser) {
|
||||
this.userSelected = true;
|
||||
localStorage.setItem("theme", theme);
|
||||
persistTheme(theme);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "agent-teams-ai",
|
||||
"type": "module",
|
||||
"version": "1.3.0",
|
||||
"version": "2.0.0",
|
||||
"description": "Desktop app for managing AI agent teams, reviews, runtime logs, and provider-aware workflows",
|
||||
"license": "AGPL-3.0",
|
||||
"author": {
|
||||
|
|
@ -19,6 +19,7 @@
|
|||
"main": "dist-electron/main/index.cjs",
|
||||
"scripts": {
|
||||
"dev": "node ./scripts/dev-with-runtime.mjs",
|
||||
"dev:mcp": "node ./scripts/dev-with-runtime.mjs --remoteDebuggingPort 9222",
|
||||
"dev:kill": "node bin/kill-dev.js",
|
||||
"opencode:prove-mixed-recovery": "node ./scripts/prove-opencode-mixed-recovery.mjs",
|
||||
"opencode:prove-semantic-gauntlet": "node ./scripts/prove-opencode-semantic-gauntlet.mjs",
|
||||
|
|
|
|||
|
|
@ -2,16 +2,31 @@ diff --git a/dist/index.js b/dist/index.js
|
|||
index c91ae9196280060974778cbb1164839d5610e7d0..a2dd82afe79d7d0a6640e983166b4b205686dae9 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -58,7 +58,13 @@ var FocusScope = React.forwardRef((props, forwardedRef) => {
|
||||
@@ -58,7 +58,28 @@ var FocusScope = React.forwardRef((props, forwardedRef) => {
|
||||
const onMountAutoFocus = (0, import_react_use_callback_ref.useCallbackRef)(onMountAutoFocusProp);
|
||||
const onUnmountAutoFocus = (0, import_react_use_callback_ref.useCallbackRef)(onUnmountAutoFocusProp);
|
||||
const lastFocusedElementRef = React.useRef(null);
|
||||
- const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, (node) => setContainer(node));
|
||||
+ const containerRef = React.useRef(null);
|
||||
+ const containerCleanupGenerationRef = React.useRef(0);
|
||||
+ const setContainerRef = React.useCallback((node) => {
|
||||
+ if (containerRef.current === node) return;
|
||||
+ containerRef.current = node;
|
||||
+ setContainer(node);
|
||||
+ const syncContainer = (nextContainer) => {
|
||||
+ if (containerRef.current === nextContainer) return;
|
||||
+ containerRef.current = nextContainer;
|
||||
+ setContainer(nextContainer);
|
||||
+ };
|
||||
+ containerCleanupGenerationRef.current += 1;
|
||||
+ const cleanupGeneration = containerCleanupGenerationRef.current;
|
||||
+ if (node) {
|
||||
+ syncContainer(node);
|
||||
+ return;
|
||||
+ }
|
||||
+ queueMicrotask(() => {
|
||||
+ if (containerCleanupGenerationRef.current !== cleanupGeneration) {
|
||||
+ return;
|
||||
+ }
|
||||
+ syncContainer(null);
|
||||
+ });
|
||||
+ }, []);
|
||||
+ const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, setContainerRef);
|
||||
const focusScope = React.useRef({
|
||||
|
|
@ -21,16 +36,31 @@ diff --git a/dist/index.mjs b/dist/index.mjs
|
|||
index e39d5c9105b3f8060d037bf5490843d20d1c859a..70781360acc81bff33c36b8ebd8d6b278df58450 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -22,7 +22,13 @@ var FocusScope = React.forwardRef((props, forwardedRef) => {
|
||||
@@ -22,7 +22,28 @@ var FocusScope = React.forwardRef((props, forwardedRef) => {
|
||||
const onMountAutoFocus = useCallbackRef(onMountAutoFocusProp);
|
||||
const onUnmountAutoFocus = useCallbackRef(onUnmountAutoFocusProp);
|
||||
const lastFocusedElementRef = React.useRef(null);
|
||||
- const composedRefs = useComposedRefs(forwardedRef, (node) => setContainer(node));
|
||||
+ const containerRef = React.useRef(null);
|
||||
+ const containerCleanupGenerationRef = React.useRef(0);
|
||||
+ const setContainerRef = React.useCallback((node) => {
|
||||
+ if (containerRef.current === node) return;
|
||||
+ containerRef.current = node;
|
||||
+ setContainer(node);
|
||||
+ const syncContainer = (nextContainer) => {
|
||||
+ if (containerRef.current === nextContainer) return;
|
||||
+ containerRef.current = nextContainer;
|
||||
+ setContainer(nextContainer);
|
||||
+ };
|
||||
+ containerCleanupGenerationRef.current += 1;
|
||||
+ const cleanupGeneration = containerCleanupGenerationRef.current;
|
||||
+ if (node) {
|
||||
+ syncContainer(node);
|
||||
+ return;
|
||||
+ }
|
||||
+ queueMicrotask(() => {
|
||||
+ if (containerCleanupGenerationRef.current !== cleanupGeneration) {
|
||||
+ return;
|
||||
+ }
|
||||
+ syncContainer(null);
|
||||
+ });
|
||||
+ }, []);
|
||||
+ const composedRefs = useComposedRefs(forwardedRef, setContainerRef);
|
||||
const focusScope = React.useRef({
|
||||
|
|
|
|||
228
pnpm-lock.yaml
|
|
@ -10,7 +10,7 @@ overrides:
|
|||
|
||||
patchedDependencies:
|
||||
'@radix-ui/react-focus-scope@1.1.7':
|
||||
hash: 7804f20913d10756d0a4eb0a19936c4caffcfbd0142137ea30c880624940344d
|
||||
hash: cce5af533d09e336a548ebb15555869267a2545df720cfe228aa0a98da80e829
|
||||
path: patches/@radix-ui__react-focus-scope@1.1.7.patch
|
||||
'@radix-ui/react-presence@1.1.5':
|
||||
hash: afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e
|
||||
|
|
@ -338,7 +338,7 @@ importers:
|
|||
version: 4.0.3
|
||||
'@eslint-community/eslint-plugin-eslint-comments':
|
||||
specifier: ^4.6.0
|
||||
version: 4.6.0(eslint@9.39.2(jiti@1.21.7))
|
||||
version: 4.6.0(eslint@9.39.2(jiti@2.6.1))
|
||||
'@eslint/js':
|
||||
specifier: ^9.39.2
|
||||
version: 9.39.2
|
||||
|
|
@ -389,40 +389,40 @@ importers:
|
|||
version: 5.0.0(vite@5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0))
|
||||
eslint:
|
||||
specifier: ^9.39.2
|
||||
version: 9.39.2(jiti@1.21.7)
|
||||
version: 9.39.2(jiti@2.6.1)
|
||||
eslint-config-prettier:
|
||||
specifier: ^10.1.8
|
||||
version: 10.1.8(eslint@9.39.2(jiti@1.21.7))
|
||||
version: 10.1.8(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-import-resolver-typescript:
|
||||
specifier: ^4.4.4
|
||||
version: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@1.21.7)))(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7))
|
||||
version: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-boundaries:
|
||||
specifier: ^5.3.1
|
||||
version: 5.3.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7))
|
||||
version: 5.3.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-import:
|
||||
specifier: ^2.32.0
|
||||
version: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7))
|
||||
version: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-jsx-a11y:
|
||||
specifier: ^6.10.2
|
||||
version: 6.10.2(eslint@9.39.2(jiti@1.21.7))
|
||||
version: 6.10.2(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-react:
|
||||
specifier: ^7.37.5
|
||||
version: 7.37.5(eslint@9.39.2(jiti@1.21.7))
|
||||
version: 7.37.5(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-react-hooks:
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1(eslint@9.39.2(jiti@1.21.7))
|
||||
version: 7.0.1(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-react-refresh:
|
||||
specifier: ^0.4.26
|
||||
version: 0.4.26(eslint@9.39.2(jiti@1.21.7))
|
||||
version: 0.4.26(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-security:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
eslint-plugin-simple-import-sort:
|
||||
specifier: ^12.1.1
|
||||
version: 12.1.1(eslint@9.39.2(jiti@1.21.7))
|
||||
version: 12.1.1(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-sonarjs:
|
||||
specifier: ^3.0.6
|
||||
version: 3.0.6(eslint@9.39.2(jiti@1.21.7))
|
||||
version: 3.0.6(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-tailwindcss:
|
||||
specifier: ^3.18.2
|
||||
version: 3.18.2(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0))
|
||||
|
|
@ -461,7 +461,7 @@ importers:
|
|||
version: 5.9.3
|
||||
typescript-eslint:
|
||||
specifier: ^8.54.0
|
||||
version: 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
|
||||
version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
vite:
|
||||
specifier: ^5.4.2
|
||||
version: 5.4.21(@types/node@25.0.7)(sass@1.98.0)(terser@5.46.0)
|
||||
|
|
@ -473,6 +473,9 @@ importers:
|
|||
|
||||
landing:
|
||||
dependencies:
|
||||
'@firecms/neat':
|
||||
specifier: ^0.8.0
|
||||
version: 0.8.0
|
||||
'@mdi/js':
|
||||
specifier: ^7.4.47
|
||||
version: 7.4.47
|
||||
|
|
@ -1729,6 +1732,9 @@ packages:
|
|||
'@fastify/static@9.1.3':
|
||||
resolution: {integrity: sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==}
|
||||
|
||||
'@firecms/neat@0.8.0':
|
||||
resolution: {integrity: sha512-gwvYd63voJa+ZtEt6SW3toJwVx9smisKuXE7vsXvZtlGPzsWpR1lzaltIsijuPkg8Qj/ybS2tEdsttWE7g2KsA==}
|
||||
|
||||
'@floating-ui/core@1.7.5':
|
||||
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
|
||||
|
||||
|
|
@ -11495,10 +11501,10 @@ snapshots:
|
|||
|
||||
'@borewit/text-codec@0.2.1': {}
|
||||
|
||||
'@boundaries/elements@1.1.2(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7))':
|
||||
'@boundaries/elements@1.1.2(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1))':
|
||||
dependencies:
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7))
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1))
|
||||
handlebars: 4.7.8
|
||||
is-core-module: 2.16.1
|
||||
micromatch: 4.0.8
|
||||
|
|
@ -12265,17 +12271,12 @@ snapshots:
|
|||
'@esbuild/win32-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@eslint-community/eslint-plugin-eslint-comments@4.6.0(eslint@9.39.2(jiti@1.21.7))':
|
||||
'@eslint-community/eslint-plugin-eslint-comments@4.6.0(eslint@9.39.2(jiti@2.6.1))':
|
||||
dependencies:
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint: 9.39.2(jiti@1.21.7)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
ignore: 7.0.5
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@1.21.7))':
|
||||
dependencies:
|
||||
eslint: 9.39.2(jiti@1.21.7)
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))':
|
||||
dependencies:
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
|
|
@ -12410,6 +12411,8 @@ snapshots:
|
|||
fastq: 1.20.1
|
||||
glob: 13.0.6
|
||||
|
||||
'@firecms/neat@0.8.0': {}
|
||||
|
||||
'@floating-ui/core@1.7.5':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.11
|
||||
|
|
@ -13992,7 +13995,7 @@ snapshots:
|
|||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(patch_hash=7804f20913d10756d0a4eb0a19936c4caffcfbd0142137ea30c880624940344d)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(patch_hash=cce5af533d09e336a548ebb15555869267a2545df720cfe228aa0a98da80e829)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-presence': 1.1.5(patch_hash=afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
|
@ -14047,7 +14050,7 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-focus-scope@1.1.7(patch_hash=7804f20913d10756d0a4eb0a19936c4caffcfbd0142137ea30c880624940344d)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
'@radix-ui/react-focus-scope@1.1.7(patch_hash=cce5af533d09e336a548ebb15555869267a2545df720cfe228aa0a98da80e829)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
|
@ -14100,7 +14103,7 @@ snapshots:
|
|||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(patch_hash=7804f20913d10756d0a4eb0a19936c4caffcfbd0142137ea30c880624940344d)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(patch_hash=cce5af533d09e336a548ebb15555869267a2545df720cfe228aa0a98da80e829)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
|
@ -14124,7 +14127,7 @@ snapshots:
|
|||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(patch_hash=7804f20913d10756d0a4eb0a19936c4caffcfbd0142137ea30c880624940344d)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(patch_hash=cce5af533d09e336a548ebb15555869267a2545df720cfe228aa0a98da80e829)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
|
@ -14223,7 +14226,7 @@ snapshots:
|
|||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(patch_hash=7804f20913d10756d0a4eb0a19936c4caffcfbd0142137ea30c880624940344d)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(patch_hash=cce5af533d09e336a548ebb15555869267a2545df720cfe228aa0a98da80e829)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
|
@ -15384,15 +15387,15 @@ snapshots:
|
|||
'@types/node': 25.0.7
|
||||
optional: true
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
|
||||
'@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/scope-manager': 8.54.0
|
||||
'@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
|
||||
'@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.54.0
|
||||
eslint: 9.39.2(jiti@1.21.7)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
ignore: 7.0.5
|
||||
natural-compare: 1.4.0
|
||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||
|
|
@ -15416,14 +15419,14 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
|
||||
'@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.54.0
|
||||
'@typescript-eslint/types': 8.54.0
|
||||
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.54.0
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.2(jiti@1.21.7)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
|
@ -15476,13 +15479,13 @@ snapshots:
|
|||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
|
||||
'@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.54.0
|
||||
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.2(jiti@1.21.7)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -15534,29 +15537,17 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
|
||||
'@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7))
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
|
||||
'@typescript-eslint/scope-manager': 8.54.0
|
||||
'@typescript-eslint/types': 8.54.0
|
||||
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@1.21.7)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7))
|
||||
'@typescript-eslint/scope-manager': 8.57.1
|
||||
'@typescript-eslint/types': 8.57.1
|
||||
'@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@1.21.7)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
'@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
|
||||
|
|
@ -17705,9 +17696,9 @@ snapshots:
|
|||
'@eslint/compat': 2.0.3(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
|
||||
eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@1.21.7)):
|
||||
eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
eslint: 9.39.2(jiti@1.21.7)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
|
||||
eslint-flat-config-utils@3.0.2:
|
||||
dependencies:
|
||||
|
|
@ -17729,10 +17720,10 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@1.21.7)))(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)):
|
||||
eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.2(jiti@1.21.7)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
|
||||
get-tsconfig: 4.13.0
|
||||
is-bun-module: 2.0.0
|
||||
|
|
@ -17740,8 +17731,8 @@ snapshots:
|
|||
tinyglobby: 0.2.15
|
||||
unrs-resolver: 1.11.1
|
||||
optionalDependencies:
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7))
|
||||
eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@1.21.7))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -17749,24 +17740,24 @@ snapshots:
|
|||
dependencies:
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)):
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@1.21.7)
|
||||
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@1.21.7)))(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7))
|
||||
eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-boundaries@5.3.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)):
|
||||
eslint-plugin-boundaries@5.3.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@boundaries/elements': 1.1.2(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7))
|
||||
'@boundaries/elements': 1.1.2(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1))
|
||||
chalk: 4.1.2
|
||||
eslint: 9.39.2(jiti@1.21.7)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7))
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1))
|
||||
micromatch: 4.0.8
|
||||
transitivePeerDependencies:
|
||||
- '@typescript-eslint/parser'
|
||||
|
|
@ -17778,26 +17769,6 @@ snapshots:
|
|||
dependencies:
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
|
||||
eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@1.21.7)):
|
||||
dependencies:
|
||||
'@package-json/types': 0.0.12
|
||||
'@typescript-eslint/types': 8.57.1
|
||||
comment-parser: 1.4.5
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.2(jiti@1.21.7)
|
||||
eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
|
||||
is-glob: 4.0.3
|
||||
minimatch: 10.2.5
|
||||
semver: 7.7.4
|
||||
stable-hash-x: 0.2.0
|
||||
unrs-resolver: 1.11.1
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/utils': 8.57.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@package-json/types': 0.0.12
|
||||
|
|
@ -17817,7 +17788,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)):
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
|
|
@ -17826,9 +17797,9 @@ snapshots:
|
|||
array.prototype.flatmap: 1.3.3
|
||||
debug: 3.2.7
|
||||
doctrine: 2.1.0
|
||||
eslint: 9.39.2(jiti@1.21.7)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7))
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@2.6.1))
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
|
|
@ -17840,7 +17811,7 @@ snapshots:
|
|||
string.prototype.trimend: 1.0.9
|
||||
tsconfig-paths: 3.15.0
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- eslint-import-resolver-typescript
|
||||
- eslint-import-resolver-webpack
|
||||
|
|
@ -17866,7 +17837,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@1.21.7)):
|
||||
eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
aria-query: 5.3.2
|
||||
array-includes: 3.1.9
|
||||
|
|
@ -17876,7 +17847,7 @@ snapshots:
|
|||
axobject-query: 4.1.0
|
||||
damerau-levenshtein: 1.0.8
|
||||
emoji-regex: 9.2.2
|
||||
eslint: 9.39.2(jiti@1.21.7)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
hasown: 2.0.2
|
||||
jsx-ast-utils: 3.3.5
|
||||
language-tags: 1.0.9
|
||||
|
|
@ -17885,22 +17856,22 @@ snapshots:
|
|||
safe-regex-test: 1.1.0
|
||||
string.prototype.includes: 2.0.1
|
||||
|
||||
eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@1.21.7)):
|
||||
eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@babel/core': 7.28.6
|
||||
'@babel/parser': 7.28.6
|
||||
eslint: 9.39.2(jiti@1.21.7)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
hermes-parser: 0.25.1
|
||||
zod: 4.3.6
|
||||
zod-validation-error: 4.0.2(zod@4.3.6)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-react-refresh@0.4.26(eslint@9.39.2(jiti@1.21.7)):
|
||||
eslint-plugin-react-refresh@0.4.26(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
eslint: 9.39.2(jiti@1.21.7)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
|
||||
eslint-plugin-react@7.37.5(eslint@9.39.2(jiti@1.21.7)):
|
||||
eslint-plugin-react@7.37.5(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
array-includes: 3.1.9
|
||||
array.prototype.findlast: 1.2.5
|
||||
|
|
@ -17908,7 +17879,7 @@ snapshots:
|
|||
array.prototype.tosorted: 1.1.4
|
||||
doctrine: 2.1.0
|
||||
es-iterator-helpers: 1.2.2
|
||||
eslint: 9.39.2(jiti@1.21.7)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
estraverse: 5.3.0
|
||||
hasown: 2.0.2
|
||||
jsx-ast-utils: 3.3.5
|
||||
|
|
@ -17937,16 +17908,16 @@ snapshots:
|
|||
dependencies:
|
||||
safe-regex: 2.1.1
|
||||
|
||||
eslint-plugin-simple-import-sort@12.1.1(eslint@9.39.2(jiti@1.21.7)):
|
||||
eslint-plugin-simple-import-sort@12.1.1(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
eslint: 9.39.2(jiti@1.21.7)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
|
||||
eslint-plugin-sonarjs@3.0.6(eslint@9.39.2(jiti@1.21.7)):
|
||||
eslint-plugin-sonarjs@3.0.6(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
builtin-modules: 3.3.0
|
||||
bytes: 3.1.2
|
||||
eslint: 9.39.2(jiti@1.21.7)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
functional-red-black-tree: 1.0.1
|
||||
jsx-ast-utils-x: 0.1.0
|
||||
lodash.merge: 4.6.2
|
||||
|
|
@ -18017,47 +17988,6 @@ snapshots:
|
|||
|
||||
eslint-visitor-keys@5.0.1: {}
|
||||
|
||||
eslint@9.39.2(jiti@1.21.7):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7))
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@eslint/config-array': 0.21.1
|
||||
'@eslint/config-helpers': 0.4.2
|
||||
'@eslint/core': 0.17.0
|
||||
'@eslint/eslintrc': 3.3.3
|
||||
'@eslint/js': 9.39.2
|
||||
'@eslint/plugin-kit': 0.4.1
|
||||
'@humanfs/node': 0.16.7
|
||||
'@humanwhocodes/module-importer': 1.0.1
|
||||
'@humanwhocodes/retry': 0.4.3
|
||||
'@types/estree': 1.0.8
|
||||
ajv: 6.14.0
|
||||
chalk: 4.1.2
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.4.3
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint-scope: 8.4.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
espree: 10.4.0
|
||||
esquery: 1.7.0
|
||||
esutils: 2.0.3
|
||||
fast-deep-equal: 3.1.3
|
||||
file-entry-cache: 8.0.0
|
||||
find-up: 5.0.0
|
||||
glob-parent: 6.0.2
|
||||
ignore: 5.3.2
|
||||
imurmurhash: 0.1.4
|
||||
is-glob: 4.0.3
|
||||
json-stable-stringify-without-jsonify: 1.0.1
|
||||
lodash.merge: 4.6.2
|
||||
minimatch: 3.1.3
|
||||
natural-compare: 1.4.0
|
||||
optionator: 0.9.4
|
||||
optionalDependencies:
|
||||
jiti: 1.21.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint@9.39.2(jiti@2.6.1):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
|
||||
|
|
@ -22737,13 +22667,13 @@ snapshots:
|
|||
possible-typed-array-names: 1.1.0
|
||||
reflect.getprototypeof: 1.0.10
|
||||
|
||||
typescript-eslint@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3):
|
||||
typescript-eslint@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
|
||||
'@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@1.21.7)
|
||||
'@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
|
|
|||
|
|
@ -754,6 +754,35 @@
|
|||
"supports_native_structured_output": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"jp.anthropic.claude-sonnet-4-6": {
|
||||
"cache_creation_input_token_cost": 0.000004125,
|
||||
"cache_read_input_token_cost": 3.3e-7,
|
||||
"input_cost_per_token": 0.0000033,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 64000,
|
||||
"max_tokens": 64000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 0.0000165,
|
||||
"search_context_cost_per_query": {
|
||||
"search_context_size_high": 0.01,
|
||||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"anthropic.claude-sonnet-4-20250514-v1:0": {
|
||||
"cache_creation_input_token_cost": 0.00000375,
|
||||
"cache_read_input_token_cost": 3e-7,
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
{
|
||||
"version": "0.0.33",
|
||||
"sourceRef": "v0.0.33",
|
||||
"version": "0.0.39",
|
||||
"sourceRef": "v0.0.39",
|
||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||
"releaseRepository": "777genius/agent-teams-ai",
|
||||
"releaseTag": "v1.2.0",
|
||||
"releaseTag": "v2.0.0",
|
||||
"assets": {
|
||||
"darwin-arm64": {
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.33.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.39.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"darwin-x64": {
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.33.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.39.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"linux-x64": {
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.33.tar.gz",
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.39.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"win32-x64": {
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.33.zip",
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.39.zip",
|
||||
"archiveKind": "zip",
|
||||
"binaryName": "claude-multimodel.exe"
|
||||
}
|
||||
|
|
|
|||
131
scripts/ci/verify-sentry-release.cjs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||
const pkg = require(path.join(repoRoot, 'package.json'));
|
||||
|
||||
const REQUIRED_ENV = ['SENTRY_DSN', 'SENTRY_AUTH_TOKEN', 'SENTRY_ORG', 'SENTRY_PROJECT'];
|
||||
const OUTPUT_DIRS = ['dist-electron/main', 'out/renderer'];
|
||||
const SENTRY_DEBUG_ID_RE = /\/\/# debugId=[a-fA-F0-9-]+/;
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[sentry-release] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function isTaggedRelease() {
|
||||
return /^refs\/tags\/v/.test(process.env.GITHUB_REF ?? '');
|
||||
}
|
||||
|
||||
function assertTaggedReleaseEnv() {
|
||||
if (!isTaggedRelease()) {
|
||||
console.log('[sentry-release] skipped: not a tag release');
|
||||
return false;
|
||||
}
|
||||
|
||||
const missing = REQUIRED_ENV.filter((name) => !String(process.env[name] ?? '').trim());
|
||||
if (missing.length > 0) {
|
||||
fail(`missing required env for source map upload: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
if (!String(process.env.SENTRY_DSN).startsWith('https://')) {
|
||||
fail('SENTRY_DSN must be an https DSN');
|
||||
}
|
||||
|
||||
const tagVersion = String(process.env.GITHUB_REF).replace(/^refs\/tags\/v/, '');
|
||||
if (pkg.version !== tagVersion) {
|
||||
fail(`package version ${pkg.version} does not match release tag v${tagVersion}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function walkFiles(relativeDir) {
|
||||
const absoluteDir = path.join(repoRoot, relativeDir);
|
||||
if (!fs.existsSync(absoluteDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
const stack = [absoluteDir];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const entryPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(entryPath);
|
||||
} else if (entry.isFile()) {
|
||||
files.push(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function prebuild() {
|
||||
if (!assertTaggedReleaseEnv()) return;
|
||||
|
||||
console.log(
|
||||
`[sentry-release] prebuild ok: release=agent-teams-ai@${pkg.version}, project=${process.env.SENTRY_ORG}/${process.env.SENTRY_PROJECT}`
|
||||
);
|
||||
}
|
||||
|
||||
function postbuild() {
|
||||
if (!assertTaggedReleaseEnv()) return;
|
||||
|
||||
const jsFilesByOutputDir = new Map();
|
||||
for (const outputDir of OUTPUT_DIRS) {
|
||||
const jsFiles = walkFiles(outputDir).filter((file) => /\.(?:js|cjs|mjs)$/.test(file));
|
||||
if (jsFiles.length === 0) {
|
||||
fail(`no built JavaScript files found in ${outputDir}`);
|
||||
}
|
||||
jsFilesByOutputDir.set(outputDir, jsFiles);
|
||||
}
|
||||
|
||||
const jsFiles = [...jsFilesByOutputDir.values()].flat();
|
||||
if (jsFiles.length === 0) {
|
||||
fail(`no built JavaScript files found in ${OUTPUT_DIRS.join(', ')}`);
|
||||
}
|
||||
|
||||
const missingDebugIdDirs = [];
|
||||
for (const [outputDir, files] of jsFilesByOutputDir.entries()) {
|
||||
const hasDebugId = files.some((file) => SENTRY_DEBUG_ID_RE.test(fs.readFileSync(file, 'utf8')));
|
||||
if (!hasDebugId) {
|
||||
missingDebugIdDirs.push(outputDir);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingDebugIdDirs.length > 0) {
|
||||
fail(
|
||||
[
|
||||
'Sentry debug IDs were not injected into built JavaScript artifacts',
|
||||
...missingDebugIdDirs.map((dir) => ` - ${dir}`),
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
const mapFiles = OUTPUT_DIRS.flatMap(walkFiles).filter((file) => file.endsWith('.map'));
|
||||
if (mapFiles.length > 0) {
|
||||
fail(
|
||||
[
|
||||
'source maps still exist after build; expected Sentry upload to delete them',
|
||||
...mapFiles.slice(0, 20).map((file) => ` - ${path.relative(repoRoot, file)}`),
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[sentry-release] postbuild ok: ${jsFiles.length} JS artifacts built, debug IDs were injected, and source maps were removed after upload`
|
||||
);
|
||||
}
|
||||
|
||||
const command = process.argv[2] ?? 'prebuild';
|
||||
if (command === 'prebuild') {
|
||||
prebuild();
|
||||
} else if (command === 'postbuild') {
|
||||
postbuild();
|
||||
} else {
|
||||
fail(`unknown command: ${command}`);
|
||||
}
|
||||
|
|
@ -18,7 +18,9 @@ const defaultRuntimeCacheRoot = path.join(os.homedir(), '.agent-teams', 'runtime
|
|||
const runtimeCacheRoot = process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT?.trim()
|
||||
? path.resolve(process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT.trim())
|
||||
: defaultRuntimeCacheRoot;
|
||||
const shouldPrintRuntimePath = process.argv.includes('--print-runtime-path');
|
||||
const scriptArgs = process.argv.slice(2);
|
||||
const shouldPrintRuntimePath = scriptArgs.includes('--print-runtime-path');
|
||||
const electronViteArgs = scriptArgs.filter((arg) => arg !== '--print-runtime-path' && arg !== '--');
|
||||
const runtimeDisplayName = 'teams orchestrator';
|
||||
const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']);
|
||||
|
||||
|
|
@ -542,7 +544,7 @@ async function main() {
|
|||
delete uiEnv.CLAUDE_CLI_PATH;
|
||||
const uiPackageManager = readPackageManagerCommand(uiRepoRoot);
|
||||
|
||||
runOrExit(uiPackageManager, ['exec', 'electron-vite', 'dev'], {
|
||||
runOrExit(uiPackageManager, ['exec', 'electron-vite', 'dev', ...electronViteArgs], {
|
||||
cwd: uiRepoRoot,
|
||||
env: uiEnv,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,68 @@ const FAILURE_PATTERNS = [
|
|||
/DeprecationWarning: fs\.Stats constructor is deprecated/i,
|
||||
];
|
||||
|
||||
function isMacBundle(candidatePath) {
|
||||
return (
|
||||
candidatePath.endsWith('.app') &&
|
||||
fs.existsSync(path.join(candidatePath, 'Contents', 'MacOS')) &&
|
||||
fs.statSync(path.join(candidatePath, 'Contents', 'MacOS')).isDirectory()
|
||||
);
|
||||
}
|
||||
|
||||
function findMacBundles(searchRoot, maxDepth = 3) {
|
||||
if (!fs.existsSync(searchRoot) || maxDepth < 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stat = fs.statSync(searchRoot);
|
||||
if (stat.isDirectory() && isMacBundle(searchRoot)) {
|
||||
return [searchRoot];
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const bundles = [];
|
||||
for (const entry of fs.readdirSync(searchRoot, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullPath = path.join(searchRoot, entry.name);
|
||||
if (isMacBundle(fullPath)) {
|
||||
bundles.push(fullPath);
|
||||
continue;
|
||||
}
|
||||
bundles.push(...findMacBundles(fullPath, maxDepth - 1));
|
||||
}
|
||||
return bundles;
|
||||
}
|
||||
|
||||
function resolveBundlePath(bundlePath, platform) {
|
||||
if (platform !== 'darwin' || isMacBundle(bundlePath)) {
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
const searchRoots = [
|
||||
path.dirname(bundlePath),
|
||||
path.dirname(path.dirname(bundlePath)),
|
||||
path.resolve(process.cwd(), 'release'),
|
||||
];
|
||||
const bundles = [...new Set(searchRoots.flatMap((searchRoot) => findMacBundles(searchRoot)))];
|
||||
if (bundles.length === 1) {
|
||||
return bundles[0];
|
||||
}
|
||||
if (bundles.length > 1) {
|
||||
const expectedName = path.basename(bundlePath);
|
||||
const nameMatch = bundles.find((candidate) => path.basename(candidate) === expectedName);
|
||||
if (nameMatch) {
|
||||
return nameMatch;
|
||||
}
|
||||
}
|
||||
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
function fail(message, log = '') {
|
||||
console.error(`[smokePackagedApp] ${message}`);
|
||||
if (log.trim()) {
|
||||
|
|
@ -100,12 +162,33 @@ async function terminateChild(child, exitPromise, platform) {
|
|||
killer.once('close', resolve);
|
||||
});
|
||||
} else {
|
||||
child.kill();
|
||||
const pid = child.pid;
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(-pid, 'SIGTERM');
|
||||
} catch {
|
||||
child.kill();
|
||||
}
|
||||
} else {
|
||||
child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
const closed = await waitForProcessClose(child, exitPromise, SHUTDOWN_TIMEOUT_MS);
|
||||
if (!closed && child.exitCode === null && child.signalCode === null) {
|
||||
throw new Error(`Timed out after ${SHUTDOWN_TIMEOUT_MS}ms waiting for packaged app to exit`);
|
||||
if (child.pid && platform !== 'win32') {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGKILL');
|
||||
} catch {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
} else {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
const killed = await waitForProcessClose(child, exitPromise, SHUTDOWN_TIMEOUT_MS);
|
||||
if (!killed && child.exitCode === null && child.signalCode === null) {
|
||||
throw new Error(`Timed out after ${SHUTDOWN_TIMEOUT_MS}ms waiting for packaged app to exit`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -115,16 +198,20 @@ async function main() {
|
|||
fail('Usage: node ./scripts/electron-builder/smokePackagedApp.cjs <bundlePath> <platform>');
|
||||
}
|
||||
|
||||
const bundlePath = path.resolve(bundlePathArg);
|
||||
const bundlePath = resolveBundlePath(path.resolve(bundlePathArg), platform);
|
||||
const executable = findExecutable(bundlePath, platform);
|
||||
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-smoke-'));
|
||||
const args = [`--user-data-dir=${userDataDir}`];
|
||||
if (platform === 'linux') {
|
||||
args.push('--no-sandbox');
|
||||
}
|
||||
const child = spawn(executable, args, {
|
||||
env: {
|
||||
...process.env,
|
||||
AGENT_TEAMS_PACKAGED_SMOKE: '1',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: platform !== 'win32',
|
||||
});
|
||||
|
||||
let log = '';
|
||||
|
|
|
|||
|
|
@ -4,6 +4,69 @@ const afterPackModule = require('./afterPack.cjs');
|
|||
|
||||
const { validateNativeBinaries } = afterPackModule._internal;
|
||||
|
||||
function isMacBundle(candidatePath) {
|
||||
return (
|
||||
candidatePath.endsWith('.app') &&
|
||||
require('node:fs').existsSync(path.join(candidatePath, 'Contents', 'MacOS')) &&
|
||||
require('node:fs').statSync(path.join(candidatePath, 'Contents', 'MacOS')).isDirectory()
|
||||
);
|
||||
}
|
||||
|
||||
function findMacBundles(searchRoot, maxDepth = 3) {
|
||||
const fs = require('node:fs');
|
||||
if (!fs.existsSync(searchRoot) || maxDepth < 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stat = fs.statSync(searchRoot);
|
||||
if (stat.isDirectory() && isMacBundle(searchRoot)) {
|
||||
return [searchRoot];
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const bundles = [];
|
||||
for (const entry of fs.readdirSync(searchRoot, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullPath = path.join(searchRoot, entry.name);
|
||||
if (isMacBundle(fullPath)) {
|
||||
bundles.push(fullPath);
|
||||
continue;
|
||||
}
|
||||
bundles.push(...findMacBundles(fullPath, maxDepth - 1));
|
||||
}
|
||||
return bundles;
|
||||
}
|
||||
|
||||
function resolveBundlePath(bundlePath, platform) {
|
||||
if (platform !== 'darwin' || isMacBundle(bundlePath)) {
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
const searchRoots = [
|
||||
path.dirname(bundlePath),
|
||||
path.dirname(path.dirname(bundlePath)),
|
||||
path.resolve(process.cwd(), 'release'),
|
||||
];
|
||||
const bundles = [...new Set(searchRoots.flatMap((searchRoot) => findMacBundles(searchRoot)))];
|
||||
if (bundles.length === 1) {
|
||||
return bundles[0];
|
||||
}
|
||||
if (bundles.length > 1) {
|
||||
const expectedName = path.basename(bundlePath);
|
||||
const nameMatch = bundles.find((candidate) => path.basename(candidate) === expectedName);
|
||||
if (nameMatch) {
|
||||
return nameMatch;
|
||||
}
|
||||
}
|
||||
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
function isAllowedPostPackMismatch(mismatch, platform, arch) {
|
||||
const relativePath = mismatch.path.split(path.sep).join('/');
|
||||
return (
|
||||
|
|
@ -24,7 +87,7 @@ async function main() {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
const bundlePath = path.resolve(bundlePathArg);
|
||||
const bundlePath = resolveBundlePath(path.resolve(bundlePathArg), platform);
|
||||
const mismatches = await validateNativeBinaries(bundlePath, platform, arch);
|
||||
const blockingMismatches = mismatches.filter(
|
||||
(mismatch) => !isAllowedPostPackMismatch(mismatch, platform, arch)
|
||||
|
|
|
|||
|
|
@ -131,6 +131,37 @@ async function downloadFile(url, destinationPath) {
|
|||
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(destinationPath));
|
||||
}
|
||||
|
||||
function downloadReleaseAssetWithGh(runtimeLock, releaseTag, asset, destinationPath) {
|
||||
if (!process.env.GH_TOKEN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
||||
const result = spawnSync(
|
||||
'gh',
|
||||
[
|
||||
'release',
|
||||
'download',
|
||||
releaseTag,
|
||||
'--repo',
|
||||
runtimeLock.releaseRepository,
|
||||
'--pattern',
|
||||
asset.file,
|
||||
'--dir',
|
||||
path.dirname(destinationPath),
|
||||
'--clobber',
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
shell: false,
|
||||
env: process.env,
|
||||
}
|
||||
);
|
||||
|
||||
return result.status === 0 && fs.existsSync(destinationPath);
|
||||
}
|
||||
|
||||
function extractArchive(archivePath, extractDir, archiveKind) {
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
|
||||
|
|
@ -208,7 +239,9 @@ async function stageRuntime(options) {
|
|||
process.stdout.write(
|
||||
`Downloading ${asset.file} from ${runtimeLock.releaseRepository}@${releaseTag}\n`
|
||||
);
|
||||
await downloadFile(url, archivePath);
|
||||
if (!downloadReleaseAssetWithGh(runtimeLock, releaseTag, asset, archivePath)) {
|
||||
await downloadFile(url, archivePath);
|
||||
}
|
||||
|
||||
process.stdout.write(`Extracting ${asset.file}\n`);
|
||||
extractArchive(archivePath, extractDir, asset.archiveKind);
|
||||
|
|
|
|||
|
|
@ -573,6 +573,9 @@ export class TeamGraphAdapter {
|
|||
spawnRuntimeAlive: spawn?.runtimeAlive,
|
||||
spawnBootstrapConfirmed: spawn?.bootstrapConfirmed,
|
||||
spawnBootstrapStalled: spawn?.bootstrapStalled,
|
||||
spawnAgentToolAccepted: spawn?.agentToolAccepted,
|
||||
spawnHardFailure: spawn?.hardFailure,
|
||||
spawnLivenessKind: spawn?.livenessKind,
|
||||
runtimeAdvisory: member.runtimeAdvisory,
|
||||
isLaunchSettling,
|
||||
isTeamAlive: data.isAlive,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
import { lazy, Suspense, useCallback, useState } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectTeamDataForName } from '@renderer/store/slices/teamSlice';
|
||||
import {
|
||||
buildTaskChangeRequestOptions,
|
||||
type TaskChangeRequestOptions,
|
||||
} from '@renderer/utils/taskChangeRequest';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
const ChangeReviewDialog = lazy(() =>
|
||||
import('@renderer/components/team/review/ChangeReviewDialog').then((m) => ({
|
||||
default: m.ChangeReviewDialog,
|
||||
}))
|
||||
);
|
||||
|
||||
interface GraphChangeReviewDialogState {
|
||||
open: boolean;
|
||||
mode: 'agent' | 'task';
|
||||
memberName?: string;
|
||||
taskId?: string;
|
||||
initialFilePath?: string;
|
||||
taskChangeRequestOptions?: TaskChangeRequestOptions;
|
||||
}
|
||||
|
||||
interface UseGraphChangeReviewDialogResult {
|
||||
dialog: React.ReactNode;
|
||||
openMemberChanges: (memberName: string, filePath?: string) => void;
|
||||
openTaskChanges: (taskId: string, filePath?: string) => void;
|
||||
}
|
||||
|
||||
export function useGraphChangeReviewDialog(teamName: string): UseGraphChangeReviewDialogResult {
|
||||
const [dialogState, setDialogState] = useState<GraphChangeReviewDialogState>({
|
||||
open: false,
|
||||
mode: 'task',
|
||||
});
|
||||
const { teamData, selectReviewFile } = useStore(
|
||||
useShallow((state) => ({
|
||||
teamData: selectTeamDataForName(state, teamName),
|
||||
selectReviewFile: state.selectReviewFile,
|
||||
}))
|
||||
);
|
||||
|
||||
const openTaskChanges = useCallback(
|
||||
(taskId: string, filePath?: string): void => {
|
||||
const task = teamData?.tasks.find((candidate) => candidate.id === taskId);
|
||||
setDialogState({
|
||||
open: true,
|
||||
mode: 'task',
|
||||
taskId,
|
||||
memberName: undefined,
|
||||
initialFilePath: filePath,
|
||||
taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {},
|
||||
});
|
||||
if (filePath) {
|
||||
selectReviewFile(filePath);
|
||||
}
|
||||
},
|
||||
[selectReviewFile, teamData?.tasks]
|
||||
);
|
||||
|
||||
const openMemberChanges = useCallback(
|
||||
(memberName: string, filePath?: string): void => {
|
||||
setDialogState({
|
||||
open: true,
|
||||
mode: 'agent',
|
||||
memberName,
|
||||
taskId: undefined,
|
||||
initialFilePath: filePath,
|
||||
taskChangeRequestOptions: undefined,
|
||||
});
|
||||
if (filePath) {
|
||||
selectReviewFile(filePath);
|
||||
}
|
||||
},
|
||||
[selectReviewFile]
|
||||
);
|
||||
|
||||
const handleOpenChange = useCallback((open: boolean): void => {
|
||||
setDialogState((previous) => ({
|
||||
...previous,
|
||||
open,
|
||||
...(open ? {} : { initialFilePath: undefined, taskChangeRequestOptions: undefined }),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
openMemberChanges,
|
||||
openTaskChanges,
|
||||
dialog: dialogState.open ? (
|
||||
<Suspense fallback={null}>
|
||||
<ChangeReviewDialog
|
||||
open={dialogState.open}
|
||||
onOpenChange={handleOpenChange}
|
||||
teamName={teamName}
|
||||
mode={dialogState.mode}
|
||||
memberName={dialogState.memberName}
|
||||
taskId={dialogState.taskId}
|
||||
initialFilePath={dialogState.initialFilePath}
|
||||
taskChangeRequestOptions={dialogState.taskChangeRequestOptions}
|
||||
projectPath={teamData?.config.projectPath}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
import { lazy, Suspense, useCallback, useState } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
isTeamProvisioningActive,
|
||||
selectResolvedMembersForTeamName,
|
||||
selectTeamDataForName,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type {
|
||||
MemberActivityFilter,
|
||||
MemberDetailTab,
|
||||
} from '@renderer/components/team/members/memberDetailTypes';
|
||||
import type { TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
const MemberDetailDialog = lazy(() =>
|
||||
import('@renderer/components/team/members/MemberDetailDialog').then((m) => ({
|
||||
default: m.MemberDetailDialog,
|
||||
}))
|
||||
);
|
||||
|
||||
interface OpenMemberProfileOptions {
|
||||
initialActivityFilter?: MemberActivityFilter;
|
||||
initialTab?: MemberDetailTab;
|
||||
}
|
||||
|
||||
interface UseGraphMemberDetailDialogInput {
|
||||
onAssignTask: (owner: string) => void;
|
||||
onSendMessage: (memberName: string) => void;
|
||||
onTaskClick: (taskId: string) => void;
|
||||
onViewMemberChanges: (memberName: string, filePath?: string) => void;
|
||||
}
|
||||
|
||||
interface UseGraphMemberDetailDialogResult {
|
||||
dialog: React.ReactNode;
|
||||
openMemberProfile: (memberName: string, options?: OpenMemberProfileOptions) => void;
|
||||
}
|
||||
|
||||
export function useGraphMemberDetailDialog(
|
||||
teamName: string,
|
||||
{ onAssignTask, onSendMessage, onTaskClick, onViewMemberChanges }: UseGraphMemberDetailDialogInput
|
||||
): UseGraphMemberDetailDialogResult {
|
||||
const [selectedMemberName, setSelectedMemberName] = useState<string | null>(null);
|
||||
const [selectedMemberView, setSelectedMemberView] = useState<OpenMemberProfileOptions | null>(
|
||||
null
|
||||
);
|
||||
const {
|
||||
isTeamProvisioning,
|
||||
launchParams,
|
||||
leadActivity,
|
||||
members,
|
||||
runtimeRunId,
|
||||
selectedRuntimeEntry,
|
||||
selectedSpawnEntry,
|
||||
teamData,
|
||||
} = useStore(
|
||||
useShallow((state) => ({
|
||||
isTeamProvisioning: isTeamProvisioningActive(state, teamName),
|
||||
launchParams: state.launchParamsByTeam[teamName],
|
||||
leadActivity: state.leadActivityByTeam[teamName],
|
||||
members: selectResolvedMembersForTeamName(state, teamName),
|
||||
runtimeRunId:
|
||||
state.teamAgentRuntimeByTeam[teamName]?.runId ??
|
||||
state.memberSpawnSnapshotsByTeam[teamName]?.runId ??
|
||||
null,
|
||||
selectedRuntimeEntry: selectedMemberName
|
||||
? state.teamAgentRuntimeByTeam[teamName]?.members[selectedMemberName]
|
||||
: undefined,
|
||||
selectedSpawnEntry: selectedMemberName
|
||||
? state.memberSpawnStatusesByTeam[teamName]?.[selectedMemberName]
|
||||
: undefined,
|
||||
teamData: selectTeamDataForName(state, teamName),
|
||||
}))
|
||||
);
|
||||
|
||||
const selectedMember =
|
||||
selectedMemberName && members.length > 0
|
||||
? (members.find((member) => member.name === selectedMemberName) ?? null)
|
||||
: null;
|
||||
|
||||
const openMemberProfile = useCallback(
|
||||
(memberName: string, options?: OpenMemberProfileOptions): void => {
|
||||
setSelectedMemberName(memberName);
|
||||
setSelectedMemberView(options ?? null);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const closeMemberProfile = useCallback((): void => {
|
||||
setSelectedMemberName(null);
|
||||
setSelectedMemberView(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
openMemberProfile,
|
||||
dialog:
|
||||
selectedMemberName && teamData ? (
|
||||
<Suspense fallback={null}>
|
||||
<MemberDetailDialog
|
||||
open
|
||||
member={selectedMember}
|
||||
teamName={teamName}
|
||||
members={members}
|
||||
tasks={teamData.tasks}
|
||||
initialTab={selectedMemberView?.initialTab}
|
||||
initialActivityFilter={selectedMemberView?.initialActivityFilter}
|
||||
isTeamAlive={teamData.isAlive}
|
||||
isTeamProvisioning={isTeamProvisioning}
|
||||
leadActivity={leadActivity}
|
||||
spawnEntry={selectedSpawnEntry}
|
||||
runtimeEntry={selectedRuntimeEntry}
|
||||
runtimeRunId={runtimeRunId}
|
||||
launchParams={launchParams}
|
||||
onClose={closeMemberProfile}
|
||||
onSendMessage={() => {
|
||||
if (!selectedMemberName) return;
|
||||
closeMemberProfile();
|
||||
onSendMessage(selectedMemberName);
|
||||
}}
|
||||
onAssignTask={() => {
|
||||
if (!selectedMemberName) return;
|
||||
closeMemberProfile();
|
||||
onAssignTask(selectedMemberName);
|
||||
}}
|
||||
onTaskClick={(task: TeamTaskWithKanban) => {
|
||||
closeMemberProfile();
|
||||
onTaskClick(task.id);
|
||||
}}
|
||||
onViewMemberChanges={(memberName, filePath) => {
|
||||
closeMemberProfile();
|
||||
onViewMemberChanges(memberName, filePath);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import { lazy, Suspense, useCallback, useState } from 'react';
|
||||
|
||||
import {
|
||||
getTeamPendingRepliesState,
|
||||
setTeamPendingRepliesState,
|
||||
} from '@renderer/components/team/sidebar/teamSidebarUiState';
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
selectResolvedMembersForTeamName,
|
||||
selectTeamDataForName,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { shouldClearPendingReplyForOpenCodeRuntimeDelivery } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
const SendMessageDialog = lazy(() =>
|
||||
import('@renderer/components/team/dialogs/SendMessageDialog').then((m) => ({
|
||||
default: m.SendMessageDialog,
|
||||
}))
|
||||
);
|
||||
|
||||
interface UseGraphSendMessageDialogResult {
|
||||
dialog: React.ReactNode;
|
||||
openSendMessage: (memberName?: string) => void;
|
||||
}
|
||||
|
||||
function writePendingReply(teamName: string, memberName: string, sentAtMs: number): void {
|
||||
setTeamPendingRepliesState(teamName, {
|
||||
...getTeamPendingRepliesState(teamName),
|
||||
[memberName]: sentAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function clearPendingReply(teamName: string, memberName: string, sentAtMs: number): void {
|
||||
const previous = getTeamPendingRepliesState(teamName);
|
||||
if (previous[memberName] !== sentAtMs) return;
|
||||
const next = { ...previous };
|
||||
delete next[memberName];
|
||||
setTeamPendingRepliesState(teamName, next);
|
||||
}
|
||||
|
||||
export function useGraphSendMessageDialog(teamName: string): UseGraphSendMessageDialogResult {
|
||||
const [sendDialogOpen, setSendDialogOpen] = useState(false);
|
||||
const [sendDialogRecipient, setSendDialogRecipient] = useState<string | undefined>(undefined);
|
||||
const {
|
||||
activeMembers,
|
||||
isTeamAlive,
|
||||
lastSendMessageResult,
|
||||
sendDebugDetails,
|
||||
sendError,
|
||||
sendTeamMessage,
|
||||
sendWarning,
|
||||
sending,
|
||||
} = useStore(
|
||||
useShallow((state) => {
|
||||
const teamData = selectTeamDataForName(state, teamName);
|
||||
return {
|
||||
activeMembers: selectResolvedMembersForTeamName(state, teamName).filter(
|
||||
(member) => !member.removedAt
|
||||
),
|
||||
isTeamAlive: teamData?.isAlive,
|
||||
lastSendMessageResult: state.lastSendMessageResult,
|
||||
sendDebugDetails: state.sendMessageDebugDetails,
|
||||
sendError: state.sendMessageError,
|
||||
sendTeamMessage: state.sendTeamMessage,
|
||||
sendWarning: state.sendMessageWarning,
|
||||
sending: state.sendingMessage,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const openSendMessage = useCallback((memberName?: string): void => {
|
||||
setSendDialogRecipient(memberName);
|
||||
setSendDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeSendMessage = useCallback((): void => {
|
||||
setSendDialogOpen(false);
|
||||
setSendDialogRecipient(undefined);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
openSendMessage,
|
||||
dialog: sendDialogOpen ? (
|
||||
<Suspense fallback={null}>
|
||||
<SendMessageDialog
|
||||
open={sendDialogOpen}
|
||||
teamName={teamName}
|
||||
members={activeMembers}
|
||||
defaultRecipient={sendDialogRecipient}
|
||||
isTeamAlive={isTeamAlive}
|
||||
sending={sending}
|
||||
sendError={sendError}
|
||||
sendWarning={sendWarning}
|
||||
sendDebugDetails={sendDebugDetails}
|
||||
lastResult={lastSendMessageResult}
|
||||
onSend={async (member, text, summary, attachments, actionMode, taskRefs) => {
|
||||
const sentAtMs = Date.now();
|
||||
writePendingReply(teamName, member, sentAtMs);
|
||||
try {
|
||||
const result = await sendTeamMessage(teamName, {
|
||||
member,
|
||||
text,
|
||||
summary,
|
||||
attachments,
|
||||
actionMode,
|
||||
taskRefs,
|
||||
});
|
||||
if (shouldClearPendingReplyForOpenCodeRuntimeDelivery(result?.runtimeDelivery)) {
|
||||
clearPendingReply(teamName, member, sentAtMs);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
clearPendingReply(teamName, member, sentAtMs);
|
||||
throw error;
|
||||
}
|
||||
}}
|
||||
onClose={closeSendMessage}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { useGraphChangeReviewDialog } from './useGraphChangeReviewDialog';
|
||||
import { useGraphCreateTaskDialog } from './useGraphCreateTaskDialog';
|
||||
import { useGraphMemberDetailDialog } from './useGraphMemberDetailDialog';
|
||||
import { useGraphSendMessageDialog } from './useGraphSendMessageDialog';
|
||||
import { useGraphTaskActions } from './useGraphTaskActions';
|
||||
import { useGraphTaskDetailDialog } from './useGraphTaskDetailDialog';
|
||||
|
||||
import type {
|
||||
MemberActivityFilter,
|
||||
MemberDetailTab,
|
||||
} from '@renderer/components/team/members/memberDetailTypes';
|
||||
|
||||
interface OpenProfileOptions {
|
||||
initialActivityFilter?: MemberActivityFilter;
|
||||
initialTab?: MemberDetailTab;
|
||||
}
|
||||
|
||||
export function useGraphSurfaceInteractions(teamName: string): {
|
||||
dialogs: React.ReactNode;
|
||||
onApproveTask: (taskId: string) => void;
|
||||
onCancelTask: (taskId: string) => void;
|
||||
onCompleteTask: (taskId: string) => void;
|
||||
onDeleteTask: (taskId: string) => void;
|
||||
onMoveBackToDone: (taskId: string) => void;
|
||||
onRequestChanges: (taskId: string) => void;
|
||||
onRequestReview: (taskId: string) => void;
|
||||
onStartTask: (taskId: string) => void;
|
||||
openCreateTask: (owner?: string) => void;
|
||||
openMemberProfile: (memberName: string, options?: OpenProfileOptions) => void;
|
||||
openSendMessage: (memberName?: string) => void;
|
||||
openTaskChanges: (taskId: string, filePath?: string) => void;
|
||||
openTaskDetail: (taskId: string) => void;
|
||||
} {
|
||||
const changeReview = useGraphChangeReviewDialog(teamName);
|
||||
const createTask = useGraphCreateTaskDialog(teamName);
|
||||
const sendMessage = useGraphSendMessageDialog(teamName);
|
||||
const taskActions = useGraphTaskActions(teamName);
|
||||
const taskDetail = useGraphTaskDetailDialog(teamName, {
|
||||
onDeleteTask: taskActions.onDeleteTask,
|
||||
onViewChanges: changeReview.openTaskChanges,
|
||||
});
|
||||
const memberDetail = useGraphMemberDetailDialog(teamName, {
|
||||
onAssignTask: createTask.openCreateTaskDialog,
|
||||
onSendMessage: sendMessage.openSendMessage,
|
||||
onTaskClick: taskDetail.openTaskDetail,
|
||||
onViewMemberChanges: changeReview.openMemberChanges,
|
||||
});
|
||||
|
||||
const dialogs = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{createTask.dialog}
|
||||
{sendMessage.dialog}
|
||||
{taskActions.dialog}
|
||||
{taskDetail.dialog}
|
||||
{memberDetail.dialog}
|
||||
{changeReview.dialog}
|
||||
</>
|
||||
),
|
||||
[
|
||||
changeReview.dialog,
|
||||
createTask.dialog,
|
||||
memberDetail.dialog,
|
||||
sendMessage.dialog,
|
||||
taskActions.dialog,
|
||||
taskDetail.dialog,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
dialogs,
|
||||
onApproveTask: taskActions.onApproveTask,
|
||||
onCancelTask: taskActions.onCancelTask,
|
||||
onCompleteTask: taskActions.onCompleteTask,
|
||||
onDeleteTask: taskActions.onDeleteTask,
|
||||
onMoveBackToDone: taskActions.onMoveBackToDone,
|
||||
onRequestChanges: taskActions.onRequestChanges,
|
||||
onRequestReview: taskActions.onRequestReview,
|
||||
onStartTask: taskActions.onStartTask,
|
||||
openCreateTask: createTask.openCreateTaskDialog,
|
||||
openMemberProfile: memberDetail.openMemberProfile,
|
||||
openSendMessage: sendMessage.openSendMessage,
|
||||
openTaskChanges: changeReview.openTaskChanges,
|
||||
openTaskDetail: taskDetail.openTaskDetail,
|
||||
};
|
||||
}
|
||||
246
src/features/agent-graph/renderer/hooks/useGraphTaskActions.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||
import { ReviewDialog } from '@renderer/components/team/dialogs/ReviewDialog';
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
selectResolvedMembersForTeamName,
|
||||
selectTeamDataForName,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { TaskRef } from '@shared/types';
|
||||
|
||||
interface GraphTaskActionHandlers {
|
||||
onApproveTask: (taskId: string) => void;
|
||||
onCancelTask: (taskId: string) => void;
|
||||
onCompleteTask: (taskId: string) => void;
|
||||
onDeleteTask: (taskId: string) => void;
|
||||
onMoveBackToDone: (taskId: string) => void;
|
||||
onRequestChanges: (taskId: string) => void;
|
||||
onRequestReview: (taskId: string) => void;
|
||||
onStartTask: (taskId: string) => void;
|
||||
}
|
||||
|
||||
interface UseGraphTaskActionsResult extends GraphTaskActionHandlers {
|
||||
dialog: React.ReactNode;
|
||||
taskActionHandlers: GraphTaskActionHandlers;
|
||||
}
|
||||
|
||||
export function useGraphTaskActions(teamName: string): UseGraphTaskActionsResult {
|
||||
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
||||
const {
|
||||
teamData,
|
||||
members,
|
||||
requestReview,
|
||||
sendTeamMessage,
|
||||
softDeleteTask,
|
||||
startTaskByUser,
|
||||
updateKanban,
|
||||
updateTaskStatus,
|
||||
} = useStore(
|
||||
useShallow((state) => ({
|
||||
teamData: selectTeamDataForName(state, teamName),
|
||||
members: selectResolvedMembersForTeamName(state, teamName),
|
||||
requestReview: state.requestReview,
|
||||
sendTeamMessage: state.sendTeamMessage,
|
||||
softDeleteTask: state.softDeleteTask,
|
||||
startTaskByUser: state.startTaskByUser,
|
||||
updateKanban: state.updateKanban,
|
||||
updateTaskStatus: state.updateTaskStatus,
|
||||
}))
|
||||
);
|
||||
|
||||
const onStartTask = useCallback(
|
||||
(taskId: string): void => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await startTaskByUser(teamName, taskId);
|
||||
if (!teamData?.isAlive) return;
|
||||
|
||||
const task = teamData.tasks.find((candidate) => candidate.id === taskId);
|
||||
try {
|
||||
if (result.notifiedOwner && task?.owner) {
|
||||
await api.teams.processSend(
|
||||
teamName,
|
||||
`Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.notifiedOwner) {
|
||||
const desc = task?.description?.trim()
|
||||
? `\nDescription: ${task.description.trim()}`
|
||||
: '';
|
||||
await api.teams.processSend(
|
||||
teamName,
|
||||
`Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// best-effort notification
|
||||
}
|
||||
} catch {
|
||||
// error via store
|
||||
}
|
||||
})();
|
||||
},
|
||||
[startTaskByUser, teamData, teamName]
|
||||
);
|
||||
|
||||
const onCompleteTask = useCallback(
|
||||
(taskId: string): void => {
|
||||
void updateTaskStatus(teamName, taskId, 'completed').catch(() => undefined);
|
||||
},
|
||||
[teamName, updateTaskStatus]
|
||||
);
|
||||
|
||||
const onApproveTask = useCallback(
|
||||
(taskId: string): void => {
|
||||
void updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }).catch(
|
||||
() => undefined
|
||||
);
|
||||
},
|
||||
[teamName, updateKanban]
|
||||
);
|
||||
|
||||
const onRequestReview = useCallback(
|
||||
(taskId: string): void => {
|
||||
void requestReview(teamName, taskId).catch(() => undefined);
|
||||
},
|
||||
[requestReview, teamName]
|
||||
);
|
||||
|
||||
const onRequestChanges = useCallback((taskId: string): void => {
|
||||
setRequestChangesTaskId(taskId);
|
||||
}, []);
|
||||
|
||||
const onCancelTask = useCallback(
|
||||
(taskId: string): void => {
|
||||
void (async () => {
|
||||
try {
|
||||
const task = teamData?.tasks.find((candidate) => candidate.id === taskId);
|
||||
await updateTaskStatus(teamName, taskId, 'pending');
|
||||
|
||||
if (task?.owner) {
|
||||
try {
|
||||
await sendTeamMessage(teamName, {
|
||||
member: task.owner,
|
||||
text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`,
|
||||
summary: `Task ${formatTaskDisplayLabel(task)} cancelled`,
|
||||
});
|
||||
} catch {
|
||||
// best-effort notification
|
||||
}
|
||||
}
|
||||
|
||||
if (teamData?.isAlive) {
|
||||
try {
|
||||
const ownerSuffix = task?.owner ? ` ${task.owner} has been notified to stop.` : '';
|
||||
await api.teams.processSend(
|
||||
teamName,
|
||||
`Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}`
|
||||
);
|
||||
} catch {
|
||||
// best-effort notification
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// error via store
|
||||
}
|
||||
})();
|
||||
},
|
||||
[sendTeamMessage, teamData, teamName, updateTaskStatus]
|
||||
);
|
||||
|
||||
const onMoveBackToDone = useCallback(
|
||||
(taskId: string): void => {
|
||||
void (async () => {
|
||||
try {
|
||||
await updateKanban(teamName, taskId, { op: 'remove' });
|
||||
await updateTaskStatus(teamName, taskId, 'completed');
|
||||
} catch {
|
||||
// error via store
|
||||
}
|
||||
})();
|
||||
},
|
||||
[teamName, updateKanban, updateTaskStatus]
|
||||
);
|
||||
|
||||
const onDeleteTask = useCallback(
|
||||
(taskId: string): void => {
|
||||
void (async () => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete task',
|
||||
message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`,
|
||||
confirmLabel: 'Delete',
|
||||
cancelLabel: 'Cancel',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
await softDeleteTask(teamName, taskId).catch(() => undefined);
|
||||
})();
|
||||
},
|
||||
[softDeleteTask, teamName]
|
||||
);
|
||||
|
||||
const handleSubmitRequestChanges = useCallback(
|
||||
(comment?: string, taskRefs?: TaskRef[]): void => {
|
||||
if (!requestChangesTaskId) return;
|
||||
void (async () => {
|
||||
try {
|
||||
await updateKanban(teamName, requestChangesTaskId, {
|
||||
op: 'request_changes',
|
||||
comment,
|
||||
taskRefs,
|
||||
});
|
||||
setRequestChangesTaskId(null);
|
||||
} catch {
|
||||
// error via store
|
||||
}
|
||||
})();
|
||||
},
|
||||
[requestChangesTaskId, teamName, updateKanban]
|
||||
);
|
||||
|
||||
const taskActionHandlers = useMemo<GraphTaskActionHandlers>(
|
||||
() => ({
|
||||
onApproveTask,
|
||||
onCancelTask,
|
||||
onCompleteTask,
|
||||
onDeleteTask,
|
||||
onMoveBackToDone,
|
||||
onRequestChanges,
|
||||
onRequestReview,
|
||||
onStartTask,
|
||||
}),
|
||||
[
|
||||
onApproveTask,
|
||||
onCancelTask,
|
||||
onCompleteTask,
|
||||
onDeleteTask,
|
||||
onMoveBackToDone,
|
||||
onRequestChanges,
|
||||
onRequestReview,
|
||||
onStartTask,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
...taskActionHandlers,
|
||||
taskActionHandlers,
|
||||
dialog: (
|
||||
<ReviewDialog
|
||||
open={requestChangesTaskId !== null}
|
||||
teamName={teamName}
|
||||
taskId={requestChangesTaskId}
|
||||
members={members}
|
||||
onCancel={() => setRequestChangesTaskId(null)}
|
||||
onSubmit={handleSubmitRequestChanges}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import { lazy, Suspense, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
selectResolvedMembersForTeamName,
|
||||
selectTeamDataForName,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
const TaskDetailDialog = lazy(() =>
|
||||
import('@renderer/components/team/dialogs/TaskDetailDialog').then((m) => ({
|
||||
default: m.TaskDetailDialog,
|
||||
}))
|
||||
);
|
||||
|
||||
interface UseGraphTaskDetailDialogInput {
|
||||
onDeleteTask?: (taskId: string) => void;
|
||||
onViewChanges?: (taskId: string, filePath?: string) => void;
|
||||
}
|
||||
|
||||
interface UseGraphTaskDetailDialogResult {
|
||||
dialog: React.ReactNode;
|
||||
openTaskDetail: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export function useGraphTaskDetailDialog(
|
||||
teamName: string,
|
||||
{ onDeleteTask, onViewChanges }: UseGraphTaskDetailDialogInput
|
||||
): UseGraphTaskDetailDialogResult {
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const { activeMembers, teamData, updateTaskOwner } = useStore(
|
||||
useShallow((state) => ({
|
||||
activeMembers: selectResolvedMembersForTeamName(state, teamName).filter(
|
||||
(member) => !member.removedAt
|
||||
),
|
||||
teamData: selectTeamDataForName(state, teamName),
|
||||
updateTaskOwner: state.updateTaskOwner,
|
||||
}))
|
||||
);
|
||||
|
||||
const taskMap = useMemo(
|
||||
() => new Map((teamData?.tasks ?? []).map((task) => [task.id, task])),
|
||||
[teamData?.tasks]
|
||||
);
|
||||
const selectedTask = selectedTaskId ? (taskMap.get(selectedTaskId) ?? null) : null;
|
||||
|
||||
const openTaskDetail = useCallback((taskId: string): void => {
|
||||
setSelectedTaskId(taskId);
|
||||
}, []);
|
||||
|
||||
const closeTaskDetail = useCallback((): void => {
|
||||
setSelectedTaskId(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
openTaskDetail,
|
||||
dialog:
|
||||
selectedTaskId && teamData ? (
|
||||
<Suspense fallback={null}>
|
||||
<TaskDetailDialog
|
||||
open
|
||||
task={selectedTask}
|
||||
teamName={teamName}
|
||||
kanbanTaskState={teamData.kanbanState.tasks[selectedTaskId]}
|
||||
taskMap={taskMap}
|
||||
members={activeMembers}
|
||||
onClose={closeTaskDetail}
|
||||
onScrollToTask={openTaskDetail}
|
||||
onOwnerChange={(taskId, owner) => {
|
||||
void updateTaskOwner(teamName, taskId, owner).catch(() => undefined);
|
||||
}}
|
||||
onViewChanges={onViewChanges}
|
||||
onDeleteTask={onDeleteTask}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null,
|
||||
};
|
||||
}
|
||||
|
|
@ -29,8 +29,9 @@ const LOG_PREVIEW_FALLBACK_WIDTH = 260;
|
|||
const LOG_PREVIEW_FALLBACK_HEIGHT = 292;
|
||||
const NEW_LOG_HIGHLIGHT_MS = 1_000;
|
||||
const COMPACT_ROW_TITLE_LIMIT = 24;
|
||||
const COMPACT_ROW_TEXT_LIMIT = 76;
|
||||
const COMPACT_ROW_MIN_PREVIEW_LIMIT = 40;
|
||||
const COMPACT_ROW_TEXT_LIMIT = 110;
|
||||
const COMPACT_ROW_MIN_PREVIEW_LIMIT = 96;
|
||||
const INTERACTIVE_LOG_CONTROL_CLASS = 'pointer-events-auto';
|
||||
|
||||
interface StableRectLike {
|
||||
left: number;
|
||||
|
|
@ -81,7 +82,7 @@ function formatRelativeTime(timestamp: string): string {
|
|||
}
|
||||
|
||||
function itemIcon(item: MemberLogPreviewItem): React.JSX.Element {
|
||||
const className = 'size-3.5 shrink-0';
|
||||
const className = 'size-3 shrink-0';
|
||||
const title = item.title.trim().toLowerCase();
|
||||
if (item.tone === 'error') {
|
||||
return <AlertCircle className={`${className} text-rose-300`} />;
|
||||
|
|
@ -253,10 +254,10 @@ function renderLoadingSkeleton(): React.JSX.Element {
|
|||
{[0, 1, 2].map((index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="flex h-[72px] min-h-[72px] w-full min-w-0 animate-pulse rounded-md border border-white/10 bg-[rgba(8,14,28,0.42)] px-2.5 py-1.5"
|
||||
className="grid h-[72px] min-h-[72px] w-full min-w-0 grid-cols-[1rem_minmax(0,1fr)] gap-x-1.5 overflow-hidden rounded-md border border-white/10 bg-[rgba(8,14,28,0.42)] px-2 py-1.5"
|
||||
>
|
||||
<span className="mr-2 mt-0.5 inline-flex size-5 shrink-0 rounded bg-white/10" />
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-2 pt-0.5">
|
||||
<span className="mt-0.5 inline-flex size-4 shrink-0 animate-pulse rounded bg-white/10" />
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1 pt-0.5">
|
||||
<span className="h-3 w-2/5 rounded bg-slate-400/20" />
|
||||
<span className="h-2.5 w-full rounded bg-slate-400/15" />
|
||||
<span className="h-2.5 w-2/3 rounded bg-slate-400/10" />
|
||||
|
|
@ -427,7 +428,7 @@ export const GraphMemberLogPreviewHud = ({
|
|||
|
||||
const baseOpacity = focusNodeIds && !focusNodeIds.has(node.id) ? 0.25 : 1;
|
||||
shell.style.opacity = String(baseOpacity);
|
||||
shell.style.pointerEvents = 'auto';
|
||||
shell.style.pointerEvents = 'none';
|
||||
shell.style.left = `${Math.round(laneRect.left)}px`;
|
||||
shell.style.top = `${Math.round(laneRect.top)}px`;
|
||||
shell.style.width = `${Math.round(laneRect.width)}px`;
|
||||
|
|
@ -526,35 +527,35 @@ export const GraphMemberLogPreviewHud = ({
|
|||
? 'border-rose-400/35 bg-rose-950/20 hover:border-rose-300/50 hover:bg-rose-950/30'
|
||||
: 'border-white/10 bg-[rgba(8,14,28,0.52)] hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]';
|
||||
const iconClassName = isError
|
||||
? 'float-left mr-2 mt-0 inline-flex size-5 shrink-0 items-center justify-center rounded bg-rose-500/10'
|
||||
: 'float-left mr-2 mt-0 inline-flex size-5 shrink-0 items-center justify-center rounded bg-white/5';
|
||||
const headerClassName = 'inline align-baseline';
|
||||
? 'inline-flex size-4 shrink-0 items-center justify-center rounded bg-rose-500/10'
|
||||
: 'inline-flex size-4 shrink-0 items-center justify-center rounded bg-white/5';
|
||||
const headerClassName = 'flex h-4 min-w-0 items-center gap-1.5';
|
||||
const titleClassName = isError
|
||||
? 'align-baseline text-[11px] font-medium leading-5 text-rose-100'
|
||||
: 'align-baseline text-[11px] font-medium leading-5 text-slate-200';
|
||||
? 'min-w-0 truncate text-[10.5px] font-medium leading-4 text-rose-100'
|
||||
: 'min-w-0 truncate text-[10.5px] font-medium leading-4 text-slate-200';
|
||||
const timeClassName = isError
|
||||
? 'ml-1 align-baseline text-[9px] font-normal leading-5 text-rose-300/70'
|
||||
: 'ml-1 align-baseline text-[9px] font-normal leading-5 text-slate-500';
|
||||
? 'shrink-0 text-[9px] font-normal leading-4 text-rose-300/70'
|
||||
: 'shrink-0 text-[9px] font-normal leading-4 text-slate-500';
|
||||
const previewClassName = isError
|
||||
? 'ml-1 break-words align-baseline text-[10px] leading-5 text-rose-100/85'
|
||||
: 'ml-1 break-words align-baseline text-[10px] leading-5 text-slate-300/85';
|
||||
? 'mt-1 line-clamp-2 min-w-0 break-words text-[10px] leading-[15px] text-rose-100/85'
|
||||
: 'mt-1 line-clamp-2 min-w-0 break-words text-[10px] leading-[15px] text-slate-300/85';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={[
|
||||
'block h-[72px] min-h-[72px] w-full min-w-0 overflow-hidden rounded-md border px-2.5 py-1 text-left text-slate-400 transition-[border-color,background-color,box-shadow] duration-500',
|
||||
`${INTERACTIVE_LOG_CONTROL_CLASS} flex h-[72px] min-h-[72px] w-full min-w-0 flex-col overflow-hidden rounded-md border px-2 py-1.5 text-left text-slate-400 transition-[border-color,background-color,box-shadow] duration-500`,
|
||||
rowStateClassName,
|
||||
].join(' ')}
|
||||
title={titleText}
|
||||
aria-label={titleText}
|
||||
onClick={() => openLogs(memberName)}
|
||||
>
|
||||
<span className={iconClassName} aria-hidden="true">
|
||||
{itemIcon(item)}
|
||||
</span>
|
||||
<span className={headerClassName}>
|
||||
<span className={iconClassName} aria-hidden="true">
|
||||
{itemIcon(item)}
|
||||
</span>
|
||||
<span className={titleClassName}>{displayTitle}</span>
|
||||
{relativeTime ? <span className={timeClassName}>{relativeTime}</span> : null}
|
||||
</span>
|
||||
|
|
@ -593,7 +594,7 @@ export const GraphMemberLogPreviewHud = ({
|
|||
ref={(element) => {
|
||||
shellRefs.current.set(node.id, element);
|
||||
}}
|
||||
className="pointer-events-auto absolute z-10 origin-top-left select-none opacity-0"
|
||||
className="pointer-events-none absolute z-10 origin-top-left select-none opacity-0"
|
||||
style={{
|
||||
width: `${laneWidth}px`,
|
||||
maxWidth: `${laneWidth}px`,
|
||||
|
|
@ -604,8 +605,8 @@ export const GraphMemberLogPreviewHud = ({
|
|||
}}
|
||||
>
|
||||
<div className="flex h-full min-w-0 max-w-full flex-col overflow-hidden">
|
||||
<div className="flex h-5 min-h-5 items-center gap-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
|
||||
<Wrench className="size-3 text-slate-500" />
|
||||
<div className="flex h-4 min-h-4 items-center gap-1 px-1 text-[9px] font-semibold tracking-[0.18em] text-slate-400/70">
|
||||
<Wrench className="size-2.5 text-slate-500" />
|
||||
Logs
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||
|
|
@ -614,7 +615,7 @@ export const GraphMemberLogPreviewHud = ({
|
|||
) : isEmptyLoading ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-0 flex-1 rounded-md text-left text-[11px] text-slate-400/60"
|
||||
className={`${INTERACTIVE_LOG_CONTROL_CLASS} flex min-h-0 flex-1 rounded-md text-left text-[11px] text-slate-400/60`}
|
||||
aria-busy="true"
|
||||
aria-label="Loading logs"
|
||||
onClick={() => openLogs(memberName)}
|
||||
|
|
@ -625,7 +626,7 @@ export const GraphMemberLogPreviewHud = ({
|
|||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-[72px] min-h-[72px] items-center rounded-md border border-dashed border-white/10 bg-[rgba(8,14,28,0.28)] px-3 text-left text-[11px] text-slate-400/60"
|
||||
className={`${INTERACTIVE_LOG_CONTROL_CLASS} flex h-[72px] min-h-[72px] items-center rounded-md border border-dashed border-white/10 bg-[rgba(8,14,28,0.28)] px-3 text-left text-[11px] text-slate-400/60`}
|
||||
onClick={() => openLogs(memberName)}
|
||||
>
|
||||
{resolveEmptyText(preview, loading, error)}
|
||||
|
|
@ -634,7 +635,7 @@ export const GraphMemberLogPreviewHud = ({
|
|||
{preview && preview.overflowCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 min-h-8 w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]"
|
||||
className={`${INTERACTIVE_LOG_CONTROL_CLASS} h-8 min-h-8 w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]`}
|
||||
onClick={() => openLogs(memberName)}
|
||||
>
|
||||
+{preview.overflowCount} more
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ import { useGraphMemberPopoverContext } from '../hooks/useGraphMemberPopoverCont
|
|||
import { GraphTaskCard } from './GraphTaskCard';
|
||||
|
||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||
import type {
|
||||
MemberActivityFilter,
|
||||
MemberDetailTab,
|
||||
} from '@renderer/components/team/members/memberDetailTypes';
|
||||
import type { TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
// ─── Tool name/preview formatters ───────────────────────────────────────────
|
||||
|
|
@ -74,7 +78,13 @@ interface GraphNodePopoverProps {
|
|||
onClose: () => void;
|
||||
onSendMessage?: (memberName: string) => void;
|
||||
onOpenTaskDetail?: (taskId: string) => void;
|
||||
onOpenMemberProfile?: (memberName: string) => void;
|
||||
onOpenMemberProfile?: (
|
||||
memberName: string,
|
||||
options?: {
|
||||
initialActivityFilter?: MemberActivityFilter;
|
||||
initialTab?: MemberDetailTab;
|
||||
}
|
||||
) => void;
|
||||
onCreateTask?: (owner: string) => void;
|
||||
onStartTask?: (taskId: string) => void;
|
||||
onCompleteTask?: (taskId: string) => void;
|
||||
|
|
@ -83,6 +93,7 @@ interface GraphNodePopoverProps {
|
|||
onRequestChanges?: (taskId: string) => void;
|
||||
onCancelTask?: (taskId: string) => void;
|
||||
onMoveBackToDone?: (taskId: string) => void;
|
||||
onViewChanges?: (taskId: string) => void;
|
||||
onDeleteTask?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -101,6 +112,7 @@ export const GraphNodePopover = ({
|
|||
onRequestChanges,
|
||||
onCancelTask,
|
||||
onMoveBackToDone,
|
||||
onViewChanges,
|
||||
onDeleteTask,
|
||||
}: GraphNodePopoverProps): React.JSX.Element => {
|
||||
if (node.kind === 'member' || node.kind === 'lead') {
|
||||
|
|
@ -140,6 +152,7 @@ export const GraphNodePopover = ({
|
|||
onRequestChanges={onRequestChanges}
|
||||
onCancelTask={onCancelTask}
|
||||
onMoveBackToDone={onMoveBackToDone}
|
||||
onViewChanges={onViewChanges}
|
||||
onDeleteTask={onDeleteTask}
|
||||
/>
|
||||
);
|
||||
|
|
@ -342,6 +355,9 @@ const MemberPopoverContent = ({
|
|||
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
|
||||
spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed,
|
||||
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
|
||||
spawnAgentToolAccepted: spawnEntry?.agentToolAccepted,
|
||||
spawnHardFailure: spawnEntry?.hardFailure,
|
||||
spawnLivenessKind: spawnEntry?.livenessKind,
|
||||
runtimeAdvisory: member.runtimeAdvisory,
|
||||
isLaunchSettling: provisioningPresentation?.hasMembersStillJoining ?? false,
|
||||
isTeamAlive: teamData?.isAlive,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ interface GraphTaskCardProps {
|
|||
onRequestChanges?: (taskId: string) => void;
|
||||
onCancelTask?: (taskId: string) => void;
|
||||
onMoveBackToDone?: (taskId: string) => void;
|
||||
onViewChanges?: (taskId: string) => void;
|
||||
onDeleteTask?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +81,7 @@ export const GraphTaskCard = ({
|
|||
onRequestChanges,
|
||||
onCancelTask,
|
||||
onMoveBackToDone,
|
||||
onViewChanges,
|
||||
onDeleteTask,
|
||||
}: GraphTaskCardProps): React.JSX.Element => {
|
||||
const taskId = node.domainRef.kind === 'task' ? node.domainRef.taskId : '';
|
||||
|
|
@ -143,6 +145,7 @@ export const GraphTaskCard = ({
|
|||
onRequestChanges={closeAct(onRequestChanges)}
|
||||
onCancelTask={closeAct(onCancelTask)}
|
||||
onMoveBackToDone={closeAct(onMoveBackToDone)}
|
||||
onViewChanges={onViewChanges ? closeAct(onViewChanges) : undefined}
|
||||
onDeleteTask={onDeleteTask ? closeAct(onDeleteTask) : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@
|
|||
* Follows the exact ProjectEditorOverlay pattern (lazy-loaded, fixed z-50).
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { GraphView } from '@claude-teams/agent-graph';
|
||||
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
||||
|
||||
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
|
||||
import { useGraphMessagesPanel } from '../hooks/useGraphMessagesPanel';
|
||||
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
||||
import { useGraphSurfaceInteractions } from '../hooks/useGraphSurfaceInteractions';
|
||||
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
|
||||
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
|
||||
|
||||
|
|
@ -26,10 +26,6 @@ import type {
|
|||
GraphEventPort,
|
||||
TransientHandoffCard,
|
||||
} from '@claude-teams/agent-graph';
|
||||
import type {
|
||||
MemberActivityFilter,
|
||||
MemberDetailTab,
|
||||
} from '@renderer/components/team/members/memberDetailTypes';
|
||||
|
||||
export interface TeamGraphOverlayProps {
|
||||
teamName: string;
|
||||
|
|
@ -38,15 +34,6 @@ export interface TeamGraphOverlayProps {
|
|||
sidebarVisible?: boolean;
|
||||
onToggleSidebar?: () => void;
|
||||
messagesPanelEnabled?: boolean;
|
||||
onSendMessage?: (memberName: string) => void;
|
||||
onOpenTaskDetail?: (taskId: string) => void;
|
||||
onOpenMemberProfile?: (
|
||||
memberName: string,
|
||||
options?: {
|
||||
initialTab?: MemberDetailTab;
|
||||
initialActivityFilter?: MemberActivityFilter;
|
||||
}
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const TeamGraphOverlay = ({
|
||||
|
|
@ -56,9 +43,6 @@ export const TeamGraphOverlay = ({
|
|||
sidebarVisible,
|
||||
onToggleSidebar,
|
||||
messagesPanelEnabled = true,
|
||||
onSendMessage,
|
||||
onOpenTaskDetail,
|
||||
onOpenMemberProfile,
|
||||
}: TeamGraphOverlayProps): React.JSX.Element => {
|
||||
const graphData = useTeamGraphAdapter(teamName);
|
||||
const {
|
||||
|
|
@ -69,7 +53,7 @@ export const TeamGraphOverlay = ({
|
|||
} = useTeamGraphSurfaceActions(teamName);
|
||||
const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } =
|
||||
useGraphSidebarVisibility();
|
||||
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
||||
const interactions = useGraphSurfaceInteractions(teamName);
|
||||
const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState<HTMLDivElement | null>(
|
||||
null
|
||||
);
|
||||
|
|
@ -79,55 +63,29 @@ export const TeamGraphOverlay = ({
|
|||
teamName,
|
||||
enabled: messagesPanelEnabled,
|
||||
mountPoint: messagesPanelMountPoint,
|
||||
onOpenMemberProfile: (memberName) => onOpenMemberProfile?.(memberName),
|
||||
onOpenTaskDetail: (taskId) => onOpenTaskDetail?.(taskId),
|
||||
onOpenMemberProfile: interactions.openMemberProfile,
|
||||
onOpenTaskDetail: interactions.openTaskDetail,
|
||||
});
|
||||
|
||||
// Task action dispatchers (same pattern as TeamGraphTab)
|
||||
const dispatchTaskAction = useCallback(
|
||||
(action: string) => (taskId: string) =>
|
||||
window.dispatchEvent(new CustomEvent(`graph:${action}`, { detail: { teamName, taskId } })),
|
||||
[teamName]
|
||||
);
|
||||
const taskActions = useMemo(
|
||||
() => ({
|
||||
onStartTask: dispatchTaskAction('start-task'),
|
||||
onCompleteTask: dispatchTaskAction('complete-task'),
|
||||
onApproveTask: dispatchTaskAction('approve-task'),
|
||||
onRequestReview: dispatchTaskAction('request-review'),
|
||||
onRequestChanges: dispatchTaskAction('request-changes'),
|
||||
onCancelTask: dispatchTaskAction('cancel-task'),
|
||||
onMoveBackToDone: dispatchTaskAction('move-back-to-done'),
|
||||
onDeleteTask: dispatchTaskAction('delete-task'),
|
||||
}),
|
||||
[dispatchTaskAction]
|
||||
);
|
||||
const openTeamPage = useCallback(() => {
|
||||
openTeamTab();
|
||||
onClose();
|
||||
}, [onClose, openTeamTab]);
|
||||
const openCreateTask = useCallback(() => {
|
||||
openCreateTaskDialog('');
|
||||
}, [openCreateTaskDialog]);
|
||||
interactions.openCreateTask('');
|
||||
}, [interactions]);
|
||||
const events: GraphEventPort = {
|
||||
onNodeDoubleClick: useCallback(
|
||||
(ref: GraphDomainRef) => {
|
||||
if (ref.kind === 'task') onOpenTaskDetail?.(ref.taskId);
|
||||
else if (ref.kind === 'member') onOpenMemberProfile?.(ref.memberName);
|
||||
if (ref.kind === 'task') interactions.openTaskDetail(ref.taskId);
|
||||
else if (ref.kind === 'member') interactions.openMemberProfile(ref.memberName);
|
||||
},
|
||||
[onOpenTaskDetail, onOpenMemberProfile]
|
||||
),
|
||||
onSendMessage: useCallback(
|
||||
(memberName: string) => onSendMessage?.(memberName),
|
||||
[onSendMessage]
|
||||
),
|
||||
onOpenTaskDetail: useCallback(
|
||||
(taskId: string) => onOpenTaskDetail?.(taskId),
|
||||
[onOpenTaskDetail]
|
||||
[interactions]
|
||||
),
|
||||
onSendMessage: interactions.openSendMessage,
|
||||
onOpenTaskDetail: interactions.openTaskDetail,
|
||||
onOpenMemberProfile: useCallback(
|
||||
(memberName: string) => onOpenMemberProfile?.(memberName),
|
||||
[onOpenMemberProfile]
|
||||
(memberName: string) => interactions.openMemberProfile(memberName),
|
||||
[interactions]
|
||||
),
|
||||
};
|
||||
|
||||
|
|
@ -205,8 +163,8 @@ export const TeamGraphOverlay = ({
|
|||
getViewportSize={getViewportSize}
|
||||
focusNodeIds={focusNodeIds}
|
||||
enabled={filters?.showActivity ?? true}
|
||||
onOpenTaskDetail={onOpenTaskDetail}
|
||||
onOpenMemberProfile={onOpenMemberProfile}
|
||||
onOpenTaskDetail={interactions.openTaskDetail}
|
||||
onOpenMemberProfile={interactions.openMemberProfile}
|
||||
/>
|
||||
<GraphMemberLogPreviewHud
|
||||
teamName={teamName}
|
||||
|
|
@ -217,7 +175,7 @@ export const TeamGraphOverlay = ({
|
|||
getViewportSize={getViewportSize}
|
||||
focusNodeIds={focusNodeIds}
|
||||
enabled={filters?.showLogs ?? true}
|
||||
onOpenMemberProfile={onOpenMemberProfile}
|
||||
onOpenMemberProfile={interactions.openMemberProfile}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
@ -230,7 +188,7 @@ export const TeamGraphOverlay = ({
|
|||
targetNode={targetNode}
|
||||
onClose={closeEdge}
|
||||
onSelectNode={onSelectNode}
|
||||
onOpenTaskDetail={onOpenTaskDetail}
|
||||
onOpenTaskDetail={interactions.openTaskDetail}
|
||||
/>
|
||||
)}
|
||||
renderOverlay={({ node, onClose: closePopover }) => (
|
||||
|
|
@ -239,19 +197,27 @@ export const TeamGraphOverlay = ({
|
|||
teamName={teamName}
|
||||
onClose={closePopover}
|
||||
onSendMessage={(name) => {
|
||||
onSendMessage?.(name);
|
||||
interactions.openSendMessage(name);
|
||||
closePopover();
|
||||
}}
|
||||
onCreateTask={openCreateTaskDialog}
|
||||
onCreateTask={interactions.openCreateTask}
|
||||
onOpenTaskDetail={(id) => {
|
||||
onOpenTaskDetail?.(id);
|
||||
interactions.openTaskDetail(id);
|
||||
closePopover();
|
||||
}}
|
||||
onOpenMemberProfile={(name) => {
|
||||
onOpenMemberProfile?.(name);
|
||||
onOpenMemberProfile={(name, options) => {
|
||||
interactions.openMemberProfile(name, options);
|
||||
closePopover();
|
||||
}}
|
||||
{...taskActions}
|
||||
onStartTask={interactions.onStartTask}
|
||||
onCompleteTask={interactions.onCompleteTask}
|
||||
onApproveTask={interactions.onApproveTask}
|
||||
onRequestReview={interactions.onRequestReview}
|
||||
onRequestChanges={interactions.onRequestChanges}
|
||||
onCancelTask={interactions.onCancelTask}
|
||||
onMoveBackToDone={interactions.onMoveBackToDone}
|
||||
onViewChanges={interactions.openTaskChanges}
|
||||
onDeleteTask={interactions.onDeleteTask}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -262,7 +228,7 @@ export const TeamGraphOverlay = ({
|
|||
/>
|
||||
) : null}
|
||||
{graphMessagesPanel}
|
||||
{createTaskDialog}
|
||||
{interactions.dialogs}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@
|
|||
* Provides Fullscreen button that opens the overlay.
|
||||
*/
|
||||
|
||||
import { lazy, Suspense, useCallback, useMemo, useState } from 'react';
|
||||
import { lazy, Suspense, useCallback, useState } from 'react';
|
||||
|
||||
import { GraphView } from '@claude-teams/agent-graph';
|
||||
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
||||
|
||||
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
|
||||
import { useGraphMessagesPanel } from '../hooks/useGraphMessagesPanel';
|
||||
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
||||
import { useGraphSurfaceInteractions } from '../hooks/useGraphSurfaceInteractions';
|
||||
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
|
||||
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
|
||||
|
||||
|
|
@ -26,10 +26,6 @@ import type {
|
|||
GraphEventPort,
|
||||
TransientHandoffCard,
|
||||
} from '@claude-teams/agent-graph';
|
||||
import type {
|
||||
MemberActivityFilter,
|
||||
MemberDetailTab,
|
||||
} from '@renderer/components/team/members/memberDetailTypes';
|
||||
|
||||
const TeamGraphOverlay = lazy(() =>
|
||||
import('./TeamGraphOverlay').then((m) => ({ default: m.TeamGraphOverlay }))
|
||||
|
|
@ -41,11 +37,6 @@ export interface TeamGraphTabProps {
|
|||
isPaneFocused?: boolean;
|
||||
}
|
||||
|
||||
interface OpenProfileOptions {
|
||||
initialTab?: MemberDetailTab;
|
||||
initialActivityFilter?: MemberActivityFilter;
|
||||
}
|
||||
|
||||
export const TeamGraphTab = ({
|
||||
teamName,
|
||||
isActive = true,
|
||||
|
|
@ -59,86 +50,34 @@ export const TeamGraphTab = ({
|
|||
null
|
||||
);
|
||||
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
|
||||
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
||||
|
||||
// Typed event dispatchers (DRY — used in both events + renderOverlay)
|
||||
const dispatchOpenTask = useCallback(
|
||||
(taskId: string) =>
|
||||
window.dispatchEvent(new CustomEvent('graph:open-task', { detail: { teamName, taskId } })),
|
||||
[teamName]
|
||||
);
|
||||
const dispatchSendMessage = useCallback(
|
||||
(memberName: string) =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('graph:send-message', { detail: { teamName, memberName } })
|
||||
),
|
||||
[teamName]
|
||||
);
|
||||
const dispatchOpenProfile = useCallback(
|
||||
(memberName: string, options?: OpenProfileOptions) =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('graph:open-profile', {
|
||||
detail: { teamName, memberName, ...options },
|
||||
})
|
||||
),
|
||||
[teamName]
|
||||
);
|
||||
const interactions = useGraphSurfaceInteractions(teamName);
|
||||
const openCreateTask = useCallback(() => {
|
||||
openCreateTaskDialog('');
|
||||
}, [openCreateTaskDialog]);
|
||||
// Task action dispatchers
|
||||
const dispatchTaskAction = useCallback(
|
||||
(action: string) => (taskId: string) =>
|
||||
window.dispatchEvent(new CustomEvent(`graph:${action}`, { detail: { teamName, taskId } })),
|
||||
[teamName]
|
||||
);
|
||||
const dispatchStartTask = useMemo(() => dispatchTaskAction('start-task'), [dispatchTaskAction]);
|
||||
const dispatchCompleteTask = useMemo(
|
||||
() => dispatchTaskAction('complete-task'),
|
||||
[dispatchTaskAction]
|
||||
);
|
||||
const dispatchApproveTask = useMemo(
|
||||
() => dispatchTaskAction('approve-task'),
|
||||
[dispatchTaskAction]
|
||||
);
|
||||
const dispatchRequestReview = useMemo(
|
||||
() => dispatchTaskAction('request-review'),
|
||||
[dispatchTaskAction]
|
||||
);
|
||||
const dispatchRequestChanges = useMemo(
|
||||
() => dispatchTaskAction('request-changes'),
|
||||
[dispatchTaskAction]
|
||||
);
|
||||
const dispatchCancelTask = useMemo(() => dispatchTaskAction('cancel-task'), [dispatchTaskAction]);
|
||||
const dispatchMoveBackToDone = useMemo(
|
||||
() => dispatchTaskAction('move-back-to-done'),
|
||||
[dispatchTaskAction]
|
||||
);
|
||||
const dispatchDeleteTask = useMemo(() => dispatchTaskAction('delete-task'), [dispatchTaskAction]);
|
||||
interactions.openCreateTask('');
|
||||
}, [interactions]);
|
||||
|
||||
const events: GraphEventPort = {
|
||||
onNodeDoubleClick: useCallback(
|
||||
(ref: GraphDomainRef) => {
|
||||
if (ref.kind === 'task') dispatchOpenTask(ref.taskId);
|
||||
else if (ref.kind === 'member') dispatchOpenProfile(ref.memberName);
|
||||
if (ref.kind === 'task') interactions.openTaskDetail(ref.taskId);
|
||||
else if (ref.kind === 'member') interactions.openMemberProfile(ref.memberName);
|
||||
},
|
||||
[dispatchOpenTask, dispatchOpenProfile]
|
||||
[interactions]
|
||||
),
|
||||
onSendMessage: dispatchSendMessage,
|
||||
onOpenTaskDetail: dispatchOpenTask,
|
||||
onSendMessage: interactions.openSendMessage,
|
||||
onOpenTaskDetail: interactions.openTaskDetail,
|
||||
onOpenMemberProfile: useCallback(
|
||||
(memberName: string) => {
|
||||
dispatchOpenProfile(memberName);
|
||||
interactions.openMemberProfile(memberName);
|
||||
},
|
||||
[dispatchOpenProfile]
|
||||
[interactions]
|
||||
),
|
||||
};
|
||||
const graphMessagesPanel = useGraphMessagesPanel({
|
||||
teamName,
|
||||
enabled: isActive && isPaneFocused && !fullscreen,
|
||||
mountPoint: messagesPanelMountPoint,
|
||||
onOpenMemberProfile: dispatchOpenProfile,
|
||||
onOpenTaskDetail: dispatchOpenTask,
|
||||
onOpenMemberProfile: interactions.openMemberProfile,
|
||||
onOpenTaskDetail: interactions.openTaskDetail,
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
@ -224,8 +163,8 @@ export const TeamGraphTab = ({
|
|||
getViewportSize={getViewportSize}
|
||||
focusNodeIds={focusNodeIds}
|
||||
enabled={isActive && (filters?.showActivity ?? true)}
|
||||
onOpenTaskDetail={dispatchOpenTask}
|
||||
onOpenMemberProfile={dispatchOpenProfile}
|
||||
onOpenTaskDetail={interactions.openTaskDetail}
|
||||
onOpenMemberProfile={interactions.openMemberProfile}
|
||||
/>
|
||||
<GraphMemberLogPreviewHud
|
||||
teamName={teamName}
|
||||
|
|
@ -236,7 +175,7 @@ export const TeamGraphTab = ({
|
|||
getViewportSize={getViewportSize}
|
||||
focusNodeIds={focusNodeIds}
|
||||
enabled={isActive && (filters?.showLogs ?? true)}
|
||||
onOpenMemberProfile={dispatchOpenProfile}
|
||||
onOpenMemberProfile={interactions.openMemberProfile}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
@ -249,7 +188,7 @@ export const TeamGraphTab = ({
|
|||
targetNode={targetNode}
|
||||
onClose={onClose}
|
||||
onSelectNode={onSelectNode}
|
||||
onOpenTaskDetail={dispatchOpenTask}
|
||||
onOpenTaskDetail={interactions.openTaskDetail}
|
||||
/>
|
||||
)}
|
||||
renderOverlay={({ node, onClose }) => (
|
||||
|
|
@ -257,18 +196,19 @@ export const TeamGraphTab = ({
|
|||
node={node}
|
||||
teamName={teamName}
|
||||
onClose={onClose}
|
||||
onSendMessage={dispatchSendMessage}
|
||||
onOpenTaskDetail={dispatchOpenTask}
|
||||
onOpenMemberProfile={dispatchOpenProfile}
|
||||
onCreateTask={openCreateTaskDialog}
|
||||
onStartTask={dispatchStartTask}
|
||||
onCompleteTask={dispatchCompleteTask}
|
||||
onApproveTask={dispatchApproveTask}
|
||||
onRequestReview={dispatchRequestReview}
|
||||
onRequestChanges={dispatchRequestChanges}
|
||||
onCancelTask={dispatchCancelTask}
|
||||
onMoveBackToDone={dispatchMoveBackToDone}
|
||||
onDeleteTask={dispatchDeleteTask}
|
||||
onSendMessage={interactions.openSendMessage}
|
||||
onOpenTaskDetail={interactions.openTaskDetail}
|
||||
onOpenMemberProfile={interactions.openMemberProfile}
|
||||
onCreateTask={interactions.openCreateTask}
|
||||
onStartTask={interactions.onStartTask}
|
||||
onCompleteTask={interactions.onCompleteTask}
|
||||
onApproveTask={interactions.onApproveTask}
|
||||
onRequestReview={interactions.onRequestReview}
|
||||
onRequestChanges={interactions.onRequestChanges}
|
||||
onCancelTask={interactions.onCancelTask}
|
||||
onMoveBackToDone={interactions.onMoveBackToDone}
|
||||
onViewChanges={interactions.openTaskChanges}
|
||||
onDeleteTask={interactions.onDeleteTask}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
@ -280,7 +220,7 @@ export const TeamGraphTab = ({
|
|||
/>
|
||||
) : null}
|
||||
{graphMessagesPanel}
|
||||
{createTaskDialog}
|
||||
{interactions.dialogs}
|
||||
{fullscreen && (
|
||||
<Suspense fallback={null}>
|
||||
<TeamGraphOverlay
|
||||
|
|
@ -288,9 +228,6 @@ export const TeamGraphTab = ({
|
|||
onClose={() => setFullscreen(false)}
|
||||
sidebarVisible={sidebarVisible}
|
||||
onToggleSidebar={toggleSidebarVisible}
|
||||
onSendMessage={dispatchSendMessage}
|
||||
onOpenTaskDetail={dispatchOpenTask}
|
||||
onOpenMemberProfile={dispatchOpenProfile}
|
||||
messagesPanelEnabled={isActive && isPaneFocused}
|
||||
/>
|
||||
</Suspense>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import {
|
|||
CodexBinaryResolver,
|
||||
JsonRpcStdioClient,
|
||||
} from '@main/services/infrastructure/codexAppServer';
|
||||
import { getCachedShellEnv } from '@main/utils/shellEnv';
|
||||
import { getCachedShellEnv, resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
|
||||
|
||||
import { CodexAccountSnapshotPresenter } from '../adapters/output/presenters/CodexAccountSnapshotPresenter';
|
||||
import { CodexAccountAppServerClient } from '../infrastructure/CodexAccountAppServerClient';
|
||||
|
|
@ -41,6 +41,7 @@ type LoggerPort = Pick<Logger, 'info' | 'warn' | 'error'>;
|
|||
const SNAPSHOT_CACHE_TTL_MS = 5_000;
|
||||
const RATE_LIMITS_CACHE_TTL_MS = 45_000;
|
||||
const LAST_KNOWN_GOOD_MANAGED_ACCOUNT_TTL_MS = 60_000;
|
||||
const CODEX_BINARY_COLD_RETRY_TIMEOUT_MS = 12_000;
|
||||
|
||||
interface CodexLastKnownAccount {
|
||||
payload: CodexAppServerGetAccountResponse;
|
||||
|
|
@ -251,6 +252,20 @@ function createDeferred(): { promise: Promise<void>; resolve: () => void } {
|
|||
};
|
||||
}
|
||||
|
||||
async function resolveCodexBinaryForAccountSnapshot(): Promise<string | null> {
|
||||
const binaryPath = await CodexBinaryResolver.resolve();
|
||||
if (binaryPath) {
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: CODEX_BINARY_COLD_RETRY_TIMEOUT_MS,
|
||||
fallbackEnv: process.env,
|
||||
});
|
||||
CodexBinaryResolver.clearCache();
|
||||
return CodexBinaryResolver.resolve();
|
||||
}
|
||||
|
||||
export interface CodexAccountFeatureFacade {
|
||||
getSnapshot(): Promise<CodexAccountSnapshotDto>;
|
||||
refreshSnapshot(options?: {
|
||||
|
|
@ -351,7 +366,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
}): Promise<CodexAccountSnapshotDto> {
|
||||
let binaryMissing = false;
|
||||
await this.runSerializedMutation(async () => {
|
||||
const binaryPath = await CodexBinaryResolver.resolve();
|
||||
const binaryPath = await resolveCodexBinaryForAccountSnapshot();
|
||||
if (!binaryPath) {
|
||||
binaryMissing = true;
|
||||
return;
|
||||
|
|
@ -380,7 +395,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
await this.runSerializedMutation(async () => {
|
||||
await this.loginSessionManager.cancel().catch(() => undefined);
|
||||
|
||||
const binaryPath = await CodexBinaryResolver.resolve();
|
||||
const binaryPath = await resolveCodexBinaryForAccountSnapshot();
|
||||
if (!binaryPath) {
|
||||
throw new Error('Codex CLI is not available, so logout cannot be completed.');
|
||||
}
|
||||
|
|
@ -467,7 +482,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
const localAccountState = await detectCodexLocalAccountState();
|
||||
const localAccountArtifactsPresent = localAccountState.hasArtifacts;
|
||||
const localActiveChatgptAccountPresent = localAccountState.hasActiveChatgptAccount;
|
||||
const binaryPath = await CodexBinaryResolver.resolve();
|
||||
const binaryPath = await resolveCodexBinaryForAccountSnapshot();
|
||||
const login = this.loginSessionManager.getState();
|
||||
const now = Date.now();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { CODEX_RUNTIME_PROGRESS } from '@features/codex-runtime-installer/contracts';
|
||||
import { execCli } from '@main/utils/childProcess';
|
||||
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
|
||||
import { getAppDataPath } from '@main/utils/pathDecoder';
|
||||
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
|
||||
import { getCachedShellEnv } from '@main/utils/shellEnv';
|
||||
import { getCachedShellEnv, resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
|
|
@ -27,6 +28,7 @@ const MAX_TARBALL_BYTES = 160 * 1024 * 1024;
|
|||
const MAX_UNPACKED_BYTES = 650 * 1024 * 1024;
|
||||
const FETCH_TIMEOUT_MS = 60_000;
|
||||
const VERSION_TIMEOUT_MS = 10_000;
|
||||
const PATH_SHELL_ENV_TIMEOUT_MS = 1_500;
|
||||
|
||||
interface NpmPackageMetadata {
|
||||
name?: string;
|
||||
|
|
@ -149,9 +151,16 @@ function splitPathEnv(pathValue: string | undefined): string[] {
|
|||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function resolvePathCodexBinary(): string | null {
|
||||
function resolvePathCodexBinary(
|
||||
additionalEnvSources: (NodeJS.ProcessEnv | null | undefined)[] = []
|
||||
): string | null {
|
||||
const shellEnv = getCachedShellEnv() ?? {};
|
||||
const pathEntries = [...splitPathEnv(shellEnv.PATH), ...splitPathEnv(process.env.PATH)];
|
||||
const pathEntries = [
|
||||
...additionalEnvSources.flatMap((env) => splitPathEnv(env?.PATH)),
|
||||
...splitPathEnv(shellEnv.PATH),
|
||||
...splitPathEnv(buildMergedCliPath(null)),
|
||||
...splitPathEnv(process.env.PATH),
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
for (const entry of pathEntries) {
|
||||
const normalizedEntry = path.resolve(entry);
|
||||
|
|
@ -169,6 +178,21 @@ function resolvePathCodexBinary(): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
async function resolvePathCodexBinaryWithBestEffortEnv(
|
||||
options: { shellEnvTimeoutMs?: number } = {}
|
||||
): Promise<string | null> {
|
||||
const cachedCandidate = resolvePathCodexBinary();
|
||||
if (cachedCandidate) {
|
||||
return cachedCandidate;
|
||||
}
|
||||
|
||||
const shellEnv = await resolveInteractiveShellEnvBestEffort({
|
||||
timeoutMs: options.shellEnvTimeoutMs ?? PATH_SHELL_ENV_TIMEOUT_MS,
|
||||
fallbackEnv: process.env,
|
||||
});
|
||||
return resolvePathCodexBinary([shellEnv]);
|
||||
}
|
||||
|
||||
export function getCodexRuntimePlatformCandidates(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
arch: string = process.arch
|
||||
|
|
@ -543,7 +567,7 @@ export class CodexRuntimeInstallerService implements CodexRuntimeInstallerPort {
|
|||
}
|
||||
|
||||
private async getPathStatus(): Promise<CodexRuntimeStatus> {
|
||||
const binaryPath = resolvePathCodexBinary();
|
||||
const binaryPath = await resolvePathCodexBinaryWithBestEffortEnv();
|
||||
if (!binaryPath) {
|
||||
return { installed: false, source: 'missing', state: 'idle' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
hasWorkSyncActiveRuntime,
|
||||
isRuntimeEntryActiveForWorkSync,
|
||||
} from '../memberWorkSyncTeamActivity';
|
||||
|
||||
import type { TeamAgentRuntimeEntry, TeamAgentRuntimeSnapshot } from '@shared/types';
|
||||
|
||||
function createRuntimeEntry(overrides: Partial<TeamAgentRuntimeEntry> = {}): TeamAgentRuntimeEntry {
|
||||
return {
|
||||
memberName: 'alice',
|
||||
alive: true,
|
||||
restartable: true,
|
||||
backendType: 'process',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
livenessKind: 'runtime_process',
|
||||
pid: 46773,
|
||||
updatedAt: '2026-05-18T19:44:48.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntimeSnapshot(
|
||||
members: Record<string, TeamAgentRuntimeEntry>
|
||||
): TeamAgentRuntimeSnapshot {
|
||||
return {
|
||||
teamName: 'signal-ops-6',
|
||||
updatedAt: '2026-05-18T19:44:48.000Z',
|
||||
runId: null,
|
||||
members,
|
||||
};
|
||||
}
|
||||
|
||||
describe('member work sync team activity', () => {
|
||||
it('treats a verified runtime process as active', () => {
|
||||
expect(isRuntimeEntryActiveForWorkSync(createRuntimeEntry())).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a confirmed bootstrap runtime entry as active', () => {
|
||||
expect(
|
||||
isRuntimeEntryActiveForWorkSync(
|
||||
createRuntimeEntry({
|
||||
livenessKind: 'confirmed_bootstrap',
|
||||
runtimeLastSeenAt: '2026-05-18T19:44:47.000Z',
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not treat inactive liveness diagnostics as active by themselves', () => {
|
||||
for (const livenessKind of [
|
||||
'permission_blocked',
|
||||
'runtime_process_candidate',
|
||||
'shell_only',
|
||||
'registered_only',
|
||||
'stale_metadata',
|
||||
'not_found',
|
||||
] as const) {
|
||||
expect(isRuntimeEntryActiveForWorkSync(createRuntimeEntry({ livenessKind }))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not treat a runtime candidate as active until it is alive', () => {
|
||||
expect(
|
||||
isRuntimeEntryActiveForWorkSync(
|
||||
createRuntimeEntry({
|
||||
alive: false,
|
||||
livenessKind: 'runtime_process_candidate',
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('detects an active runtime among stale members', () => {
|
||||
expect(
|
||||
hasWorkSyncActiveRuntime(
|
||||
createRuntimeSnapshot({
|
||||
alice: createRuntimeEntry({ alive: false, livenessKind: 'stale_metadata' }),
|
||||
bob: createRuntimeEntry({ memberName: 'bob', livenessKind: 'runtime_process' }),
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when no member has active runtime evidence', () => {
|
||||
expect(
|
||||
hasWorkSyncActiveRuntime(
|
||||
createRuntimeSnapshot({
|
||||
alice: createRuntimeEntry({ alive: false, livenessKind: 'stale_metadata' }),
|
||||
bob: createRuntimeEntry({
|
||||
memberName: 'bob',
|
||||
alive: false,
|
||||
livenessKind: 'registered_only',
|
||||
}),
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('handles missing snapshots as inactive', () => {
|
||||
expect(hasWorkSyncActiveRuntime(null)).toBe(false);
|
||||
expect(hasWorkSyncActiveRuntime(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import type { TeamAgentRuntimeEntry, TeamAgentRuntimeSnapshot } from '@shared/types';
|
||||
|
||||
type RuntimeLivenessKind = NonNullable<TeamAgentRuntimeEntry['livenessKind']>;
|
||||
|
||||
const WORK_SYNC_INACTIVE_LIVENESS_KINDS = new Set<RuntimeLivenessKind>([
|
||||
'permission_blocked',
|
||||
'runtime_process_candidate',
|
||||
'shell_only',
|
||||
'registered_only',
|
||||
'stale_metadata',
|
||||
'not_found',
|
||||
]);
|
||||
|
||||
export function isRuntimeEntryActiveForWorkSync(
|
||||
entry: Pick<TeamAgentRuntimeEntry, 'alive' | 'livenessKind'> | null | undefined
|
||||
): boolean {
|
||||
if (entry?.alive !== true) {
|
||||
return false;
|
||||
}
|
||||
if (!entry.livenessKind) {
|
||||
return true;
|
||||
}
|
||||
return !WORK_SYNC_INACTIVE_LIVENESS_KINDS.has(entry.livenessKind);
|
||||
}
|
||||
|
||||
export function hasWorkSyncActiveRuntime(
|
||||
snapshot: Pick<TeamAgentRuntimeSnapshot, 'members'> | null | undefined
|
||||
): boolean {
|
||||
return Object.values(snapshot?.members ?? {}).some(isRuntimeEntryActiveForWorkSync);
|
||||
}
|
||||
|
|
@ -8,3 +8,7 @@ export {
|
|||
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
|
||||
createMemberWorkSyncFeature,
|
||||
} from './composition/createMemberWorkSyncFeature';
|
||||
export {
|
||||
hasWorkSyncActiveRuntime,
|
||||
isRuntimeEntryActiveForWorkSync,
|
||||
} from './composition/memberWorkSyncTeamActivity';
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export async function listTmuxPaneRuntimeInfoForCurrentPlatform(
|
|||
return runtimeCommandExecutor.listPaneRuntimeInfo(paneIds);
|
||||
}
|
||||
|
||||
export async function listRuntimeProcessesForCurrentTmuxPlatform(): Promise<
|
||||
export async function listRuntimeProcessTableForCurrentPlatform(): Promise<
|
||||
RuntimeProcessTableRow[]
|
||||
> {
|
||||
return runtimeCommandExecutor.listRuntimeProcesses();
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export {
|
|||
isTmuxRuntimeReadyForCurrentPlatform,
|
||||
killTmuxPaneForCurrentPlatform,
|
||||
killTmuxPaneForCurrentPlatformSync,
|
||||
listRuntimeProcessesForCurrentTmuxPlatform,
|
||||
listRuntimeProcessTableForCurrentPlatform,
|
||||
listTmuxPanePidsForCurrentPlatform,
|
||||
listTmuxPaneRuntimeInfoForCurrentPlatform,
|
||||
sendKeysToTmuxPaneForCurrentPlatform,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const EXACT_STRIP_ENV_KEYS = new Set([
|
|||
'CLAUDE_CODE_GEMINI_BACKEND',
|
||||
'CLAUDE_CODE_CODEX_BACKEND',
|
||||
'CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH',
|
||||
'OPENCODE_BIN_PATH',
|
||||
'CODEX_HOME',
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
} from '@shared/utils/effortLevels';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { isTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import { constants as fsConstants } from 'fs';
|
||||
import { access } from 'fs/promises';
|
||||
|
|
@ -33,6 +33,9 @@ type CreateTeamBody = TeamCreateConfigRequest;
|
|||
class HttpBadRequestError extends Error {}
|
||||
class HttpFeatureUnavailableError extends Error {}
|
||||
|
||||
const PROVIDER_BACKEND_ERROR =
|
||||
'providerBackendId must be valid for the selected provider (auto, adapter, api, cli-sdk, codex-native, or opencode-cli)';
|
||||
|
||||
function isMemberWorkSyncReportState(value: string): value is MemberWorkSyncReportState {
|
||||
return value === 'still_working' || value === 'blocked' || value === 'caught_up';
|
||||
}
|
||||
|
|
@ -212,14 +215,30 @@ function parseProviderBackendId(
|
|||
const rawProviderBackendId = assertOptionalString(value, 'providerBackendId');
|
||||
const providerBackendId = migrateProviderBackendId(providerId, rawProviderBackendId);
|
||||
if (rawProviderBackendId && !providerBackendId) {
|
||||
throw new HttpBadRequestError(
|
||||
'providerBackendId must be one of auto, adapter, api, cli-sdk, or codex-native'
|
||||
);
|
||||
throw new HttpBadRequestError(PROVIDER_BACKEND_ERROR);
|
||||
}
|
||||
return providerBackendId;
|
||||
}
|
||||
|
||||
function parseCreateMembers(payloadMembers: unknown): TeamCreateConfigRequest['members'] {
|
||||
function parseLaunchProviderBackendId(
|
||||
providerId: TeamLaunchRequest['providerId'],
|
||||
value: unknown
|
||||
): TeamLaunchRequest['providerBackendId'] | undefined {
|
||||
const rawProviderBackendId = assertOptionalString(value, 'providerBackendId');
|
||||
const providerBackendId = migrateProviderBackendId(providerId, rawProviderBackendId);
|
||||
if (providerBackendId || !rawProviderBackendId) {
|
||||
return providerBackendId;
|
||||
}
|
||||
if (isTeamProviderBackendId(rawProviderBackendId)) {
|
||||
return undefined;
|
||||
}
|
||||
throw new HttpBadRequestError(PROVIDER_BACKEND_ERROR);
|
||||
}
|
||||
|
||||
function parseCreateMembers(
|
||||
payloadMembers: unknown,
|
||||
defaultProviderId: TeamLaunchRequest['providerId']
|
||||
): TeamCreateConfigRequest['members'] {
|
||||
if (payloadMembers == null) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -250,9 +269,12 @@ function parseCreateMembers(payloadMembers: unknown): TeamCreateConfigRequest['m
|
|||
}
|
||||
const providerId =
|
||||
rawMember.providerId == null ? undefined : parseProviderId(rawMember.providerId);
|
||||
const providerBackendId = parseProviderBackendId(providerId, rawMember.providerBackendId);
|
||||
const providerBackendId = parseProviderBackendId(
|
||||
providerId ?? defaultProviderId,
|
||||
rawMember.providerBackendId
|
||||
);
|
||||
const model = assertOptionalString(rawMember.model, 'member model');
|
||||
const effort = assertOptionalEffort(rawMember.effort, providerId);
|
||||
const effort = assertOptionalEffort(rawMember.effort, providerId ?? defaultProviderId);
|
||||
const fastMode = assertOptionalFastMode(rawMember.fastMode);
|
||||
|
||||
return {
|
||||
|
|
@ -273,9 +295,9 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest
|
|||
const payload = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
|
||||
const providerId = parseProviderId(payload.providerId);
|
||||
const prompt = assertOptionalString(payload.prompt, 'prompt');
|
||||
const providerBackendId = parseProviderBackendId(providerId, payload.providerBackendId);
|
||||
const providerBackendId = parseLaunchProviderBackendId(providerId, payload.providerBackendId);
|
||||
const model = assertOptionalString(payload.model, 'model');
|
||||
const effort = assertOptionalEffort(payload.effort, providerId);
|
||||
const effort = assertOptionalEffort(payload.effort, providerId ?? 'anthropic');
|
||||
const fastMode = assertOptionalFastMode(payload.fastMode);
|
||||
const clearContext = assertOptionalBoolean(payload.clearContext, 'clearContext');
|
||||
const skipPermissions = assertOptionalBoolean(payload.skipPermissions, 'skipPermissions');
|
||||
|
|
@ -320,14 +342,14 @@ function parseCreateTeamRequest(body: unknown): TeamCreateConfigRequest {
|
|||
const payload = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
|
||||
const teamName = assertProvisioningTeamName(payload.teamName);
|
||||
const providerId = payload.providerId == null ? undefined : parseProviderId(payload.providerId);
|
||||
const providerBackendId = parseProviderBackendId(providerId, payload.providerBackendId);
|
||||
const providerBackendId = parseLaunchProviderBackendId(providerId, payload.providerBackendId);
|
||||
const displayName = assertOptionalString(payload.displayName, 'displayName');
|
||||
const description = assertOptionalString(payload.description, 'description');
|
||||
const color = assertOptionalString(payload.color, 'color');
|
||||
const cwd = assertOptionalCwd(payload.cwd);
|
||||
const prompt = assertOptionalString(payload.prompt, 'prompt');
|
||||
const model = assertOptionalString(payload.model, 'model');
|
||||
const effort = assertOptionalEffort(payload.effort, providerId);
|
||||
const effort = assertOptionalEffort(payload.effort, providerId ?? 'anthropic');
|
||||
const fastMode = assertOptionalFastMode(payload.fastMode);
|
||||
const limitContext = assertOptionalBoolean(payload.limitContext, 'limitContext');
|
||||
const skipPermissions = assertOptionalBoolean(payload.skipPermissions, 'skipPermissions');
|
||||
|
|
@ -336,7 +358,7 @@ function parseCreateTeamRequest(body: unknown): TeamCreateConfigRequest {
|
|||
|
||||
return {
|
||||
teamName,
|
||||
members: parseCreateMembers(payload.members),
|
||||
members: parseCreateMembers(payload.members, providerId ?? 'anthropic'),
|
||||
...(displayName ? { displayName } : {}),
|
||||
...(description ? { description } : {}),
|
||||
...(color ? { color } : {}),
|
||||
|
|
@ -389,19 +411,29 @@ function parseDraftLaunchCreateRequest(
|
|||
const providerId = Object.hasOwn(payload, 'providerId')
|
||||
? parseProviderId(payload.providerId)
|
||||
: (savedRequest.providerId ?? 'anthropic');
|
||||
const providerBackendId = parseProviderBackendId(
|
||||
const providerChangedFromSaved =
|
||||
Object.hasOwn(payload, 'providerId') && providerId !== (savedRequest.providerId ?? 'anthropic');
|
||||
const providerBackendId = parseLaunchProviderBackendId(
|
||||
providerId,
|
||||
Object.hasOwn(payload, 'providerBackendId')
|
||||
? payload.providerBackendId
|
||||
: savedRequest.providerBackendId
|
||||
: providerChangedFromSaved
|
||||
? undefined
|
||||
: savedRequest.providerBackendId
|
||||
);
|
||||
const effort = assertOptionalEffort(
|
||||
Object.hasOwn(payload, 'effort') ? payload.effort : savedRequest.effort,
|
||||
Object.hasOwn(payload, 'effort')
|
||||
? payload.effort
|
||||
: providerChangedFromSaved
|
||||
? undefined
|
||||
: savedRequest.effort,
|
||||
providerId
|
||||
);
|
||||
const fastMode = Object.hasOwn(payload, 'fastMode')
|
||||
? assertOptionalFastMode(payload.fastMode)
|
||||
: savedRequest.fastMode;
|
||||
: providerChangedFromSaved
|
||||
? undefined
|
||||
: savedRequest.fastMode;
|
||||
const extraCliArgs = Object.hasOwn(payload, 'extraCliArgs')
|
||||
? assertOptionalExtraCliArgs(payload.extraCliArgs)
|
||||
: savedRequest.extraCliArgs;
|
||||
|
|
@ -419,13 +451,18 @@ function parseDraftLaunchCreateRequest(
|
|||
prompt: pickOptionalString(payload, 'prompt', savedRequest.prompt, 'prompt'),
|
||||
providerId,
|
||||
...(providerBackendId ? { providerBackendId } : {}),
|
||||
model: pickOptionalString(payload, 'model', savedRequest.model, 'model'),
|
||||
model: pickOptionalString(
|
||||
payload,
|
||||
'model',
|
||||
providerChangedFromSaved ? undefined : savedRequest.model,
|
||||
'model'
|
||||
),
|
||||
...(effort ? { effort } : {}),
|
||||
...(fastMode ? { fastMode } : {}),
|
||||
limitContext: pickOptionalBoolean(
|
||||
payload,
|
||||
'limitContext',
|
||||
savedRequest.limitContext,
|
||||
providerChangedFromSaved ? undefined : savedRequest.limitContext,
|
||||
'limitContext'
|
||||
),
|
||||
skipPermissions: pickOptionalBoolean(
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import {
|
|||
import {
|
||||
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
|
||||
createMemberWorkSyncFeature,
|
||||
hasWorkSyncActiveRuntime,
|
||||
type MemberWorkSyncFeatureFacade,
|
||||
registerMemberWorkSyncIpc,
|
||||
removeMemberWorkSyncIpc,
|
||||
|
|
@ -57,6 +58,7 @@ import {
|
|||
type RuntimeProviderManagementFeatureFacade,
|
||||
} from '@features/runtime-provider-management/main';
|
||||
import { createWorkspaceTrustCoordinator } from '@features/workspace-trust/main';
|
||||
import { ensureOpenCodeBridgeRuntimeBinaryEnv } from '@main/services/runtime/openCodeBridgeRuntimeEnv';
|
||||
import { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService';
|
||||
import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy';
|
||||
import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService';
|
||||
|
|
@ -137,6 +139,7 @@ import {
|
|||
SkillsMutationService,
|
||||
SkillsWatcherService,
|
||||
} from './services/extensions';
|
||||
import { applyAgentTeamsIdentityEnv } from './services/identity/AgentTeamsIdentityStore';
|
||||
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||
import { clearAutoResumeService } from './services/team/AutoResumeService';
|
||||
|
|
@ -224,7 +227,7 @@ import {
|
|||
TeamTaskStallSnapshotSource,
|
||||
TeamTranscriptSourceLocator,
|
||||
UpdaterService,
|
||||
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath,
|
||||
resolveVerifiedOpenCodeRuntimeBinaryPath,
|
||||
} from './services';
|
||||
|
||||
import type { FileChangeEvent } from '@main/types';
|
||||
|
|
@ -340,6 +343,23 @@ function describeMemberWorkSyncReviewPickupEscalationReason(reason: string): str
|
|||
return 'The current review request is still waiting for explicit review pickup.';
|
||||
}
|
||||
|
||||
async function resolveOpenCodeRuntimeBinaryForBridgeEnv(): Promise<string | null> {
|
||||
const resolvedBinaryPath = await resolveVerifiedOpenCodeRuntimeBinaryPath();
|
||||
if (resolvedBinaryPath) return resolvedBinaryPath;
|
||||
|
||||
try {
|
||||
const status = await openCodeRuntimeInstallerService?.getStatus();
|
||||
return status?.installed === true && status.binaryPath ? status.binaryPath : null;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[OpenCode] Runtime installer status unavailable while resolving bridge binary: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function createOpenCodeRuntimeAdapterRegistry(
|
||||
reportProgress: (phase: string, message: string) => void = () => undefined
|
||||
): Promise<TeamRuntimeAdapterRegistry> {
|
||||
|
|
@ -358,6 +378,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
|||
|
||||
reportProgress('runtime-environment', 'Preparing runtime environment...');
|
||||
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
|
||||
applyAgentTeamsIdentityEnv(bridgeEnv);
|
||||
bridgeEnv.CLAUDE_TEAM_APP_INSTANCE_ID = openCodeManagedHostInstanceId;
|
||||
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
|
||||
const useHttpMcpBridge = isOpenCodeMcpHttpBridgeEnabled(bridgeEnv);
|
||||
|
|
@ -409,18 +430,15 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
|||
copyOpenCodeLocalMcpLaunchEnv(targetEnv, bridgeEnv);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath();
|
||||
if (appManagedOpenCodeBinary && !bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH) {
|
||||
bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH = appManagedOpenCodeBinary;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[OpenCode] Runtime adapter bundled OpenCode binary unresolved: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
const ensureOpenCodeRuntimeBinaryEnv = async (targetEnv: NodeJS.ProcessEnv): Promise<void> => {
|
||||
await ensureOpenCodeBridgeRuntimeBinaryEnv({
|
||||
targetEnv,
|
||||
bridgeEnv,
|
||||
resolveVerifiedOpenCodeRuntimeBinaryPath: resolveOpenCodeRuntimeBinaryForBridgeEnv,
|
||||
onWarning: (message) => logger.warn(message),
|
||||
});
|
||||
};
|
||||
await ensureOpenCodeRuntimeBinaryEnv(bridgeEnv);
|
||||
try {
|
||||
reportProgress('runtime-work-sync', 'Preparing runtime work sync hooks...');
|
||||
const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({
|
||||
|
|
@ -442,6 +460,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
|||
reportProgress('runtime-mcp-http', 'Starting Agent Teams MCP server...');
|
||||
const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted();
|
||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
|
||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL_HASH = mcpHttpServer.urlHash;
|
||||
reportProgress('runtime-mcp-http-ready', 'Agent Teams MCP server is ready...');
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
|
|
@ -463,17 +482,22 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
|||
reportProgress('runtime-bridge', 'Preparing OpenCode bridge...');
|
||||
const resolveBridgeCommandEnv = async (): Promise<NodeJS.ProcessEnv> => {
|
||||
const nextEnv = { ...bridgeEnv };
|
||||
await ensureOpenCodeRuntimeBinaryEnv(nextEnv);
|
||||
if (!useHttpMcpBridge) {
|
||||
return nextEnv;
|
||||
}
|
||||
try {
|
||||
const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted();
|
||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
|
||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL_HASH = mcpHttpServer.urlHash;
|
||||
nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
|
||||
nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL_HASH = mcpHttpServer.urlHash;
|
||||
await ensureOpenCodeLocalMcpLaunchEnv(nextEnv);
|
||||
} catch (error) {
|
||||
delete bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL;
|
||||
delete bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL_HASH;
|
||||
delete nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL;
|
||||
delete nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL_HASH;
|
||||
await ensureOpenCodeLocalMcpLaunchEnv(nextEnv);
|
||||
logger.warn(
|
||||
`[OpenCode] Runtime adapter bridge MCP HTTP server refresh failed: ${
|
||||
|
|
@ -895,6 +919,23 @@ function isShutdownStarted(): boolean {
|
|||
return shutdownComplete || shutdownPromise !== null;
|
||||
}
|
||||
|
||||
function hasActiveTeamRuntimesForWindowClose(): boolean {
|
||||
if (!servicesReady || !teamProvisioningService) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return teamProvisioningService.hasActiveTeamRuntimes();
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to check active team runtimes before closing last window: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleStartupTask(action: () => void, delayMs: number): void {
|
||||
const timer = setTimeout(() => {
|
||||
startupTimers.delete(timer);
|
||||
|
|
@ -1740,27 +1781,55 @@ async function initializeServices(): Promise<void> {
|
|||
logger: createLogger('Feature:RecentProjects'),
|
||||
});
|
||||
runtimeProviderManagementFeature = createRuntimeProviderManagementFeature();
|
||||
const memberWorkSyncLogger = createLogger('Feature:MemberWorkSync');
|
||||
const hasMemberWorkSyncRuntimeActivity = async (teamName: string): Promise<boolean> => {
|
||||
try {
|
||||
const snapshot = await teamProvisioningService.getTeamAgentRuntimeSnapshot(teamName);
|
||||
return hasWorkSyncActiveRuntime(snapshot);
|
||||
} catch (error) {
|
||||
memberWorkSyncLogger.warn('member work sync runtime activity check failed', {
|
||||
teamName,
|
||||
error: String(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const isTeamActiveForMemberWorkSync = async (teamName: string): Promise<boolean> => {
|
||||
if (
|
||||
teamProvisioningService.isTeamAlive(teamName) ||
|
||||
teamProvisioningService.hasProvisioningRun(teamName)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return hasMemberWorkSyncRuntimeActivity(teamName);
|
||||
};
|
||||
const canDispatchMemberWorkSyncNudges = async (teamName: string): Promise<boolean> => {
|
||||
if (teamProvisioningService.isTeamAlive(teamName)) {
|
||||
return true;
|
||||
}
|
||||
return hasMemberWorkSyncRuntimeActivity(teamName);
|
||||
};
|
||||
const listMemberWorkSyncLifecycleActiveTeamNames = async (): Promise<string[]> => {
|
||||
const activeTeamNames: string[] = [];
|
||||
for (const team of await teamDataService.listTeams()) {
|
||||
if (team.deletedAt) {
|
||||
continue;
|
||||
}
|
||||
if (await isTeamActiveForMemberWorkSync(team.teamName)) {
|
||||
activeTeamNames.push(team.teamName);
|
||||
}
|
||||
}
|
||||
return activeTeamNames;
|
||||
};
|
||||
memberWorkSyncFeature = createMemberWorkSyncFeature({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
configReader: new TeamConfigReader(),
|
||||
taskReader: new TeamTaskReader(),
|
||||
kanbanManager: new TeamKanbanManager(),
|
||||
membersMetaStore: new TeamMembersMetaStore(),
|
||||
isTeamActive: (teamName) =>
|
||||
teamProvisioningService.isTeamAlive(teamName) ||
|
||||
teamProvisioningService.hasProvisioningRun(teamName),
|
||||
canDispatchNudges: (teamName) => teamProvisioningService.isTeamAlive(teamName),
|
||||
listLifecycleActiveTeamNames: async () => {
|
||||
const teams = await teamDataService.listTeams();
|
||||
return teams
|
||||
.filter(
|
||||
(team) =>
|
||||
!team.deletedAt &&
|
||||
(teamProvisioningService.isTeamAlive(team.teamName) ||
|
||||
teamProvisioningService.hasProvisioningRun(team.teamName))
|
||||
)
|
||||
.map((team) => team.teamName);
|
||||
},
|
||||
isTeamActive: isTeamActiveForMemberWorkSync,
|
||||
canDispatchNudges: canDispatchMemberWorkSyncNudges,
|
||||
listLifecycleActiveTeamNames: listMemberWorkSyncLifecycleActiveTeamNames,
|
||||
extraBusySignals: [
|
||||
{
|
||||
isBusy: (input) => teamProvisioningService.getOpenCodeMemberDeliveryBusyStatus(input),
|
||||
|
|
@ -1944,7 +2013,7 @@ async function initializeServices(): Promise<void> {
|
|||
});
|
||||
},
|
||||
},
|
||||
logger: createLogger('Feature:MemberWorkSync'),
|
||||
logger: memberWorkSyncLogger,
|
||||
});
|
||||
teamProvisioningService.setRuntimeTurnSettledHookSettingsProvider((input) =>
|
||||
memberWorkSyncFeature
|
||||
|
|
@ -2746,10 +2815,16 @@ void app.whenReady().then(async () => {
|
|||
* All windows closed handler.
|
||||
*/
|
||||
app.on('window-all-closed', () => {
|
||||
const hasActiveTeamRuntimes = hasActiveTeamRuntimesForWindowClose();
|
||||
const shouldQuitWhenAllWindowsClosed =
|
||||
process.platform !== 'darwin' || !configManager.getConfig().general.showDockIcon;
|
||||
hasActiveTeamRuntimes ||
|
||||
process.platform !== 'darwin' ||
|
||||
!configManager.getConfig().general.showDockIcon;
|
||||
|
||||
if (shouldQuitWhenAllWindowsClosed) {
|
||||
if (hasActiveTeamRuntimes) {
|
||||
logger.info('Quitting after last window closed because active team runtimes are running');
|
||||
}
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { CodexBinaryResolver } from '../services/infrastructure/codexAppServer';
|
||||
import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver';
|
||||
|
||||
import type { CliInstallerService } from '../services';
|
||||
|
|
@ -35,7 +36,23 @@ let service: CliInstallerService;
|
|||
let statusInFlight: Promise<CliInstallationStatus> | null = null;
|
||||
const providerStatusInFlight = new Map<CliProviderId, Promise<CliProviderStatus | null>>();
|
||||
let cachedStatus: { value: CliInstallationStatus; at: number } | null = null;
|
||||
let statusCacheGeneration = 0;
|
||||
const STATUS_CACHE_TTL_MS = 5_000;
|
||||
const FRONTEND_MULTIMODEL_PROVIDER_IDS = new Set<CliProviderId>(['anthropic', 'codex', 'opencode']);
|
||||
|
||||
function isFrontendMultimodelProviderId(providerId: CliProviderId): boolean {
|
||||
return FRONTEND_MULTIMODEL_PROVIDER_IDS.has(providerId);
|
||||
}
|
||||
|
||||
function getCachedStatusAuthenticatedProvider(
|
||||
providers: CliProviderStatus[]
|
||||
): CliProviderStatus | null {
|
||||
return (
|
||||
providers.find(
|
||||
(provider) => isFrontendMultimodelProviderId(provider.providerId) && provider.authenticated
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes CLI installer handlers with the service instance.
|
||||
|
|
@ -89,14 +106,19 @@ async function handleGetStatus(
|
|||
|
||||
if (!statusInFlight) {
|
||||
const startedAt = Date.now();
|
||||
statusInFlight = service
|
||||
const generation = statusCacheGeneration;
|
||||
const request = service
|
||||
.getStatus()
|
||||
.then((status) => {
|
||||
cachedStatus = { value: status, at: Date.now() };
|
||||
if (generation === statusCacheGeneration) {
|
||||
cachedStatus = { value: status, at: Date.now() };
|
||||
}
|
||||
return status;
|
||||
})
|
||||
.catch((err) => {
|
||||
cachedStatus = null;
|
||||
if (generation === statusCacheGeneration) {
|
||||
cachedStatus = null;
|
||||
}
|
||||
throw err;
|
||||
})
|
||||
.finally(() => {
|
||||
|
|
@ -104,8 +126,11 @@ async function handleGetStatus(
|
|||
if (ms >= 2000) {
|
||||
logger.warn(`cliInstaller:getStatus slow ms=${ms}`);
|
||||
}
|
||||
statusInFlight = null;
|
||||
if (statusInFlight === request) {
|
||||
statusInFlight = null;
|
||||
}
|
||||
});
|
||||
statusInFlight = request;
|
||||
}
|
||||
|
||||
const status = await statusInFlight;
|
||||
|
|
@ -122,6 +147,13 @@ function patchCachedProviderStatus(providerStatus: CliProviderStatus | null): vo
|
|||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
cachedStatus.value.flavor === 'agent_teams_orchestrator' &&
|
||||
!isFrontendMultimodelProviderId(providerStatus.providerId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasProvider = cachedStatus.value.providers.some(
|
||||
(provider) => provider.providerId === providerStatus.providerId
|
||||
);
|
||||
|
|
@ -130,13 +162,19 @@ function patchCachedProviderStatus(providerStatus: CliProviderStatus | null): vo
|
|||
provider.providerId === providerStatus.providerId ? providerStatus : provider
|
||||
)
|
||||
: [...cachedStatus.value.providers, providerStatus];
|
||||
const authenticatedProvider = nextProviders.find((provider) => provider.authenticated) ?? null;
|
||||
const authenticatedProvider =
|
||||
cachedStatus.value.flavor === 'agent_teams_orchestrator'
|
||||
? getCachedStatusAuthenticatedProvider(nextProviders)
|
||||
: (nextProviders.find((provider) => provider.authenticated) ?? null);
|
||||
|
||||
cachedStatus = {
|
||||
value: {
|
||||
...cachedStatus.value,
|
||||
providers: nextProviders,
|
||||
authLoggedIn: nextProviders.some((provider) => provider.authenticated),
|
||||
authLoggedIn:
|
||||
cachedStatus.value.flavor === 'agent_teams_orchestrator'
|
||||
? authenticatedProvider !== null
|
||||
: nextProviders.some((provider) => provider.authenticated),
|
||||
authMethod: authenticatedProvider?.authMethod ?? null,
|
||||
},
|
||||
at: Date.now(),
|
||||
|
|
@ -154,14 +192,19 @@ async function handleGetProviderStatus(
|
|||
return { success: true, data: status };
|
||||
}
|
||||
|
||||
const generation = statusCacheGeneration;
|
||||
const request = service
|
||||
.getProviderStatus(providerId)
|
||||
.then((status) => {
|
||||
patchCachedProviderStatus(status);
|
||||
if (generation === statusCacheGeneration) {
|
||||
patchCachedProviderStatus(status);
|
||||
}
|
||||
return status;
|
||||
})
|
||||
.finally(() => {
|
||||
providerStatusInFlight.delete(providerId);
|
||||
if (providerStatusInFlight.get(providerId) === request) {
|
||||
providerStatusInFlight.delete(providerId);
|
||||
}
|
||||
});
|
||||
|
||||
providerStatusInFlight.set(providerId, request);
|
||||
|
|
@ -201,9 +244,12 @@ async function handleVerifyProviderModels(
|
|||
}
|
||||
|
||||
function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> {
|
||||
statusCacheGeneration += 1;
|
||||
cachedStatus = null;
|
||||
statusInFlight = null;
|
||||
providerStatusInFlight.clear();
|
||||
ClaudeBinaryResolver.clearCache();
|
||||
CodexBinaryResolver.clearCache();
|
||||
service.invalidateStatusCache();
|
||||
return { success: true, data: undefined };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,14 +45,12 @@ import {
|
|||
registerHttpServerHandlers,
|
||||
removeHttpServerHandlers,
|
||||
} from './httpServer';
|
||||
import { registerNotificationHandlers, removeNotificationHandlers } from './notifications';
|
||||
import {
|
||||
initializeOpenCodeRuntimeHandlers,
|
||||
registerOpenCodeRuntimeHandlers,
|
||||
removeOpenCodeRuntimeHandlers,
|
||||
} from './openCodeRuntime';
|
||||
|
||||
const logger = createLogger('IPC:handlers');
|
||||
import { registerNotificationHandlers, removeNotificationHandlers } from './notifications';
|
||||
import {
|
||||
initializeProjectHandlers,
|
||||
registerProjectHandlers,
|
||||
|
|
@ -79,6 +77,7 @@ import {
|
|||
removeSubagentHandlers,
|
||||
} from './subagents';
|
||||
import { initializeTeamHandlers, registerTeamHandlers, removeTeamHandlers } from './teams';
|
||||
import { registerTelemetryHandlers, removeTelemetryHandlers } from './telemetry';
|
||||
import {
|
||||
initializeTerminalHandlers,
|
||||
registerTerminalHandlers,
|
||||
|
|
@ -133,6 +132,8 @@ import type { CrossTeamService } from '../services/team/CrossTeamService';
|
|||
import type { LaunchIoGovernor } from '../services/team/LaunchIoGovernor';
|
||||
import type { TeamBackupService } from '../services/team/TeamBackupService';
|
||||
|
||||
const logger = createLogger('IPC:handlers');
|
||||
|
||||
/**
|
||||
* Initializes IPC handlers with service registry.
|
||||
*/
|
||||
|
|
@ -268,6 +269,7 @@ export function initializeIpcHandlers(
|
|||
registerWindowHandlers(ipcMain);
|
||||
registerRendererLogHandlers(ipcMain);
|
||||
registerScheduleHandlers(ipcMain);
|
||||
registerTelemetryHandlers(ipcMain);
|
||||
if (cliInstaller) {
|
||||
registerCliInstallerHandlers(ipcMain);
|
||||
}
|
||||
|
|
@ -315,6 +317,7 @@ export function removeIpcHandlers(): void {
|
|||
removeWindowHandlers(ipcMain);
|
||||
removeRendererLogHandlers(ipcMain);
|
||||
removeScheduleHandlers(ipcMain);
|
||||
removeTelemetryHandlers(ipcMain);
|
||||
removeCliInstallerHandlers(ipcMain);
|
||||
removeOpenCodeRuntimeHandlers(ipcMain);
|
||||
removeCodexRuntimeHandlers(ipcMain);
|
||||
|
|
|
|||
|
|
@ -1467,7 +1467,42 @@ function parseOptionalProviderBackendId(
|
|||
|
||||
return {
|
||||
valid: false,
|
||||
error: 'providerBackendId must be one of auto, adapter, api, cli-sdk, or codex-native',
|
||||
error:
|
||||
'providerBackendId must be valid for the selected provider (auto, adapter, api, cli-sdk, codex-native, or opencode-cli)',
|
||||
};
|
||||
}
|
||||
|
||||
function parseOptionalLaunchProviderBackendId(
|
||||
value: unknown,
|
||||
providerId?: TeamProviderId
|
||||
): { valid: true; value: TeamProviderBackendId | undefined } | { valid: false; error: string } {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return { valid: true, value: undefined };
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return { valid: false, error: 'providerBackendId must be a string' };
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return { valid: true, value: undefined };
|
||||
}
|
||||
if (trimmed.length > 64) {
|
||||
return { valid: false, error: 'providerBackendId too long (max 64)' };
|
||||
}
|
||||
|
||||
const migratedBackendId = migrateProviderBackendId(providerId, trimmed);
|
||||
if (migratedBackendId) {
|
||||
return { valid: true, value: migratedBackendId };
|
||||
}
|
||||
|
||||
if (isTeamProviderBackendId(trimmed)) {
|
||||
return { valid: true, value: undefined };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
'providerBackendId must be valid for the selected provider (auto, adapter, api, cli-sdk, codex-native, or opencode-cli)',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1775,15 +1810,11 @@ async function validateProvisioningRequest(
|
|||
if (!Array.isArray(payload.members)) {
|
||||
return { valid: false, error: 'members must be an array' };
|
||||
}
|
||||
const explicitProviderId =
|
||||
payload.providerId === 'codex'
|
||||
? 'codex'
|
||||
: payload.providerId === 'gemini'
|
||||
? 'gemini'
|
||||
: payload.providerId === 'anthropic'
|
||||
? 'anthropic'
|
||||
: undefined;
|
||||
const providerId = explicitProviderId ?? 'anthropic';
|
||||
const providerValidation = parseOptionalTeamProviderId(payload.providerId);
|
||||
if (!providerValidation.valid) {
|
||||
return { valid: false, error: providerValidation.error };
|
||||
}
|
||||
const providerId = providerValidation.value ?? 'anthropic';
|
||||
|
||||
const seenNames = new Set<string>();
|
||||
const members: TeamCreateRequest['members'] = [];
|
||||
|
|
@ -1821,7 +1852,7 @@ async function validateProvisioningRequest(
|
|||
}
|
||||
const providerBackendValidation = parseOptionalProviderBackendId(
|
||||
(member as { providerBackendId?: unknown }).providerBackendId,
|
||||
providerValidation.value
|
||||
providerValidation.value ?? providerId
|
||||
);
|
||||
if (!providerBackendValidation.valid) {
|
||||
return { valid: false, error: providerBackendValidation.error };
|
||||
|
|
@ -1867,7 +1898,7 @@ async function validateProvisioningRequest(
|
|||
if (payload.prompt !== undefined && typeof payload.prompt !== 'string') {
|
||||
return { valid: false, error: 'prompt must be a string' };
|
||||
}
|
||||
const providerBackendValidation = parseOptionalProviderBackendId(
|
||||
const providerBackendValidation = parseOptionalLaunchProviderBackendId(
|
||||
payload.providerBackendId,
|
||||
providerId
|
||||
);
|
||||
|
|
@ -2076,16 +2107,13 @@ async function handleLaunchTeam(
|
|||
if (payload.model !== undefined && typeof payload.model !== 'string') {
|
||||
return { success: false, error: 'model must be a string' };
|
||||
}
|
||||
const explicitProviderId =
|
||||
payload.providerId === 'codex'
|
||||
? 'codex'
|
||||
: payload.providerId === 'gemini'
|
||||
? 'gemini'
|
||||
: payload.providerId === 'anthropic'
|
||||
? 'anthropic'
|
||||
: undefined;
|
||||
const providerValidation = parseOptionalTeamProviderId(payload.providerId);
|
||||
if (!providerValidation.valid) {
|
||||
return { success: false, error: providerValidation.error };
|
||||
}
|
||||
const explicitProviderId = providerValidation.value;
|
||||
const providerId = explicitProviderId ?? 'anthropic';
|
||||
const providerBackendValidation = parseOptionalProviderBackendId(
|
||||
const providerBackendValidation = parseOptionalLaunchProviderBackendId(
|
||||
payload.providerBackendId,
|
||||
providerId
|
||||
);
|
||||
|
|
@ -2113,20 +2141,44 @@ async function handleLaunchTeam(
|
|||
return { success: false, error: `Missing saved request for draft team: ${tn}` };
|
||||
}
|
||||
|
||||
const savedProviderId = savedRequest.providerId ?? 'anthropic';
|
||||
const resolvedProviderId = explicitProviderId ?? savedRequest.providerId ?? providerId;
|
||||
const providerChangedFromSaved =
|
||||
explicitProviderId != null && explicitProviderId !== savedProviderId;
|
||||
const effortValidation = parseOptionalTeamEffort(
|
||||
Object.hasOwn(payload, 'effort') ? payload.effort : savedRequest.effort,
|
||||
Object.hasOwn(payload, 'effort')
|
||||
? payload.effort
|
||||
: providerChangedFromSaved
|
||||
? undefined
|
||||
: savedRequest.effort,
|
||||
resolvedProviderId
|
||||
);
|
||||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
}
|
||||
const fastModeValidation = parseOptionalTeamFastMode(
|
||||
Object.hasOwn(payload, 'fastMode') ? payload.fastMode : savedRequest.fastMode
|
||||
Object.hasOwn(payload, 'fastMode')
|
||||
? payload.fastMode
|
||||
: providerChangedFromSaved
|
||||
? undefined
|
||||
: savedRequest.fastMode
|
||||
);
|
||||
if (!fastModeValidation.valid) {
|
||||
return { success: false, error: fastModeValidation.error };
|
||||
}
|
||||
const draftModel = Object.hasOwn(payload, 'model')
|
||||
? typeof payload.model === 'string'
|
||||
? payload.model.trim() || undefined
|
||||
: undefined
|
||||
: providerChangedFromSaved
|
||||
? undefined
|
||||
: savedRequest.model;
|
||||
const draftLimitContext =
|
||||
typeof payload.limitContext === 'boolean'
|
||||
? payload.limitContext
|
||||
: providerChangedFromSaved
|
||||
? undefined
|
||||
: savedRequest.limitContext;
|
||||
|
||||
const createRequest: TeamCreateRequest = {
|
||||
teamName: tn,
|
||||
|
|
@ -2143,14 +2195,10 @@ async function handleLaunchTeam(
|
|||
resolvedProviderId,
|
||||
providerBackendValidation.value ?? savedRequest.providerBackendId
|
||||
),
|
||||
model:
|
||||
typeof payload.model === 'string' ? payload.model.trim() || undefined : savedRequest.model,
|
||||
model: draftModel,
|
||||
effort: effortValidation.value,
|
||||
fastMode: fastModeValidation.value,
|
||||
limitContext:
|
||||
typeof payload.limitContext === 'boolean'
|
||||
? payload.limitContext
|
||||
: savedRequest.limitContext,
|
||||
limitContext: draftLimitContext,
|
||||
skipPermissions:
|
||||
typeof payload.skipPermissions === 'boolean'
|
||||
? payload.skipPermissions
|
||||
|
|
@ -2186,39 +2234,64 @@ async function handleLaunchTeam(
|
|||
}
|
||||
|
||||
const persistedMeta = await teamMetaStore.getMeta(tn).catch(() => null);
|
||||
const launchProviderId = explicitProviderId ?? persistedMeta?.providerId ?? providerId;
|
||||
const rawLaunchProviderBackendId =
|
||||
payload.providerBackendId ??
|
||||
persistedMeta?.providerBackendId ??
|
||||
persistedMeta?.launchIdentity?.providerBackendId ??
|
||||
undefined;
|
||||
const launchProviderBackendValidation = parseOptionalProviderBackendId(
|
||||
const persistedLaunchProviderId =
|
||||
persistedMeta?.launchIdentity?.providerId ?? persistedMeta?.providerId ?? 'anthropic';
|
||||
const launchProviderId =
|
||||
explicitProviderId ??
|
||||
persistedMeta?.launchIdentity?.providerId ??
|
||||
persistedMeta?.providerId ??
|
||||
providerId;
|
||||
const providerChangedFromPersisted =
|
||||
explicitProviderId != null && explicitProviderId !== persistedLaunchProviderId;
|
||||
const rawLaunchProviderBackendId = Object.hasOwn(payload, 'providerBackendId')
|
||||
? payload.providerBackendId
|
||||
: providerChangedFromPersisted
|
||||
? undefined
|
||||
: persistedMeta?.launchIdentity
|
||||
? migrateProviderBackendId(
|
||||
persistedMeta.launchIdentity.providerId,
|
||||
persistedMeta.launchIdentity.providerBackendId ?? persistedMeta.providerBackendId
|
||||
)
|
||||
: (persistedMeta?.providerBackendId ?? undefined);
|
||||
const launchProviderBackendValidation = parseOptionalLaunchProviderBackendId(
|
||||
rawLaunchProviderBackendId,
|
||||
launchProviderId
|
||||
);
|
||||
if (!launchProviderBackendValidation.valid) {
|
||||
return { success: false, error: launchProviderBackendValidation.error };
|
||||
}
|
||||
const rawLaunchEffort = Object.hasOwn(payload, 'effort')
|
||||
? payload.effort
|
||||
: (persistedMeta?.effort ?? persistedMeta?.launchIdentity?.selectedEffort ?? undefined);
|
||||
const persistedLaunchEffort = providerChangedFromPersisted
|
||||
? undefined
|
||||
: (persistedMeta?.launchIdentity?.selectedEffort ?? persistedMeta?.effort ?? undefined);
|
||||
const rawLaunchEffort = Object.hasOwn(payload, 'effort') ? payload.effort : persistedLaunchEffort;
|
||||
const effortValidation = parseOptionalTeamEffort(rawLaunchEffort, launchProviderId);
|
||||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
}
|
||||
const persistedLaunchFastMode = providerChangedFromPersisted
|
||||
? undefined
|
||||
: (persistedMeta?.launchIdentity?.selectedFastMode ?? persistedMeta?.fastMode ?? undefined);
|
||||
const rawLaunchFastMode = Object.hasOwn(payload, 'fastMode')
|
||||
? payload.fastMode
|
||||
: (persistedMeta?.fastMode ?? persistedMeta?.launchIdentity?.selectedFastMode ?? undefined);
|
||||
: persistedLaunchFastMode;
|
||||
const fastModeValidation = parseOptionalTeamFastMode(rawLaunchFastMode);
|
||||
if (!fastModeValidation.valid) {
|
||||
return { success: false, error: fastModeValidation.error };
|
||||
}
|
||||
const rawLaunchModel =
|
||||
typeof payload.model === 'string' && payload.model.trim().length > 0
|
||||
const persistedLaunchModel = providerChangedFromPersisted
|
||||
? undefined
|
||||
: (persistedMeta?.launchIdentity?.selectedModel ?? persistedMeta?.model ?? undefined);
|
||||
const rawLaunchModel = Object.hasOwn(payload, 'model')
|
||||
? typeof payload.model === 'string' && payload.model.trim().length > 0
|
||||
? payload.model.trim()
|
||||
: (persistedMeta?.model ?? persistedMeta?.launchIdentity?.selectedModel ?? undefined);
|
||||
: undefined
|
||||
: persistedLaunchModel;
|
||||
const launchLimitContext =
|
||||
typeof payload.limitContext === 'boolean' ? payload.limitContext : persistedMeta?.limitContext;
|
||||
typeof payload.limitContext === 'boolean'
|
||||
? payload.limitContext
|
||||
: providerChangedFromPersisted
|
||||
? undefined
|
||||
: persistedMeta?.limitContext;
|
||||
|
||||
return wrapTeamHandler('launch', async () => {
|
||||
addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! });
|
||||
|
|
@ -3573,13 +3646,14 @@ async function handleCreateConfig(
|
|||
if (payload.prompt !== undefined && typeof payload.prompt !== 'string') {
|
||||
return { success: false, error: 'prompt must be a string' };
|
||||
}
|
||||
const providerValidation = parseOptionalTeamProviderId(payload.providerId);
|
||||
if (!providerValidation.valid) {
|
||||
return { success: false, error: providerValidation.error };
|
||||
const teamProviderValidation = parseOptionalTeamProviderId(payload.providerId);
|
||||
if (!teamProviderValidation.valid) {
|
||||
return { success: false, error: teamProviderValidation.error };
|
||||
}
|
||||
const providerBackendValidation = parseOptionalProviderBackendId(
|
||||
const effectiveTeamProviderId = teamProviderValidation.value ?? 'anthropic';
|
||||
const providerBackendValidation = parseOptionalLaunchProviderBackendId(
|
||||
payload.providerBackendId,
|
||||
providerValidation.value
|
||||
effectiveTeamProviderId
|
||||
);
|
||||
if (!providerBackendValidation.valid) {
|
||||
return { success: false, error: providerBackendValidation.error };
|
||||
|
|
@ -3587,7 +3661,7 @@ async function handleCreateConfig(
|
|||
if (payload.model !== undefined && typeof payload.model !== 'string') {
|
||||
return { success: false, error: 'model must be a string' };
|
||||
}
|
||||
const effortValidation = parseOptionalTeamEffort(payload.effort, providerValidation.value);
|
||||
const effortValidation = parseOptionalTeamEffort(payload.effort, effectiveTeamProviderId);
|
||||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
}
|
||||
|
|
@ -3668,9 +3742,10 @@ async function handleCreateConfig(
|
|||
if (!providerValidation.valid) {
|
||||
return { success: false, error: providerValidation.error };
|
||||
}
|
||||
const effectiveMemberProviderId = providerValidation.value ?? effectiveTeamProviderId;
|
||||
const providerBackendValidation = parseOptionalProviderBackendId(
|
||||
(member as { providerBackendId?: unknown }).providerBackendId,
|
||||
providerValidation.value
|
||||
effectiveMemberProviderId
|
||||
);
|
||||
if (!providerBackendValidation.valid) {
|
||||
return { success: false, error: providerBackendValidation.error };
|
||||
|
|
@ -3681,7 +3756,7 @@ async function handleCreateConfig(
|
|||
}
|
||||
const effortValidation = parseOptionalMemberEffort(
|
||||
(member as { effort?: unknown }).effort,
|
||||
providerValidation.value
|
||||
effectiveMemberProviderId
|
||||
);
|
||||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
|
|
@ -3714,7 +3789,7 @@ async function handleCreateConfig(
|
|||
members,
|
||||
cwd: typeof payload.cwd === 'string' ? payload.cwd.trim() || undefined : undefined,
|
||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||
providerId: providerValidation.value,
|
||||
providerId: teamProviderValidation.value,
|
||||
providerBackendId: providerBackendValidation.value,
|
||||
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||
effort: effortValidation.value,
|
||||
|
|
|
|||
24
src/main/ipc/telemetry.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* Telemetry IPC handlers.
|
||||
*
|
||||
* Only exposes Sentry-safe anonymous context. Raw app identity stays in main.
|
||||
*/
|
||||
|
||||
import { getCurrentSentryTelemetryContext } from '@main/sentry';
|
||||
import {
|
||||
TELEMETRY_GET_SENTRY_CONTEXT,
|
||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
|
||||
} from '@preload/constants/ipcChannels';
|
||||
|
||||
import type { SentryTelemetryContext } from '@main/sentry';
|
||||
import type { IpcMain } from 'electron';
|
||||
|
||||
export function registerTelemetryHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.handle(TELEMETRY_GET_SENTRY_CONTEXT, async (): Promise<SentryTelemetryContext | null> => {
|
||||
return getCurrentSentryTelemetryContext();
|
||||
});
|
||||
}
|
||||
|
||||
export function removeTelemetryHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.removeHandler(TELEMETRY_GET_SENTRY_CONTEXT);
|
||||
}
|
||||
|
|
@ -11,21 +11,85 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
type AgentTeamsIdentitySource,
|
||||
ensureAgentTeamsClientIdentity,
|
||||
getSentryAnonymousUserId,
|
||||
} from '@main/services/identity/AgentTeamsIdentityStore';
|
||||
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||
import {
|
||||
filterSafeSentryIntegrations,
|
||||
isValidDsn,
|
||||
redactSentryEvent,
|
||||
SENTRY_ENVIRONMENT,
|
||||
SENTRY_RELEASE,
|
||||
TRACES_SAMPLE_RATE,
|
||||
} from '@shared/utils/sentryConfig';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Telemetry gate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Module-level flag that `beforeSend` checks.
|
||||
// Updated by `syncTelemetryFlag()` once ConfigManager is ready.
|
||||
// Defaults to `true` so early crash reports are NOT silently dropped;
|
||||
// if the user later turns telemetry off, the flag flips to `false`.
|
||||
let telemetryAllowed = true;
|
||||
const CONFIG_FILENAME = 'agent-teams-config.json';
|
||||
const LEGACY_CONFIG_FILENAMES = [
|
||||
'claude-devtools-config.json',
|
||||
'claude-code-context-config.json',
|
||||
] as const;
|
||||
|
||||
export interface SentryTelemetryContext {
|
||||
userId: string;
|
||||
tags: Record<string, string>;
|
||||
}
|
||||
|
||||
function readTelemetryFlagFromConfig(configPath: string): boolean | null {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as unknown;
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const general = (parsed as { general?: unknown }).general;
|
||||
if (typeof general !== 'object' || general === null || Array.isArray(general)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const telemetryEnabled = (general as { telemetryEnabled?: unknown }).telemetryEnabled;
|
||||
return typeof telemetryEnabled === 'boolean' ? telemetryEnabled : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readPersistedTelemetryEnabled(basePath = getClaudeBasePath()): boolean {
|
||||
const currentPath = path.join(basePath, CONFIG_FILENAME);
|
||||
if (fs.existsSync(currentPath)) {
|
||||
return readTelemetryFlagFromConfig(currentPath) ?? true;
|
||||
}
|
||||
|
||||
const legacyPaths = LEGACY_CONFIG_FILENAMES.map((filename) => path.join(basePath, filename));
|
||||
const readableLegacyPath =
|
||||
legacyPaths.find((candidatePath) => readTelemetryFlagFromConfig(candidatePath) !== null) ??
|
||||
legacyPaths.find((candidatePath) => fs.existsSync(candidatePath));
|
||||
|
||||
return readableLegacyPath ? (readTelemetryFlagFromConfig(readableLegacyPath) ?? true) : true;
|
||||
}
|
||||
|
||||
// Module-level flag that `beforeSend` checks. Read persisted config before init
|
||||
// so telemetry-disabled users do not start Sentry sessions on app startup.
|
||||
let telemetryAllowed = readPersistedTelemetryEnabled();
|
||||
let telemetryIdentitySyncToken = 0;
|
||||
|
||||
export function getSafeSentryTelemetryTags(
|
||||
identitySource: AgentTeamsIdentitySource
|
||||
): Record<string, string> {
|
||||
return {
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
app_version: SENTRY_RELEASE ?? 'unknown',
|
||||
identity_source: identitySource,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Call once ConfigManager is initialised to sync the opt-in flag.
|
||||
|
|
@ -33,45 +97,172 @@ let telemetryAllowed = true;
|
|||
*/
|
||||
export function syncTelemetryFlag(enabled: boolean): void {
|
||||
telemetryAllowed = enabled;
|
||||
if (!enabled) {
|
||||
telemetryIdentitySyncToken++;
|
||||
shutdownSentry();
|
||||
return;
|
||||
}
|
||||
|
||||
initializeSentryIfAllowed();
|
||||
void syncTelemetryIdentity();
|
||||
}
|
||||
|
||||
export function filterSentryEventForTelemetry(event: unknown): unknown {
|
||||
return telemetryAllowed ? redactSentryEvent(event) : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy Sentry import — safe in non-Electron environments
|
||||
// Lazy Sentry import - safe in non-Electron environments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let Sentry: any = null;
|
||||
interface SentryMainApi {
|
||||
init?: (options: SentryInitOptions) => void;
|
||||
setUser?: (user: { id: string } | null) => void;
|
||||
setTags?: (tags: Record<string, string>) => void;
|
||||
close?: (timeout?: number) => PromiseLike<boolean> | boolean;
|
||||
addBreadcrumb?: (breadcrumb: {
|
||||
category: string;
|
||||
message: string;
|
||||
data?: Record<string, unknown>;
|
||||
level: 'info';
|
||||
}) => void;
|
||||
startSpan?: <T>(context: { name: string; op: string }, callback: () => T) => T;
|
||||
}
|
||||
|
||||
interface SentryInitOptions {
|
||||
dsn: string;
|
||||
release: string | undefined;
|
||||
environment: string;
|
||||
tracesSampleRate: number;
|
||||
sendDefaultPii: false;
|
||||
beforeSend: (event: unknown) => unknown;
|
||||
beforeSendTransaction: (event: unknown) => unknown;
|
||||
integrations: <TIntegration extends { name?: string }>(
|
||||
integrations: TIntegration[]
|
||||
) => TIntegration[];
|
||||
}
|
||||
|
||||
let Sentry: SentryMainApi | null = null;
|
||||
let initialized = false;
|
||||
|
||||
const dsn = process.env.SENTRY_DSN;
|
||||
export function setMainSentryApiForTesting(sentryApi: SentryMainApi): void {
|
||||
if (process.env.NODE_ENV !== 'test') return;
|
||||
Sentry = sentryApi;
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
function clearSentryUser(): void {
|
||||
if (!initialized || !Sentry) return;
|
||||
Sentry.setUser?.(null);
|
||||
}
|
||||
|
||||
function shutdownSentry(): void {
|
||||
const sentry = Sentry;
|
||||
if (initialized && sentry) {
|
||||
sentry.setUser?.(null);
|
||||
try {
|
||||
void Promise.resolve(sentry.close?.(2000)).catch(() => undefined);
|
||||
} catch {
|
||||
// Best effort only. The telemetry gate still blocks later events.
|
||||
}
|
||||
}
|
||||
|
||||
initialized = false;
|
||||
Sentry = null;
|
||||
}
|
||||
|
||||
export async function getCurrentSentryTelemetryContext(): Promise<SentryTelemetryContext | null> {
|
||||
if (!telemetryAllowed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const identity = await ensureAgentTeamsClientIdentity();
|
||||
if (!telemetryAllowed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: getSentryAnonymousUserId(identity.clientId),
|
||||
tags: getSafeSentryTelemetryTags(identity.source),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncTelemetryIdentity(): Promise<void> {
|
||||
const syncToken = ++telemetryIdentitySyncToken;
|
||||
if (!initialized || !Sentry) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!telemetryAllowed) {
|
||||
clearSentryUser();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const context = await getCurrentSentryTelemetryContext();
|
||||
if (syncToken !== telemetryIdentitySyncToken || !telemetryAllowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context) {
|
||||
clearSentryUser();
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.setUser?.({ id: context.userId });
|
||||
Sentry.setTags?.(context.tags);
|
||||
} catch {
|
||||
if (syncToken === telemetryIdentitySyncToken) {
|
||||
clearSentryUser();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initializeSentryIfAllowed(): void {
|
||||
if (initialized || !telemetryAllowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dsn = process.env.SENTRY_DSN;
|
||||
if (!isValidDsn(dsn)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isValidDsn(dsn)) {
|
||||
try {
|
||||
// Dynamic import would be cleaner but top-level await is not available
|
||||
// in all contexts. require() is synchronous and works in both Electron
|
||||
// and Node.js — it simply throws in standalone mode where the electron
|
||||
// and Node.js - it simply throws in standalone mode where the electron
|
||||
// module is not resolvable.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
Sentry = require('@sentry/electron/main');
|
||||
Sentry.init({
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy optional Electron runtime dependency.
|
||||
Sentry = require('@sentry/electron/main') as SentryMainApi;
|
||||
Sentry.init?.({
|
||||
dsn,
|
||||
release: SENTRY_RELEASE,
|
||||
environment: SENTRY_ENVIRONMENT,
|
||||
tracesSampleRate: TRACES_SAMPLE_RATE,
|
||||
sendDefaultPii: false,
|
||||
|
||||
beforeSend(event: unknown) {
|
||||
return telemetryAllowed ? event : null;
|
||||
},
|
||||
beforeSend: filterSentryEventForTelemetry,
|
||||
beforeSendTransaction: filterSentryEventForTelemetry,
|
||||
integrations: filterSafeSentryIntegrations,
|
||||
});
|
||||
initialized = true;
|
||||
void syncTelemetryIdentity();
|
||||
} catch {
|
||||
// @sentry/electron/main requires Electron runtime — not available in
|
||||
Sentry = null;
|
||||
initialized = false;
|
||||
// @sentry/electron/main requires Electron runtime - not available in
|
||||
// standalone (pure Node.js) mode. All exported helpers are no-ops when
|
||||
// initialized is false, so this is safe to swallow.
|
||||
}
|
||||
}
|
||||
|
||||
initializeSentryIfAllowed();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public helpers (no-op when Sentry is not configured)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -80,10 +271,10 @@ if (isValidDsn(dsn)) {
|
|||
export function addMainBreadcrumb(
|
||||
category: string,
|
||||
message: string,
|
||||
data?: Record<string, unknown>
|
||||
_data?: Record<string, unknown>
|
||||
): void {
|
||||
if (!initialized) return;
|
||||
Sentry.addBreadcrumb({ category, message, data, level: 'info' });
|
||||
Sentry?.addBreadcrumb?.({ category, message, level: 'info' });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -92,5 +283,6 @@ export function addMainBreadcrumb(
|
|||
*/
|
||||
export function startMainSpan<T>(name: string, op: string, fn: () => T): T {
|
||||
if (!initialized) return fn();
|
||||
if (!Sentry?.startSpan) return fn();
|
||||
return Sentry.startSpan({ name, op }, fn);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,15 @@
|
|||
|
||||
import { countContentTokens } from '@main/utils/tokenizer';
|
||||
|
||||
import type { AIChunk, EnhancedAIChunk, SemanticStep } from '@main/types';
|
||||
import type { AIChunk, ContentBlock, EnhancedAIChunk, SemanticStep } from '@main/types';
|
||||
|
||||
function normalizeAssistantContent(content: ContentBlock[] | string): ContentBlock[] {
|
||||
if (typeof content === 'string') {
|
||||
return content ? [{ type: 'text', text: content }] : [];
|
||||
}
|
||||
|
||||
return Array.isArray(content) ? content : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract semantic steps from AI chunk responses.
|
||||
|
|
@ -33,7 +41,7 @@ export function extractSemanticStepsFromAIChunk(chunk: AIChunk | EnhancedAIChunk
|
|||
for (const msg of chunk.responses) {
|
||||
if (msg.type === 'assistant') {
|
||||
// Extract from content blocks
|
||||
const content = Array.isArray(msg.content) ? msg.content : [];
|
||||
const content = normalizeAssistantContent(msg.content);
|
||||
|
||||
for (const block of content) {
|
||||
if (block.type === 'thinking' && block.thinking) {
|
||||
|
|
|
|||
218
src/main/services/identity/AgentTeamsIdentityStore.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import { atomicWriteAsync } from '@main/utils/atomicWrite';
|
||||
import {
|
||||
getAppDataPath,
|
||||
getAutoDetectedClaudeBasePath,
|
||||
getClaudeBasePath,
|
||||
getHomeDir,
|
||||
} from '@main/utils/pathDecoder';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export const AGENT_TEAMS_IDENTITY_STORE_PATH_ENV = 'AGENT_TEAMS_IDENTITY_STORE_PATH';
|
||||
export const AGENT_TEAMS_IDENTITY_SCHEMA_VERSION = 1;
|
||||
const SENTRY_ANONYMOUS_USER_PREFIX = 'agent-teams-sentry-v1:';
|
||||
const IDENTITY_DIR_MODE = 0o700;
|
||||
const IDENTITY_FILE_MODE = 0o600;
|
||||
|
||||
type ParsedJson = null | boolean | number | string | ParsedJson[] | { [key: string]: ParsedJson };
|
||||
|
||||
export type AgentTeamsIdentitySource = 'app-data' | 'legacy-global-config' | 'created';
|
||||
|
||||
export interface AgentTeamsIdentityStoreV1 {
|
||||
schemaVersion: typeof AGENT_TEAMS_IDENTITY_SCHEMA_VERSION;
|
||||
clientId: string;
|
||||
session?: Record<string, unknown>;
|
||||
capabilities?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AgentTeamsClientIdentity {
|
||||
clientId: string;
|
||||
source: AgentTeamsIdentitySource;
|
||||
storePath: string;
|
||||
}
|
||||
|
||||
interface LegacyAgentTeamsState {
|
||||
clientId: string;
|
||||
session?: Record<string, unknown>;
|
||||
capabilities?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function isValidAgentTeamsClientId(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function pickObjectField(
|
||||
record: Record<string, unknown>,
|
||||
key: string
|
||||
): Record<string, unknown> | undefined {
|
||||
const value = record[key];
|
||||
return isRecord(value) ? value : undefined;
|
||||
}
|
||||
|
||||
export function getAgentTeamsIdentityStorePath(): string {
|
||||
return path.join(getAppDataPath(), 'identity', 'agent-teams-client.json');
|
||||
}
|
||||
|
||||
export function applyAgentTeamsIdentityEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const existing = env[AGENT_TEAMS_IDENTITY_STORE_PATH_ENV];
|
||||
if (!isNonEmptyString(existing)) {
|
||||
env[AGENT_TEAMS_IDENTITY_STORE_PATH_ENV] = getAgentTeamsIdentityStorePath();
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export function getSentryAnonymousUserId(clientId: string): string {
|
||||
if (!isValidAgentTeamsClientId(clientId)) {
|
||||
throw new Error('Invalid Agent Teams clientId');
|
||||
}
|
||||
return createHash('sha256').update(`${SENTRY_ANONYMOUS_USER_PREFIX}${clientId}`).digest('hex');
|
||||
}
|
||||
|
||||
function getLegacyGlobalConfigPath(): string {
|
||||
const claudeBasePath = getClaudeBasePath();
|
||||
return claudeBasePath !== getAutoDetectedClaudeBasePath()
|
||||
? path.join(claudeBasePath, '.claude.json')
|
||||
: path.join(getHomeDir(), '.claude.json');
|
||||
}
|
||||
|
||||
async function readJsonFile(filePath: string): Promise<ParsedJson | undefined> {
|
||||
try {
|
||||
const raw = await fs.promises.readFile(filePath, 'utf8');
|
||||
return JSON.parse(raw) as ParsedJson;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.promises.stat(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code !== 'ENOENT';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStoreRecord(value: unknown): AgentTeamsIdentityStoreV1 | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.schemaVersion !== AGENT_TEAMS_IDENTITY_SCHEMA_VERSION) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isValidAgentTeamsClientId(value.clientId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createdAt = isNonEmptyString(value.createdAt) ? value.createdAt : new Date().toISOString();
|
||||
const updatedAt = isNonEmptyString(value.updatedAt) ? value.updatedAt : createdAt;
|
||||
return {
|
||||
schemaVersion: AGENT_TEAMS_IDENTITY_SCHEMA_VERSION,
|
||||
clientId: value.clientId,
|
||||
session: pickObjectField(value, 'session'),
|
||||
capabilities: pickObjectField(value, 'capabilities'),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLegacyAgentTeams(value: unknown): LegacyAgentTeamsState | null {
|
||||
if (!isRecord(value) || !isValidAgentTeamsClientId(value.clientId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
clientId: value.clientId,
|
||||
session: pickObjectField(value, 'session'),
|
||||
capabilities: pickObjectField(value, 'capabilities'),
|
||||
};
|
||||
}
|
||||
|
||||
async function readLegacyAgentTeamsState(): Promise<LegacyAgentTeamsState | null> {
|
||||
const legacyConfig = await readJsonFile(getLegacyGlobalConfigPath());
|
||||
if (!isRecord(legacyConfig)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeLegacyAgentTeams(legacyConfig.agentTeams);
|
||||
}
|
||||
|
||||
function buildStoreRecord(
|
||||
source: LegacyAgentTeamsState | null,
|
||||
options?: { existingCreatedAt?: string }
|
||||
): AgentTeamsIdentityStoreV1 {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
schemaVersion: AGENT_TEAMS_IDENTITY_SCHEMA_VERSION,
|
||||
clientId: source?.clientId ?? randomUUID(),
|
||||
session: source?.session,
|
||||
capabilities: source?.capabilities,
|
||||
createdAt: options?.existingCreatedAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeStoreRecord(
|
||||
storePath: string,
|
||||
record: AgentTeamsIdentityStoreV1
|
||||
): Promise<void> {
|
||||
const dir = path.dirname(storePath);
|
||||
await fs.promises.mkdir(dir, { recursive: true, mode: IDENTITY_DIR_MODE });
|
||||
await fs.promises.chmod(dir, IDENTITY_DIR_MODE).catch(() => undefined);
|
||||
await atomicWriteAsync(storePath, `${JSON.stringify(record, null, 2)}\n`);
|
||||
await fs.promises.chmod(storePath, IDENTITY_FILE_MODE).catch(() => undefined);
|
||||
}
|
||||
|
||||
async function loadAppDataIdentity(storePath: string): Promise<AgentTeamsIdentityStoreV1 | null> {
|
||||
return normalizeStoreRecord(await readJsonFile(storePath));
|
||||
}
|
||||
|
||||
export async function ensureAgentTeamsClientIdentity(options?: {
|
||||
storePath?: string;
|
||||
}): Promise<AgentTeamsClientIdentity> {
|
||||
const storePath = options?.storePath ?? getAgentTeamsIdentityStorePath();
|
||||
const existing = await loadAppDataIdentity(storePath);
|
||||
if (existing) {
|
||||
return {
|
||||
clientId: existing.clientId,
|
||||
source: 'app-data',
|
||||
storePath,
|
||||
};
|
||||
}
|
||||
|
||||
const legacy = !(await pathExists(storePath)) ? await readLegacyAgentTeamsState() : null;
|
||||
const record = buildStoreRecord(legacy);
|
||||
await writeStoreRecord(storePath, record);
|
||||
|
||||
return {
|
||||
clientId: record.clientId,
|
||||
source: legacy ? 'legacy-global-config' : 'created',
|
||||
storePath,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readAgentTeamsIdentityStore(options?: {
|
||||
storePath?: string;
|
||||
}): Promise<AgentTeamsIdentityStoreV1 | null> {
|
||||
return loadAppDataIdentity(options?.storePath ?? getAgentTeamsIdentityStorePath());
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
|
|||
import {
|
||||
getCachedShellEnv,
|
||||
getShellPreferredHome,
|
||||
resolveInteractiveShellEnv,
|
||||
resolveInteractiveShellEnvBestEffort,
|
||||
} from '@main/utils/shellEnv';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
|
@ -68,6 +68,47 @@ const GCS_BASE =
|
|||
'https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases';
|
||||
|
||||
const CLI_INSTALLER_PROGRESS_CHANNEL = 'cliInstaller:progress';
|
||||
const FRONTEND_MULTIMODEL_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'opencode'];
|
||||
const FRONTEND_MULTIMODEL_PROVIDER_ID_SET = new Set<CliProviderId>(
|
||||
FRONTEND_MULTIMODEL_PROVIDER_IDS
|
||||
);
|
||||
|
||||
function getProviderDisplayName(providerId: CliProviderId): string {
|
||||
switch (providerId) {
|
||||
case 'anthropic':
|
||||
return 'Anthropic';
|
||||
case 'codex':
|
||||
return 'Codex';
|
||||
case 'gemini':
|
||||
return 'Gemini';
|
||||
case 'opencode':
|
||||
return 'OpenCode (200+ models)';
|
||||
}
|
||||
}
|
||||
|
||||
function isFrontendMultimodelProviderId(providerId: CliProviderId): boolean {
|
||||
return FRONTEND_MULTIMODEL_PROVIDER_ID_SET.has(providerId);
|
||||
}
|
||||
|
||||
function getFrontendAuthenticatedProvider(
|
||||
providers: CliProviderStatus[]
|
||||
): CliProviderStatus | null {
|
||||
return (
|
||||
providers.find(
|
||||
(provider) => isFrontendMultimodelProviderId(provider.providerId) && provider.authenticated
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function hasFrontendAuthenticatedProvider(providers: CliProviderStatus[]): boolean {
|
||||
return providers.some(
|
||||
(provider) => isFrontendMultimodelProviderId(provider.providerId) && provider.authenticated
|
||||
);
|
||||
}
|
||||
|
||||
function filterFrontendMultimodelProviders(providers: CliProviderStatus[]): CliProviderStatus[] {
|
||||
return providers.filter((provider) => isFrontendMultimodelProviderId(provider.providerId));
|
||||
}
|
||||
|
||||
/** Timeout for `claude --version` (ms) */
|
||||
const VERSION_TIMEOUT_MS = 10_000;
|
||||
|
|
@ -152,6 +193,7 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat
|
|||
providers: status.providers.map((provider) => ({
|
||||
...provider,
|
||||
modelVerificationState: provider.modelVerificationState ?? 'idle',
|
||||
modelCatalogRefreshState: provider.modelCatalogRefreshState ?? 'idle',
|
||||
modelCatalog: provider.modelCatalog ? structuredClone(provider.modelCatalog) : null,
|
||||
detailMessage: provider.detailMessage ?? null,
|
||||
modelAvailability: provider.modelAvailability?.map((item) => ({ ...item })) ?? [],
|
||||
|
|
@ -176,6 +218,26 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat
|
|||
};
|
||||
}
|
||||
|
||||
function mergeProviderStatusCatalogCache(
|
||||
incomingProvider: CliProviderStatus,
|
||||
currentProvider: CliProviderStatus
|
||||
): CliProviderStatus {
|
||||
const modelCatalog = incomingProvider.modelCatalog ?? currentProvider.modelCatalog ?? null;
|
||||
const incomingRefreshState = incomingProvider.modelCatalogRefreshState ?? null;
|
||||
|
||||
return {
|
||||
...incomingProvider,
|
||||
models: incomingProvider.models.length > 0 ? incomingProvider.models : currentProvider.models,
|
||||
modelCatalog,
|
||||
modelCatalogRefreshState:
|
||||
modelCatalog && incomingRefreshState !== 'error'
|
||||
? 'ready'
|
||||
: (incomingRefreshState ?? currentProvider.modelCatalogRefreshState ?? 'idle'),
|
||||
runtimeCapabilities:
|
||||
incomingProvider.runtimeCapabilities ?? currentProvider.runtimeCapabilities ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneProviderModelAvailability(
|
||||
modelAvailability: CliProviderModelAvailability[] | undefined
|
||||
): CliProviderModelAvailability[] {
|
||||
|
|
@ -485,27 +547,9 @@ export class CliInstallerService {
|
|||
const ui = getCliFlavorUiOptions(flavor);
|
||||
const providers =
|
||||
flavor === 'agent_teams_orchestrator'
|
||||
? (
|
||||
[
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
},
|
||||
{
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
},
|
||||
{
|
||||
providerId: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
},
|
||||
{
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode (200+ models)',
|
||||
},
|
||||
] as const
|
||||
).map((provider) => ({
|
||||
...provider,
|
||||
? FRONTEND_MULTIMODEL_PROVIDER_IDS.map((providerId) => ({
|
||||
providerId,
|
||||
displayName: getProviderDisplayName(providerId),
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
|
|
@ -514,7 +558,7 @@ export class CliInstallerService {
|
|||
statusMessage: 'Checking...',
|
||||
models: [],
|
||||
modelAvailability: [],
|
||||
canLoginFromUi: provider.providerId !== 'opencode',
|
||||
canLoginFromUi: providerId !== 'opencode',
|
||||
capabilities: {
|
||||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
|
|
@ -652,6 +696,13 @@ export class CliInstallerService {
|
|||
}
|
||||
|
||||
private updateLatestProviderStatus(providerStatus: CliProviderStatus): void {
|
||||
if (
|
||||
this.latestStatusSnapshot?.flavor === 'agent_teams_orchestrator' &&
|
||||
!isFrontendMultimodelProviderId(providerStatus.providerId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
providerStatus.modelVerificationState !== 'verifying' &&
|
||||
(providerStatus.modelAvailability?.length ?? 0) <= 0
|
||||
|
|
@ -668,15 +719,17 @@ export class CliInstallerService {
|
|||
);
|
||||
const nextProviders = hasProvider
|
||||
? this.latestStatusSnapshot.providers.map((provider) =>
|
||||
provider.providerId === providerStatus.providerId ? providerStatus : provider
|
||||
provider.providerId === providerStatus.providerId
|
||||
? mergeProviderStatusCatalogCache(providerStatus, provider)
|
||||
: provider
|
||||
)
|
||||
: [...this.latestStatusSnapshot.providers, providerStatus];
|
||||
const authenticatedProvider = nextProviders.find((provider) => provider.authenticated) ?? null;
|
||||
const authenticatedProvider = getFrontendAuthenticatedProvider(nextProviders);
|
||||
|
||||
this.latestStatusSnapshot = {
|
||||
...this.latestStatusSnapshot,
|
||||
providers: nextProviders,
|
||||
authLoggedIn: nextProviders.some((provider) => provider.authenticated),
|
||||
authLoggedIn: hasFrontendAuthenticatedProvider(nextProviders),
|
||||
authMethod: authenticatedProvider?.authMethod ?? null,
|
||||
};
|
||||
}
|
||||
|
|
@ -724,7 +777,7 @@ export class CliInstallerService {
|
|||
}
|
||||
|
||||
async getProviderStatus(providerId: CliProviderId): Promise<CliProviderStatus | null> {
|
||||
await resolveInteractiveShellEnv();
|
||||
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
||||
|
||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||
if (!binaryPath) {
|
||||
|
|
@ -744,14 +797,20 @@ export class CliInstallerService {
|
|||
|
||||
const providerStatus = await this.multimodelBridgeService.getProviderStatus(
|
||||
binaryPath,
|
||||
providerId
|
||||
providerId,
|
||||
(hydratedProviderStatus) => {
|
||||
this.updateLatestProviderStatus(hydratedProviderStatus);
|
||||
if (this.latestStatusSnapshot) {
|
||||
this.publishStatusSnapshot(this.latestStatusSnapshot);
|
||||
}
|
||||
}
|
||||
);
|
||||
this.updateLatestProviderStatus(providerStatus);
|
||||
return providerStatus;
|
||||
}
|
||||
|
||||
async verifyProviderModels(providerId: CliProviderId): Promise<CliProviderStatus | null> {
|
||||
await resolveInteractiveShellEnv();
|
||||
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
||||
|
||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||
if (!binaryPath) {
|
||||
|
|
@ -813,7 +872,7 @@ export class CliInstallerService {
|
|||
diag: CliInstallerStatusRunDiag
|
||||
): Promise<void> {
|
||||
resetGatherDiag(diag);
|
||||
await resolveInteractiveShellEnv();
|
||||
await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env });
|
||||
|
||||
const r = ref.current;
|
||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||
|
|
@ -952,6 +1011,7 @@ export class CliInstallerService {
|
|||
authMethod: null,
|
||||
verificationState: 'error',
|
||||
modelVerificationState: 'idle',
|
||||
modelCatalogRefreshState: 'error',
|
||||
statusMessage: message,
|
||||
models: [],
|
||||
modelAvailability: [],
|
||||
|
|
@ -979,17 +1039,18 @@ export class CliInstallerService {
|
|||
const providers = await this.multimodelBridgeService.getProviderStatuses(
|
||||
binaryPath,
|
||||
(providersSnapshot) => {
|
||||
result.providers = providersSnapshot;
|
||||
result.authLoggedIn = providersSnapshot.some((provider) => provider.authenticated);
|
||||
const frontendProviders = filterFrontendMultimodelProviders(providersSnapshot);
|
||||
result.providers = frontendProviders;
|
||||
result.authLoggedIn = hasFrontendAuthenticatedProvider(frontendProviders);
|
||||
result.authMethod =
|
||||
providersSnapshot.find((provider) => provider.authenticated)?.authMethod ?? null;
|
||||
getFrontendAuthenticatedProvider(frontendProviders)?.authMethod ?? null;
|
||||
this.publishStatusSnapshot(result);
|
||||
}
|
||||
);
|
||||
result.providers = providers;
|
||||
result.authLoggedIn = providers.some((provider) => provider.authenticated);
|
||||
result.authMethod =
|
||||
providers.find((provider) => provider.authenticated)?.authMethod ?? null;
|
||||
const frontendProviders = filterFrontendMultimodelProviders(providers);
|
||||
result.providers = frontendProviders;
|
||||
result.authLoggedIn = hasFrontendAuthenticatedProvider(frontendProviders);
|
||||
result.authMethod = getFrontendAuthenticatedProvider(frontendProviders)?.authMethod ?? null;
|
||||
result.authStatusChecking = false;
|
||||
this.publishStatusSnapshot(result);
|
||||
} catch (error) {
|
||||
|
|
|
|||