merge: dev into main

This commit is contained in:
777genius 2026-05-19 02:51:18 +03:00
commit 467883605b
290 changed files with 36730 additions and 3890 deletions

View file

@ -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
View file

@ -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
View 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"
]
}
}
}

View file

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View file

@ -0,0 +1,12 @@
# Member Runtime Telemetry Reference
Design reference for participant-card runtime telemetry:
![Variant B reference](assets/team-member-runtime-telemetry-variant-b-reference.png)
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.

File diff suppressed because it is too large Load diff

View 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
View file

@ -3,6 +3,8 @@ node_modules
.output
.dist
.env
--host/
product-docs/.vitepress/dist/
# Large video files
public/video/*.mp4

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

File diff suppressed because it is too large Load diff

View file

@ -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"

View 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>

View file

@ -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"

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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>

View file

@ -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),

View file

@ -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"
/>

View 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>

View file

@ -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" />

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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" />

View file

@ -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>&gt; {{ devBranchNote }}</span>
<span>&gt; 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>

View file

@ -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;
}

View file

@ -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

View file

@ -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
};

View file

@ -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);
});
}

View file

@ -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;

View file

@ -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": "التوثيق"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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": "दस्तावेज़"

View file

@ -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": "ドキュメント"

View file

@ -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"

View file

@ -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": "Документация"

View file

@ -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": "文档"

View file

@ -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",

View file

@ -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",

View file

@ -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();

View file

@ -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: {

View file

@ -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);
}
}
}

View file

@ -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",

View file

@ -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({

View file

@ -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

View file

@ -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,

View file

@ -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"
}

View 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}`);
}

View file

@ -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,
});

View file

@ -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 = '';

View file

@ -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)

View file

@ -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);

View file

@ -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,

View file

@ -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,
};
}

View file

@ -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,
};
}

View file

@ -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,
};
}

View file

@ -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,
};
}

View 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}
/>
),
};
}

View file

@ -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,
};
}

View file

@ -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

View file

@ -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,

View file

@ -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>

View file

@ -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>
);
};

View file

@ -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>

View file

@ -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();

View file

@ -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' };
}

View file

@ -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);
});
});

View file

@ -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);
}

View file

@ -8,3 +8,7 @@ export {
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
createMemberWorkSyncFeature,
} from './composition/createMemberWorkSyncFeature';
export {
hasWorkSyncActiveRuntime,
isRuntimeEntryActiveForWorkSync,
} from './composition/memberWorkSyncTeamActivity';

View file

@ -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();

View file

@ -9,7 +9,7 @@ export {
isTmuxRuntimeReadyForCurrentPlatform,
killTmuxPaneForCurrentPlatform,
killTmuxPaneForCurrentPlatformSync,
listRuntimeProcessesForCurrentTmuxPlatform,
listRuntimeProcessTableForCurrentPlatform,
listTmuxPanePidsForCurrentPlatform,
listTmuxPaneRuntimeInfoForCurrentPlatform,
sendKeysToTmuxPaneForCurrentPlatform,

View file

@ -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',
]);

View file

@ -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(

View file

@ -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();
}
});

View file

@ -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 };
}

View file

@ -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);

View file

@ -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
View 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);
}

View file

@ -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);
}

View file

@ -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) {

View 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());
}

View file

@ -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) {

Some files were not shown because too many files have changed in this diff Show more