diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index 51dc144d4..f072df0c6 100644 --- a/.github/workflows/auto-tag-on-release-pr-merge.yml +++ b/.github/workflows/auto-tag-on-release-pr-merge.yml @@ -1,5 +1,26 @@ name: Auto-tag on Release PR Merge +# Three release lanes share this one workflow — adding a lane is one more branch +# prefix, never a forked copy: +# +# version-bump/ → tag v → dispatch release.yml (desktop app) +# relay-release/ → tag relay-v → dispatch docker.yml (relay image) +# mobile-release/ → tag mobile-v → (manual sprout_ref for buzz-releases build — see below) +# +# Both the desktop and relay lanes dispatch their build workflow rather than +# relying on the consumer's `on.push.tags` trigger: auto-tag pushes the tag +# with the default GITHUB_TOKEN, which GitHub's recursion guard blocks from +# firing any `on: push` trigger. So release.yml and docker.yml both have a +# `push.tags` trigger that is dead for auto-pushed tags — the dispatch is the +# real path. Each dispatch passes the bare version and the tag ref so the +# consumer builds the tagged commit (github.ref on a dispatch is `main`). +# +# The mobile lane is push-only by infosec necessity: OSS `block/buzz` CI must +# not trigger CI in the private `buzz-releases` repo, so auto-dispatch across +# that boundary is deliberately disallowed. The mobile-v* tag is consumed +# manually instead — a human feeds it as the `sprout_ref` input to the +# `buzz-releases` Buildkite pipeline, which builds and ships mobile. + on: pull_request: types: [closed] @@ -13,7 +34,9 @@ jobs: auto-tag: if: > github.event.pull_request.merged == true && - startsWith(github.event.pull_request.head.ref, 'version-bump/') && + (startsWith(github.event.pull_request.head.ref, 'version-bump/') || + startsWith(github.event.pull_request.head.ref, 'relay-release/') || + startsWith(github.event.pull_request.head.ref, 'mobile-release/')) && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: @@ -22,42 +45,78 @@ jobs: ref: ${{ github.event.pull_request.merge_commit_sha }} fetch-depth: 0 - - name: Extract version from branch name + - name: Resolve lane and version from branch name env: BRANCH: ${{ github.event.pull_request.head.ref }} run: | - VERSION="${BRANCH#version-bump/}" + # Lane is decided by branch prefix: the tag prefix and whether a + # downstream workflow_dispatch is needed both follow from it. + case "$BRANCH" in + version-bump/*) + VERSION="${BRANCH#version-bump/}" + TAG_PREFIX="v" + DISPATCH="release" ;; + relay-release/*) + VERSION="${BRANCH#relay-release/}" + TAG_PREFIX="relay-v" + DISPATCH="docker" ;; + mobile-release/*) + VERSION="${BRANCH#mobile-release/}" + TAG_PREFIX="mobile-v" + DISPATCH="" ;; + *) + echo "::error::Unhandled branch prefix: '$BRANCH'" + exit 1 ;; + esac if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then echo "::error::Invalid version in branch name: '$VERSION'" exit 1 fi echo "version=$VERSION" >> "$GITHUB_ENV" - echo "Tagging v${VERSION}" + echo "tag=${TAG_PREFIX}${VERSION}" >> "$GITHUB_ENV" + echo "dispatch=$DISPATCH" >> "$GITHUB_ENV" + echo "Tagging ${TAG_PREFIX}${VERSION}" - name: Create and push tag env: - VERSION: ${{ env.version }} + TAG: ${{ env.tag }} run: | - EXISTING_SHA="$(git ls-remote --tags origin "refs/tags/v$VERSION" | awk '{print $1}')" + EXISTING_SHA="$(git ls-remote --tags origin "refs/tags/$TAG" | awk '{print $1}')" if [ -n "$EXISTING_SHA" ]; then if [ "$EXISTING_SHA" = "$GITHUB_SHA" ]; then - echo "Tag v$VERSION already exists at $GITHUB_SHA — skipping tag creation" + echo "Tag $TAG already exists at $GITHUB_SHA — skipping tag creation" exit 0 else - echo "::error::Tag v$VERSION already exists at $EXISTING_SHA (expected $GITHUB_SHA)" + echo "::error::Tag $TAG already exists at $EXISTING_SHA (expected $GITHUB_SHA)" exit 1 fi fi git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git config user.name "github-actions[bot]" - git tag "v$VERSION" - git push origin "v$VERSION" + git tag "$TAG" + git push origin "$TAG" - name: Trigger release build + # The desktop and relay lanes dispatch their build workflow because the + # consumer's on:push:tags trigger is dead for auto-pushed tags (default + # GITHUB_TOKEN, recursion guard — see header comment). Mobile has no + # consumer yet, so dispatch="" skips this step entirely. + if: ${{ env.dispatch != '' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DISPATCH: ${{ env.dispatch }} VERSION: ${{ env.version }} + TAG: ${{ env.tag }} run: | - gh workflow run release.yml \ + # Both workflows take the bare version (for the build) and the tag ref + # (so the dispatch builds the tagged commit, not main). + case "$DISPATCH" in + release) WORKFLOW="release.yml" ;; + docker) WORKFLOW="docker.yml" ;; + *) + echo "::error::Unhandled dispatch target: '$DISPATCH'" + exit 1 ;; + esac + gh workflow run "$WORKFLOW" \ -f version="$VERSION" \ - -f ref="v$VERSION" + -f ref="$TAG" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 8f7c461fa..6321629c6 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -8,16 +8,42 @@ name: Docker image # This avoids QEMU emulation (~10× slower for Rust) at zero cost on free # GitHub-hosted runners. # +# Versioning: the relay is versioned independently of the desktop app via +# its own `relay-v*` tags (see `just release-relay`). Desktop `v*` tags and +# agent `sprig-v*` tags do NOT publish this image — only `relay-v*` does, so +# the relay image version tracks crates/buzz-relay/Cargo.toml, never desktop. +# # Triggers: -# - push to main → :main + :sha-<7> -# - push tags v*.*.* → :latest + :{version} + :{major}.{minor} + :{major} -# - pull_request → build only (no push), cache stays warm -# - workflow_dispatch → manual canary +# - push to main → :main + :sha-<7> +# - push tags relay-v*.*.* → :{version} + :{major}.{minor} + :{major} +# (+ :latest for stable, NOT for prereleases) +# - pull_request → build only (no push), cache stays warm +# - workflow_dispatch → manual canary (no inputs), or relay-tag rescue +# dispatch with version+ref inputs (see below) +# +# Why workflow_dispatch carries version/ref inputs: +# auto-tag-on-release-pr-merge.yml pushes relay-v* with the default +# GITHUB_TOKEN, which GitHub deliberately does NOT let fire on:push triggers +# (recursion guard). So the push:tags trigger above never runs for releases. +# auto-tag instead dispatches this workflow with the bare version + tag ref, +# the same rescue release.yml already uses for the desktop lane. On dispatch +# github.ref is `main`, so the tag ref is plumbed through explicitly: checkout +# pins to inputs.ref, and the semver tags take inputs.version via `value=`. +# On the rescue path inputs.version is already bare (e.g. 0.3.0), so the +# match=^relay-v(.*)$ regex simply no-ops (it warns, leaving the value +# intact) and the bare version flows straight to the semver parser. On a +# real push event value= is empty and the match strips relay-v from the ref. +# Inputless canary dispatch (version="", ref=main) renders no semver tag. +# +# The :latest tag tracks the latest STABLE relay release: metadata-action's +# `flavor.latest=auto` (its default) emits :latest only for non-prerelease +# semver, so relay-v0.3.0-rc.1 publishes :0.3.0-rc.1 without moving :latest, +# and main pushes (no semver tag) never produce :latest. on: push: branches: [main] - tags: ["v[0-9]*"] + tags: ["relay-v[0-9]*"] pull_request: paths: - "Dockerfile" @@ -33,6 +59,14 @@ on: - "pnpm-workspace.yaml" - "patches/**" workflow_dispatch: + inputs: + version: + description: "Semver version e.g. 0.3.0 (no relay-v prefix) — for relay-tag rescue dispatch" + required: false + ref: + description: "Tag/branch/SHA to build, e.g. relay-v0.3.0" + required: false + default: main # One image build per ref; cancel superseded PR builds, but never cancel # tag/main builds (publishing must not be aborted mid-flight). @@ -77,6 +111,9 @@ jobs: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: + # On workflow_dispatch (relay-tag rescue) build the tagged commit, + # not main. Empty string = default ref for push/PR events. + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} persist-credentials: false - name: Set up Docker Buildx @@ -103,15 +140,26 @@ jobs: uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: images: ${{ env.IMAGE_NAME }} - # Tag matrix — every main commit gets sha-<7>, releases get full - # semver family. Pull requests get nothing (push: false below). + # Tag matrix — every main commit gets sha-<7>, relay releases get the + # full semver family. The semver entries carry match=^relay-v(.*)$ + # because metadata-action does NOT strip a `relay-v` prefix on its + # own — it only strips refs/tags/, then runs the raw ref through + # semver.valid(), which rejects "relay-v0.3.0". The match capture + # group feeds the bare version to the semver parser. value= supplies + # the version on the auto-tag rescue dispatch (github.ref is `main` + # there, not the tag): it is already bare, so match no-ops (warns, + # value intact) and the bare version validates as-is. On push value= + # is empty, so the ref drives it and match strips relay-v — push + # behavior is unchanged. Pull requests get nothing (push: false + # below). :latest is intentionally absent — flavor.latest defaults to + # `auto`, which adds :latest for stable semver tags only (not + # prereleases, not main pushes). tags: | - type=ref,event=branch - type=sha,prefix=sha-,format=short - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} + type=sha,prefix=sha-,format=short,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} + type=semver,pattern={{version}},match=^relay-v(.*)$,value=${{ inputs.version }} + type=semver,pattern={{major}}.{{minor}},match=^relay-v(.*)$,value=${{ inputs.version }} + type=semver,pattern={{major}},match=^relay-v(.*)$,value=${{ inputs.version }} labels: | org.opencontainers.image.title=Buzz org.opencontainers.image.description=WebSocket relay server for the Buzz communications platform @@ -186,13 +234,17 @@ jobs: uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: images: ${{ env.IMAGE_NAME }} + # Must mirror the build job's tag matrix exactly — the merge job + # re-derives tags to stamp them onto the multi-arch manifest. See + # the build job's `meta` step for why match=^relay-v(.*)$, why + # value=${{ inputs.version }} carries the rescue-dispatch version, + # and why :latest is left to flavor.latest=auto. tags: | - type=ref,event=branch - type=sha,prefix=sha-,format=short - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} + type=sha,prefix=sha-,format=short,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} + type=semver,pattern={{version}},match=^relay-v(.*)$,value=${{ inputs.version }} + type=semver,pattern={{major}}.{{minor}},match=^relay-v(.*)$,value=${{ inputs.version }} + type=semver,pattern={{major}},match=^relay-v(.*)$,value=${{ inputs.version }} - name: Create and push manifest list id: manifest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 74e108d7c..32c793ce9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -173,7 +173,7 @@ jobs: key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }} - name: Build unsigned Tauri app - run: cd desktop && pnpm tauri build --verbose --no-sign --config src-tauri/tauri.release.conf.json + run: cd desktop && pnpm tauri build --verbose --no-sign --features mesh-llm --config src-tauri/tauri.release.conf.json env: BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }} BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json @@ -513,6 +513,8 @@ jobs: - name: Build Linux Tauri app run: cd desktop && pnpm tauri build --verbose --ci --bundles deb,appimage --config src-tauri/tauri.release.conf.json env: + BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }} + BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json CMAKE_POLICY_VERSION_MINIMUM: "3.5" TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} @@ -647,6 +649,8 @@ jobs: shell: bash run: cd desktop && pnpm tauri build --verbose --target "$TARGET" --bundles nsis --config src-tauri/tauri.release.conf.json env: + BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }} + BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} CMAKE_POLICY_VERSION_MINIMUM: "3.5" diff --git a/AGENTS.md b/AGENTS.md index 3ee87ab11..54de09602 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -552,5 +552,5 @@ just mobile-dev - [CONTRIBUTING.md](CONTRIBUTING.md) — setup, code style, PR process, how to add event kinds / CLI subcommands / API endpoints - [TESTING.md](TESTING.md) — multi-agent E2E test guide - [ARCHITECTURE.md](ARCHITECTURE.md) — system design and component relationships -- [RELEASING.md](RELEASING.md) — release process: `just release`, auto-tag, internal builds +- [RELEASING.md](RELEASING.md) — release process: `release-desktop`, `release-relay`, `release-mobile`, auto-tag, internal builds - [README.md](README.md) — project overview and quick start diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a2138564..722c5888c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## v0.3.31 + +- fix(release): publish versioned relay Docker tags via independent release lanes ([#1173](https://github.com/block/buzz/pull/1173)) ([`549b7d24`](https://github.com/block/buzz/commit/549b7d24813320045bdda629d865c6c7418e7450)) +- fix(desktop): align settings section headers ([#1165](https://github.com/block/buzz/pull/1165)) ([`6ad68a6b`](https://github.com/block/buzz/commit/6ad68a6b095cd5db328ae07dfac4013eeda5820a)) +- fix(desktop): ground agent workspace, migrate legacy nest, configurable repos_dir ([#1194](https://github.com/block/buzz/pull/1194)) ([`1011cea2`](https://github.com/block/buzz/commit/1011cea2682a5e8cd92c9da255d5ba6c8f7ced78)) +- fix(desktop): enable mesh llm for release builds ([#1221](https://github.com/block/buzz/pull/1221)) ([`fa1262a9`](https://github.com/block/buzz/commit/fa1262a92c85b96c1d643c247d28df4f2f57e81a)) +- fix(desktop): move crypto commands off the main thread ([#1222](https://github.com/block/buzz/pull/1222)) ([`e35e84b0`](https://github.com/block/buzz/commit/e35e84b08bdc44f1f5297e6957bcc52e7c31eb70)) +- fix: tolerate missing private_key_nsec in agent store ([#1220](https://github.com/block/buzz/pull/1220)) ([`c58e9880`](https://github.com/block/buzz/commit/c58e9880bf60324b0fbb64917b6d1c8e197d4ea4)) +- Update navigation header height ([#1212](https://github.com/block/buzz/pull/1212)) ([`6b5cf325`](https://github.com/block/buzz/commit/6b5cf325c2777f64696923cf5b1c1ffd4fdf82e2)) +- Improve global search ([#1195](https://github.com/block/buzz/pull/1195)) ([`5130a6a0`](https://github.com/block/buzz/commit/5130a6a0b60c56a5c31549d3d4e85b956e35a671)) +- Parse Typesense multi_search errors ([#1208](https://github.com/block/buzz/pull/1208)) ([`65ccb126`](https://github.com/block/buzz/commit/65ccb1262fb876b74584bca1165feef39eda67a6)) +- fix(desktop): keep settings shortcut from opening search ([#1204](https://github.com/block/buzz/pull/1204)) ([`89ff9504`](https://github.com/block/buzz/commit/89ff950444d03ea09eb54661a21f0cb96f0bfcb6)) +- Polish sidebar channel navigation ([#1213](https://github.com/block/buzz/pull/1213)) ([`c0a872e8`](https://github.com/block/buzz/commit/c0a872e898479bcb2c3dda1b642c0d1373174f68)) +- fix(desktop): restore channel unread badges ([#1218](https://github.com/block/buzz/pull/1218)) ([`89aaa264`](https://github.com/block/buzz/commit/89aaa26443486244b6f004a7c419f4e0dc86aa44)) +- Fix collapsed home header chrome overlap ([#1215](https://github.com/block/buzz/pull/1215)) ([`b4e75a1e`](https://github.com/block/buzz/commit/b4e75a1e41a614fa3449e814ccbd9f31090dfbfc)) +- fix(desktop): dedupe welcome intro per channel ([#1216](https://github.com/block/buzz/pull/1216)) ([`2a522826`](https://github.com/block/buzz/commit/2a522826edc6dfb4df79f34256beea6b8597505b)) +- fix(desktop): defer agent page secondary requests ([#1217](https://github.com/block/buzz/pull/1217)) ([`bee2d64c`](https://github.com/block/buzz/commit/bee2d64cf7f093088cc28463e96a6c94b64f280e)) +- ci(release): enable Tauri auto-updater on Windows and Linux builds ([#1206](https://github.com/block/buzz/pull/1206)) ([`3ef2a8e5`](https://github.com/block/buzz/commit/3ef2a8e5c7e655f3347931135dde5f65b919c915)) +- Hydrate reactions for rendered messages ([#1205](https://github.com/block/buzz/pull/1205)) ([`ed556f3d`](https://github.com/block/buzz/commit/ed556f3deb895e0adfa18274b3ed90f255b5f6ad)) +- fix(desktop): show NIP-OA owners in profile pane ([#1198](https://github.com/block/buzz/pull/1198)) ([`40070a58`](https://github.com/block/buzz/commit/40070a58559938ed649950ccabce0725dd3c966e)) +- fix(desktop): preserve login-shell PATH for managed agents ([#1193](https://github.com/block/buzz/pull/1193)) ([`29978b6f`](https://github.com/block/buzz/commit/29978b6f93cdd2c5d061093ddce87d567d8d4c17)) +- Fix nav chrome offset in fullscreen ([#1192](https://github.com/block/buzz/pull/1192)) ([`b3b0704e`](https://github.com/block/buzz/commit/b3b0704efb5afc74dca0a093a6e4594973e9edf4)) +- fix(desktop): show due-reminder count in the Inbox nav badge ([#1191](https://github.com/block/buzz/pull/1191)) ([`c0858dac`](https://github.com/block/buzz/commit/c0858dac12a3efd09d301f5424074f18df5cf422)) + + ## v0.3.30 - fix(desktop): collapse mark-read/unread menu into one toggling item ([#1188](https://github.com/block/buzz/pull/1188)) ([`ce994df74`](https://github.com/block/buzz/commit/ce994df74e60cf43b2fb0b97ea9989aacd47650e)) diff --git a/Justfile b/Justfile index aa1608d0e..1fe413f1b 100644 --- a/Justfile +++ b/Justfile @@ -449,6 +449,10 @@ check-compile: get-current-version: @node -p "require('./desktop/package.json').version" +# Read the current relay version from its crate manifest +get-current-relay-version: + @grep -m1 '^version = ' crates/buzz-relay/Cargo.toml | sed -E 's/version = "(.*)"/\1/' + # Compute next minor version (e.g., 0.3.0 → 0.4.0) get-next-minor-version: @python3 -c "v='$(just get-current-version)'.split('.'); print(f'{v[0]}.{int(v[1])+1}.0')" @@ -457,15 +461,22 @@ get-next-minor-version: get-next-patch-version: @python3 -c "v='$(just get-current-version)'.split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')" -# Update version in all package manifests and regenerate lockfiles -bump-version version: +# Compute next relay patch version (e.g., 0.3.0 → 0.3.1) +get-next-relay-patch-version: + @python3 -c "v='$(just get-current-relay-version)'.split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')" + +# Read the current mobile version from pubspec.yaml (strips the +build suffix) +get-current-mobile-version: + @grep -m1 '^version: ' mobile/pubspec.yaml | sed -E 's/version: ([^+]*).*/\1/' + +# Compute next mobile patch version (e.g., 0.3.0 → 0.3.1) +get-next-mobile-patch-version: + @python3 -c "v='$(just get-current-mobile-version)'.split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')" + +# Update version in desktop package manifests and regenerate lockfiles +bump-desktop-version version: #!/usr/bin/env bash set -euo pipefail - # Validate semver format - if ! echo "{{ version }}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then - echo "Error: '{{ version }}' is not valid semver (expected X.Y.Z)" - exit 1 - fi # desktop/package.json cd desktop && npm pkg set "version={{ version }}" && cd .. # desktop/src-tauri/tauri.conf.json @@ -486,50 +497,133 @@ bump-version version: t = t.replace(/^version = \".*\"/m, 'version = \"{{ version }}\"'); fs.writeFileSync(p, t); " - # mobile/pubspec.yaml — bump version but preserve build number - sed -i '' "s/^version: .*/version: {{ version }}+1/" mobile/pubspec.yaml # Regenerate lockfiles pnpm install --lockfile-only cargo update -p buzz-desktop --manifest-path desktop/src-tauri/Cargo.toml + echo "Bumped desktop manifests to {{ version }} and regenerated lockfiles" + +# Bump the relay crate version and regenerate the lockfile +bump-relay-version version: + #!/usr/bin/env bash + set -euo pipefail + # buzz-relay carries its own `version =` (not version.workspace), so the + # replace targets the package version line only. + perl -i -pe 's/^version = ".*"/version = "{{ version }}"/' crates/buzz-relay/Cargo.toml + cargo update -p buzz-relay + echo "Bumped buzz-relay to {{ version }} and regenerated Cargo.lock" + +# Bump the mobile pubspec version and regenerate the lockfile +bump-mobile-version version: + #!/usr/bin/env bash + set -euo pipefail + # pubspec carries a `version: X.Y.Z+build`; preserve the `+build` convention + # (a literal `+1`, matching the desktop lane's prior behavior). + perl -i -pe 's/^version: .*/version: {{ version }}+1/' mobile/pubspec.yaml (unset GIT_DIR GIT_WORK_TREE; cd mobile && flutter pub get) - echo "Bumped all manifests to {{ version }} and regenerated lockfiles" + echo "Bumped mobile to {{ version }} and regenerated pubspec.lock" -# Create or update a release PR that bumps version and generates changelog -release *ARGS: +# Open or update the desktop release PR (signed desktop app) +release-desktop *ARGS: #!/usr/bin/env bash set -euo pipefail - # Determine target version ARG="{{ ARGS }}" - if [[ -z "$ARG" ]]; then - VERSION=$(just get-next-patch-version) - elif [[ "$ARG" == "patch" ]]; then + if [[ -z "$ARG" || "$ARG" == "patch" ]]; then VERSION=$(just get-next-patch-version) else VERSION="$ARG" fi - echo "Preparing release v${VERSION}..." - # Ensure on main branch + just _release-pr desktop "$VERSION" + +# Open or update the relay release PR (ghcr.io/block/buzz image) +release-relay *ARGS: + #!/usr/bin/env bash + set -euo pipefail + ARG="{{ ARGS }}" + if [[ -z "$ARG" || "$ARG" == "patch" ]]; then + VERSION=$(just get-next-relay-patch-version) + else + VERSION="$ARG" + fi + just _release-pr relay "$VERSION" + +# Open or update the mobile release PR (Buzz mobile app) +release-mobile *ARGS: + #!/usr/bin/env bash + set -euo pipefail + ARG="{{ ARGS }}" + if [[ -z "$ARG" || "$ARG" == "patch" ]]; then + VERSION=$(just get-next-mobile-patch-version) + else + VERSION="$ARG" + fi + just _release-pr mobile "$VERSION" + +# Shared release-PR engine. One body, three lanes — the only lane-specific steps +# are the version-bump command and the file/tag/changelog identifiers selected +# in the `case` below. Everything else (git preflight, branch reset, changelog +# generation, commit, push, PR open/edit) is identical across lanes. +_release-pr lane version: + #!/usr/bin/env bash + set -euo pipefail + VERSION="{{ version }}" + if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "Error: '$VERSION' is not valid semver (expected X.Y.Z)" + exit 1 + fi + # Lane-specific identifiers. The bump command runs after the branch switch. + case "{{ lane }}" in + desktop) + BRANCH_PREFIX="version-bump" + TAG_FETCH='v*' + TAG_MATCH='v[0-9]*' + TAG_EXCLUDE='*-*' + TAG_PREFIX="v" + CHANGELOG="CHANGELOG.md" + ADD_FILES=(desktop/package.json desktop/src-tauri/tauri.conf.json desktop/src-tauri/Cargo.toml desktop/src-tauri/Cargo.lock pnpm-lock.yaml CHANGELOG.md) + ARTIFACT="Buzz Desktop" ;; + relay) + BRANCH_PREFIX="relay-release" + TAG_FETCH='relay-v*' + TAG_MATCH='relay-v[0-9]*' + TAG_EXCLUDE='relay-v*-*' + TAG_PREFIX="relay-v" + CHANGELOG="crates/buzz-relay/CHANGELOG.md" + ADD_FILES=(crates/buzz-relay/Cargo.toml Cargo.lock crates/buzz-relay/CHANGELOG.md) + ARTIFACT="Buzz Relay" ;; + mobile) + BRANCH_PREFIX="mobile-release" + TAG_FETCH='mobile-v*' + TAG_MATCH='mobile-v[0-9]*' + TAG_EXCLUDE='mobile-v*-*' + TAG_PREFIX="mobile-v" + CHANGELOG="mobile/CHANGELOG.md" + ADD_FILES=(mobile/pubspec.yaml mobile/pubspec.lock mobile/CHANGELOG.md) + ARTIFACT="Buzz Mobile" ;; + *) + echo "Error: unknown release lane '{{ lane }}'" + exit 1 ;; + esac + echo "Preparing ${ARTIFACT} release v${VERSION}..." + # Must run on main with a clean, up-to-date tree. CURRENT_BRANCH=$(git symbolic-ref --short HEAD) if [[ "$CURRENT_BRANCH" != "main" ]]; then echo "Error: must be on main branch (currently on '$CURRENT_BRANCH')" exit 1 fi - # Ensure local main and release tags are up-to-date. git fetch origin refs/heads/main:refs/remotes/origin/main --no-tags - # Release tags are remote-owned state; sync only v* tags so stale local - # tags from older histories do not make release preflight fail. - git fetch origin '+refs/tags/v*:refs/tags/v*' + # Release tags are remote-owned state; sync only this lane's tags so stale + # local tags from older histories do not make release preflight fail. + git fetch origin "+refs/tags/${TAG_FETCH}:refs/tags/${TAG_FETCH}" if [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]]; then echo "Error: local main is not up-to-date with origin/main. Run 'git pull' first." exit 1 fi - # Ensure clean working tree if ! git diff --quiet || ! git diff --cached --quiet; then echo "Error: working tree is dirty. Commit or stash changes first." exit 1 fi - # Switch to version-bump branch (create if needed, reset to main if it exists) - BRANCH="version-bump/${VERSION}" + # Switch to the release branch (create, or reset to main if it exists). + BRANCH="${BRANCH_PREFIX}/${VERSION}" if git rev-parse --verify "refs/heads/$BRANCH" >/dev/null 2>&1; then echo "Branch '$BRANCH' already exists — resetting to origin/main..." git switch "$BRANCH" @@ -541,10 +635,14 @@ release *ARGS: else git switch -c "$BRANCH" fi - # Bump versions and lockfiles - just bump-version "$VERSION" - # Generate changelog - LAST_TAG=$(git describe --tags --abbrev=0 --match 'v[0-9]*' --exclude '*-*' 2>/dev/null || echo "") + # Lane-specific bump (the one diverging step). + case "{{ lane }}" in + desktop) just bump-desktop-version "$VERSION" ;; + relay) just bump-relay-version "$VERSION" ;; + mobile) just bump-mobile-version "$VERSION" ;; + esac + # Generate the changelog from commits since this lane's last release tag. + LAST_TAG=$(git describe --tags --abbrev=0 --match "$TAG_MATCH" --exclude "$TAG_EXCLUDE" 2>/dev/null || echo "") REPO=$(git remote get-url origin | sed -E 's|.*github\.com[:/]||; s|\.git$||') format_log() { local range="$1" @@ -565,7 +663,7 @@ release *ARGS: { echo "# Changelog" echo "" - echo "## v${VERSION}" + echo "## ${TAG_PREFIX}${VERSION}" echo "" if [[ -n "$LAST_TAG" ]]; then format_log "${LAST_TAG}..HEAD" @@ -573,31 +671,23 @@ release *ARGS: echo "- Initial release" fi echo "" - if [[ -f CHANGELOG.md ]]; then - tail -n +2 CHANGELOG.md + if [[ -f "$CHANGELOG" ]]; then + tail -n +2 "$CHANGELOG" fi } > "$TMPFILE" - mv "$TMPFILE" CHANGELOG.md - # Commit - git add \ - desktop/package.json \ - desktop/src-tauri/tauri.conf.json \ - desktop/src-tauri/Cargo.toml \ - desktop/src-tauri/Cargo.lock \ - mobile/pubspec.yaml \ - mobile/pubspec.lock \ - pnpm-lock.yaml \ - CHANGELOG.md - RELEASE_MSG="chore(release): release version ${VERSION}" + mkdir -p "$(dirname "$CHANGELOG")" + mv "$TMPFILE" "$CHANGELOG" + # Commit. + git add "${ADD_FILES[@]}" + RELEASE_MSG="chore(release): release ${ARTIFACT} version ${VERSION}" if [[ "$(git log -1 --format='%s' 2>/dev/null)" == "$RELEASE_MSG" ]]; then git commit --amend --no-edit else git commit -m "$RELEASE_MSG" fi - # Push and open PR + # Push and open/update the PR. git push --force-with-lease -u origin "$BRANCH" - # Build PR body - PR_BODY="## Release v${VERSION}"$'\n\n' + PR_BODY="## ${ARTIFACT} release v${VERSION}"$'\n\n' if [[ -n "$LAST_TAG" ]]; then PR_BODY+="### Changes since ${LAST_TAG}:"$'\n\n' PR_BODY+="$(format_log "${LAST_TAG}..HEAD~1")"$'\n\n' @@ -605,18 +695,15 @@ release *ARGS: PR_BODY+="Initial release."$'\n\n' fi PR_BODY+="**To release:** merge this PR. The tag and build will happen automatically." + PR_TITLE="chore(release): release ${ARTIFACT} version ${VERSION}" EXISTING_PR=$(gh pr list --head "$BRANCH" --json url --jq '.[0].url' 2>/dev/null || true) if [[ -n "$EXISTING_PR" ]]; then - gh pr edit "$BRANCH" \ - --title "chore(release): release version ${VERSION}" \ - --body "$PR_BODY" + gh pr edit "$BRANCH" --title "$PR_TITLE" --body "$PR_BODY" PR_URL="$EXISTING_PR" echo "" echo "Updated existing release PR: ${PR_URL}" else - PR_URL=$(gh pr create \ - --title "chore(release): release version ${VERSION}" \ - --body "$PR_BODY") + PR_URL=$(gh pr create --title "$PR_TITLE" --body "$PR_BODY") echo "" echo "Release PR opened: ${PR_URL}" fi diff --git a/RELEASING.md b/RELEASING.md index 88f95aa13..ea0bd897b 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,60 +1,149 @@ -# Releasing Buzz Desktop +# Releasing Buzz + +Buzz has three independent release lanes, each driven by a release PR — no human +ever pushes a git tag: + +| Lane | Recipe | Artifact | +|------|--------|----------| +| Desktop | `just release-desktop` | Signed desktop app (macOS/Linux) | +| Relay | `just release-relay` | `ghcr.io/block/buzz` container image | +| Mobile | `just release-mobile` | Buzz mobile app (tag is the `sprout_ref` for the internal build) | + +The three lanes version independently: the desktop version lives in +`desktop/package.json`, the relay version in `crates/buzz-relay/Cargo.toml`, and +the mobile version in `mobile/pubspec.yaml`. + +The mobile lane publishes a `mobile-v` tag that is consumed +**manually**, cross-repo, as the `sprout_ref` input to the internal +`buzz-releases` Buildkite pipeline (iOS dogfood → Block Comp Portal, App Store → +TestFlight — see [Internal Releases](#internal-releases)). The OSS lane is +tag-only **by design**: OSS `block/buzz` CI cannot trigger CI in the private +`buzz-releases` repo (infosec), so a human cuts the internal build from the tag +rather than auto-dispatching across that boundary. ## Quick Start ```sh -# Regular release (next patch version) -just release +# Desktop release (next patch version) +just release-desktop -# Patch release -just release patch +# Desktop patch / minor / explicit +just release-desktop patch +just release-desktop 0.4.0 +just release-desktop 1.0.0 -# Minor release -just release 0.4.0 +# Relay release (same argument forms) +just release-relay +just release-relay 0.4.0 -# Any explicit version -just release 1.0.0 +# Mobile release (same argument forms) +just release-mobile +just release-mobile 0.4.0 ``` -This creates a `version-bump/` PR that bumps all version manifests, regenerates lockfiles, and appends a changelog entry. Merge the PR to trigger the build automatically. +`just release-desktop` creates a `version-bump/` PR; `just +release-relay` creates a `relay-release/` PR; `just release-mobile` +creates a `mobile-release/` PR. Each bumps its own version manifest, +regenerates lockfiles, and appends a changelog entry. Merge the PR to trigger +the build automatically (the mobile tag is instead the `sprout_ref` a human +feeds the internal build — see above). -Re-running `just release` with the same version is safe — it detects the existing branch and PR, resets to current `main`, regenerates the changelog with any new commits, and updates the PR in place. +Re-running any of these recipes with the same version is safe — it detects the +existing branch and PR, resets to current `main`, regenerates the changelog +with any new commits, and updates the PR in place. --- ## How It Works -1. **`just release`** runs locally on `main` — computes the next version, creates (or reuses) a `version-bump/` branch, bumps versions in all manifests, regenerates lockfiles, generates a changelog entry, commits, pushes, and opens (or updates) a PR. - -2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the `version-bump/*` branch merge and pushes a `v` tag. - -3. **Tag triggers `release.yml`** — the existing release workflow builds, signs, notarizes, and publishes the desktop app for macOS and Linux. +All three lanes share one engine; they differ only in which version manifest +they bump, which branch prefix they use, and what the merge triggers. + +### Desktop + +1. **`just release-desktop`** runs locally on `main` — computes the next + version, creates (or reuses) a `version-bump/` branch, bumps the + desktop manifests, regenerates lockfiles, generates a changelog + entry in `CHANGELOG.md`, commits, pushes, and opens (or updates) a PR. +2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the + `version-bump/*` branch merge and pushes a `v` tag. +3. **Tag triggers `release.yml`** — builds, signs, notarizes, and publishes the + desktop app for macOS and Linux. + +### Relay + +1. **`just release-relay`** runs locally on `main` — computes the next relay + version, creates (or reuses) a `relay-release/` branch, bumps + `crates/buzz-relay/Cargo.toml`, regenerates `Cargo.lock`, generates a + changelog entry in `crates/buzz-relay/CHANGELOG.md`, commits, pushes, and + opens (or updates) a PR. +2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the + `relay-release/*` branch merge and pushes a `relay-v` tag. +3. **Auto-tag dispatches `docker.yml`** — the same workflow then triggers + `docker.yml` with the version and tag ref, which builds the multi-arch relay + image and publishes `ghcr.io/block/buzz:` (plus `:.`, + `:`, and `:latest` for stable releases). Prereleases + (`relay-v-rc.1`) publish only the prerelease tag and do **not** + move `:latest`. (The dispatch — rather than relying on `docker.yml`'s + `push: tags` trigger — is required because GitHub suppresses `on: push` for + tags pushed by the workflow's own `GITHUB_TOKEN`; the desktop lane dispatches + `release.yml` for the same reason.) + +Every push to `main` continues to build and publish `:main` + `:sha-<7>` tags +(the rolling development image). The `:latest` tag tracks the latest **stable** +relay release only — it does not move on main pushes or prereleases. + +### Mobile + +1. **`just release-mobile`** runs locally on `main` — computes the next mobile + version, creates (or reuses) a `mobile-release/` branch, bumps + `mobile/pubspec.yaml` (preserving the `+build` number), regenerates + `mobile/pubspec.lock`, generates a changelog entry in `mobile/CHANGELOG.md`, + commits, pushes, and opens (or updates) a PR. +2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the + `mobile-release/*` branch merge and pushes a `mobile-v` tag. +3. **The tag is consumed manually, cross-repo** — nothing in OSS `block/buzz` + builds on the tag (OSS CI must not trigger CI in the private `buzz-releases` + repo — infosec). A human feeds the `mobile-v` tag as the + `sprout_ref` input to the internal `buzz-releases` Buildkite pipeline, which + builds and ships iOS to Block Comp Portal (dogfood) and TestFlight (App + Store, opt-in). See [Internal Releases](#internal-releases). --- ## Release Types +The argument forms below apply to `release-desktop`, `release-relay`, and +`release-mobile`: + | Command | Version | Example | |---------|---------|---------| -| `just release` | Next patch | `0.3.0` → `0.3.1` | -| `just release patch` | Next patch | `0.3.0` → `0.3.1` | -| `just release 0.4.0` | Explicit minor | `0.3.1` → `0.4.0` | -| `just release 1.0.0` | Explicit | `1.0.0` | +| `just release-desktop` | Next patch | `0.3.0` → `0.3.1` | +| `just release-desktop patch` | Next patch | `0.3.0` → `0.3.1` | +| `just release-desktop 0.4.0` | Explicit minor | `0.3.1` → `0.4.0` | +| `just release-desktop 1.0.0` | Explicit | `1.0.0` | --- ## Version Files -`just bump-version ` updates these files: +`just bump-desktop-version ` (desktop lane) updates these files: | File | Field | |------|-------| | `desktop/package.json` | `"version"` | | `desktop/src-tauri/tauri.conf.json` | `"version"` | | `desktop/src-tauri/Cargo.toml` | `version` (under `[package]`) | -| `mobile/pubspec.yaml` | `version:` (preserves build number) | -It also regenerates `pnpm-lock.yaml`, `desktop/src-tauri/Cargo.lock`, and `mobile/pubspec.lock`. +It also regenerates `pnpm-lock.yaml` and `desktop/src-tauri/Cargo.lock`. + +`just bump-relay-version ` (relay lane) updates +`crates/buzz-relay/Cargo.toml` (`version` under `[package]`) and regenerates the +workspace `Cargo.lock`. + +`just bump-mobile-version ` (mobile lane) updates +`mobile/pubspec.yaml` (`version:`, preserving the `+build` number) and +regenerates `mobile/pubspec.lock`. --- @@ -116,14 +205,14 @@ the same `v` release. Intel users download the `_x64.dmg`. ## Troubleshooting -### `just release` fails with "must be on main branch" -Switch to `main` and pull latest before running `just release`. +### `just release-desktop` fails with "must be on main branch" +Switch to `main` and pull latest before running the release recipe. -### `just release` fails with "working tree is dirty" -Commit or stash your changes before running `just release`. +### `just release-desktop` fails with "working tree is dirty" +Commit or stash your changes before running the release recipe. ### New commits merged after creating the release PR -Re-run `just release` from an up-to-date `main`. It resets the branch to current `main`, regenerates the changelog and PR body to include the new commits, and force-pushes the updated branch. +Re-run the release recipe (`just release-desktop`, `just release-relay`, or `just release-mobile`) from an up-to-date `main`. It resets the branch to current `main`, regenerates the changelog and PR body to include the new commits, and force-pushes the updated branch. ### Build fails at "Validate version" The version string must be valid semver: `MAJOR.MINOR.PATCH` with an optional pre-release suffix. Do not include a `v` prefix. diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 3c80d1e75..faf4a845e 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -67,11 +67,13 @@ Your persistent workspace is in your working directory: | `GUIDES/` | How-to documentation | | `WORK_LOGS/` | Timestamped activity logs | | `OUTBOX/` | Drafts pending review or send | -| `REPOS/` | Checked-out source repositories | +| `REPOS/` | Source checkouts. Work in an existing local checkout when one exists; clone here only when none does | | `.scratch/` | Ephemeral working files | Knowledge files use `ALL_CAPS_WITH_UNDERSCORES.md` naming. `AGENTS.md` lists active agents and roles. See `AGENTS.md` in your working directory for full workspace conventions. +These paths are relative to your working directory — keep exploration there. Never run `find` or recursive searches over `$HOME` or `/` hunting for workspace files: they live under your working directory, not elsewhere on disk. + ## Agent Memory Your `core` memory is auto-injected into your context every turn — it holds identity, durable rules, and goals across sessions. diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index ca11fede2..6f82f2a24 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -440,7 +440,7 @@ async fn create_session_and_apply_model( // header from `engram_fetch::build_core_section`, so we just append it. let combined_system_prompt: Option = if agent.protocol_version >= 2 { with_core( - framed_system_prompt(ctx.base_prompt, ctx.system_prompt.as_deref()), + framed_system_prompt(&ctx.cwd, ctx.base_prompt, ctx.system_prompt.as_deref()), agent_core, ) } else { @@ -670,8 +670,20 @@ pub(crate) fn prepend_base_for_legacy( /// split the combined value into labeled sub-sections. Each prompt is wrapped /// only when present, so a persona-only agent yields `[System]\n{persona}` /// rather than an unlabeled blob that would be mislabeled as `[Base]`. -fn framed_system_prompt(base_prompt: Option<&str>, system_prompt: Option<&str>) -> Option { - match (base_prompt, system_prompt) { +/// +/// Prepends a `[Workspace]` section naming the agent's absolute working +/// directory. The base prompt describes the workspace layout but never its +/// absolute root, so without this anchor a model fills the gap by searching +/// `$HOME` (triggering macOS TCC prompts) or by inventing its own workspace +/// directory. The line is emitted only when a real base prompt is present and +/// `cwd` is an absolute path other than the `/` fallback — naming `/` as the +/// workspace would itself invite a `$HOME`-wide scan. +fn framed_system_prompt( + cwd: &str, + base_prompt: Option<&str>, + system_prompt: Option<&str>, +) -> Option { + let body = match (base_prompt, system_prompt) { (Some(bp), Some(sp)) => Some(format!( "{}\n\n[System]\n{sp}", crate::queue::base_section(bp) @@ -679,6 +691,32 @@ fn framed_system_prompt(base_prompt: Option<&str>, system_prompt: Option<&str>) (Some(bp), None) => Some(crate::queue::base_section(bp)), (None, Some(sp)) => Some(format!("[System]\n{sp}")), (None, None) => None, + }?; + // Anchor the workspace only when a base prompt is present — the workspace + // section grounds the base prompt's layout description, so it is meaningless + // for a persona-only (`[System]`-only) agent that never received that layout. + match (base_prompt, workspace_section(cwd)) { + (Some(_), Some(workspace)) => Some(format!("{workspace}\n\n{body}")), + _ => Some(body), + } +} + +/// Render the `[Workspace]` grounding section, or `None` when `cwd` is unusable. +/// +/// Skips relative paths and the `/` fallback (`std::env::current_dir()` resolves +/// to `/` on failure): a `/`-rooted workspace line would actively encourage the +/// `$HOME`-wide scan this section exists to prevent. +fn workspace_section(cwd: &str) -> Option { + if cwd != "/" && cwd.starts_with('/') { + Some(format!( + "[Workspace]\nYour absolute working directory is `{cwd}`. All workspace \ + files — `AGENTS.md`, `RESEARCH/`, `PLANS/`, `GUIDES/`, `WORK_LOGS/`, \ + `OUTBOX/` — and any repositories you clone (under `{cwd}/REPOS/`) live \ + here. This is where you already are; do not search `$HOME` or other \ + directories for them." + )) + } else { + None } } @@ -2393,14 +2431,14 @@ mod tests { #[test] fn test_framed_system_prompt_both_present_carries_both_headers() { - let framed = framed_system_prompt(Some("base text"), Some("persona text")) + let framed = framed_system_prompt("/", Some("base text"), Some("persona text")) .expect("both present yields Some"); assert_eq!(framed, "[Base]\nbase text\n\n[System]\npersona text"); } #[test] fn test_framed_system_prompt_base_only_labels_base() { - let framed = framed_system_prompt(Some("base text"), None).expect("base yields Some"); + let framed = framed_system_prompt("/", Some("base text"), None).expect("base yields Some"); assert_eq!(framed, "[Base]\nbase text"); } @@ -2408,13 +2446,51 @@ mod tests { fn test_framed_system_prompt_persona_only_labels_system() { // A bare persona would be mislabeled "Base" downstream — it must carry // its own [System] header even when no base prompt exists. - let framed = framed_system_prompt(None, Some("persona text")).expect("persona yields Some"); + let framed = + framed_system_prompt("/", None, Some("persona text")).expect("persona yields Some"); assert_eq!(framed, "[System]\npersona text"); } #[test] fn test_framed_system_prompt_neither_is_none() { - assert!(framed_system_prompt(None, None).is_none()); + assert!(framed_system_prompt("/", None, None).is_none()); + } + + #[test] + fn test_framed_system_prompt_absolute_cwd_prepends_workspace_before_base() { + let framed = framed_system_prompt("/Users/me/.buzz", Some("base text"), None) + .expect("base yields Some"); + assert!( + framed.starts_with("[Workspace]\n"), + "workspace section must lead: {framed}" + ); + assert!(framed.contains("`/Users/me/.buzz`")); + assert!( + framed.contains("\n\n[Base]\nbase text"), + "base must follow the workspace section: {framed}" + ); + } + + #[test] + fn test_framed_system_prompt_persona_only_omits_workspace() { + // The workspace section grounds the base prompt's layout; a persona-only + // agent never received that layout, so no [Workspace] anchor is emitted. + let framed = framed_system_prompt("/Users/me/.buzz", None, Some("persona text")) + .expect("persona yields Some"); + assert_eq!(framed, "[System]\npersona text"); + } + + #[test] + fn test_framed_system_prompt_root_cwd_omits_workspace() { + // The "/" fallback must never be named — it would invite a $HOME scan. + let framed = framed_system_prompt("/", Some("base text"), None).expect("base yields Some"); + assert_eq!(framed, "[Base]\nbase text"); + } + + #[test] + fn test_workspace_section_relative_cwd_is_none() { + assert!(workspace_section("relative/path").is_none()); + assert!(workspace_section("").is_none()); } // ── with_core tests ────────────────────────────────────────────────────── diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index e28389080..6545a6f64 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -1,7 +1,10 @@ [package] name = "buzz-relay" default-run = "buzz-relay" -version.workspace = true +# Independent version: buzz-relay ships as a pinnable artifact +# (ghcr.io/block/buzz), released on its own cadence via `just release-relay`. +# It does NOT inherit the workspace version. +version = "0.1.0" edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 59daa0647..714ced400 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -5,6 +5,12 @@ use std::net::SocketAddr; use thiserror::Error; use tracing::warn; +/// Default maximum inbound WebSocket frame size in bytes. +/// +/// Must comfortably exceed accepted event content sizes after Nostr JSON and +/// NIP-44 encryption overhead. +pub const DEFAULT_MAX_FRAME_BYTES: usize = 512 * 1024; + /// Errors that can occur while loading relay configuration. #[derive(Debug, Error)] pub enum ConfigError { @@ -37,6 +43,10 @@ pub struct Config { pub max_concurrent_handlers: usize, /// Per-connection outbound message buffer size (number of messages). pub send_buffer_size: usize, + /// Maximum inbound WebSocket frame size in bytes. + pub max_frame_bytes: usize, + /// Number of consecutive buffer-full events tolerated before cancelling a slow client. + pub slow_client_grace_limit: u8, /// Authentication provider configuration. pub auth: buzz_auth::AuthConfig, /// Whether REST API requests must present a valid token. Independent of @@ -159,6 +169,17 @@ impl Config { .and_then(|v| v.parse().ok()) .unwrap_or(1_000); + let max_frame_bytes = std::env::var("BUZZ_MAX_FRAME_BYTES") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|&v| v > 0) + .unwrap_or(DEFAULT_MAX_FRAME_BYTES); + + let slow_client_grace_limit = std::env::var("BUZZ_SLOW_CLIENT_GRACE_LIMIT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(15); + let require_auth_token = std::env::var("BUZZ_REQUIRE_AUTH_TOKEN") .map(|v| v == "true" || v == "1") .unwrap_or(false); @@ -360,6 +381,8 @@ impl Config { max_connections, max_concurrent_handlers, send_buffer_size, + max_frame_bytes, + slow_client_grace_limit, auth, require_auth_token, cors_origins, @@ -401,6 +424,8 @@ mod tests { assert!(!config.redis_url.is_empty()); assert!(config.max_connections > 0); assert!(config.send_buffer_size > 0); + assert_eq!(config.max_frame_bytes, DEFAULT_MAX_FRAME_BYTES); + assert!(config.slow_client_grace_limit > 0); assert!( !config.pubkey_allowlist_enabled, "pubkey_allowlist_enabled should default to false" @@ -428,6 +453,15 @@ mod tests { assert!(matches!(result, Err(ConfigError::InvalidBindAddr(_)))); } + #[test] + fn max_frame_bytes_can_be_configured() { + let _guard = ENV_MUTEX.lock().unwrap(); + std::env::set_var("BUZZ_MAX_FRAME_BYTES", "262144"); + let config = Config::from_env().expect("config"); + std::env::remove_var("BUZZ_MAX_FRAME_BYTES"); + assert_eq!(config.max_frame_bytes, 262_144); + } + #[test] fn server_domain_auto_derived_from_relay_url() { let _guard = ENV_MUTEX.lock().unwrap(); diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 3d3e2eac7..d6d6ccf77 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -20,10 +20,6 @@ use crate::handlers; use crate::protocol::{ClientMessage, RelayMessage}; use crate::state::AppState; -/// Number of buffer-full events tolerated before cancelling a slow client. -/// Prevents transient read stalls from hard-disconnecting agents mid-inference. -pub(crate) const SLOW_CLIENT_GRACE_LIMIT: u8 = 3; - /// Shared mutable subscription map for a single WebSocket connection. pub(crate) type ConnectionSubscriptions = Arc>>>; @@ -62,18 +58,20 @@ pub struct ConnectionState { pub ctrl_tx: mpsc::Sender, /// Token used to signal graceful shutdown of this connection's tasks. pub cancel: CancellationToken, - /// Consecutive buffer-full events. Cancel only after [`SLOW_CLIENT_GRACE_LIMIT`]. + /// Consecutive buffer-full events. Cancel only after `grace_limit`. /// Shared with `ConnectionManager::ConnEntry` so both direct sends and /// fan-out broadcasts track the same counter. pub backpressure_count: Arc, + /// Configurable slow-client grace limit (from `Config::slow_client_grace_limit`). + pub grace_limit: u8, } impl ConnectionState { /// Sends a data message to this connection's outbound channel. /// /// On a full buffer, increments the backpressure counter. The first - /// [`SLOW_CLIENT_GRACE_LIMIT`] occurrences log a warning; sustained - /// backpressure cancels the connection to prevent unbounded memory growth. + /// `grace_limit` occurrences log a warning; sustained backpressure + /// cancels the connection to prevent unbounded memory growth. pub fn send(&self, msg: String) -> bool { match self.send_tx.try_send(WsMessage::Text(msg.into())) { Ok(_) => { @@ -83,12 +81,12 @@ impl ConnectionState { } Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { let count = self.backpressure_count.fetch_add(1, Ordering::Relaxed) + 1; - if count >= SLOW_CLIENT_GRACE_LIMIT { + if count >= self.grace_limit { warn!(conn_id = %self.conn_id, count, "sustained backpressure — closing slow client"); metrics::counter!("buzz_ws_backpressure_disconnects_total").increment(1); self.cancel.cancel(); } else { - warn!(conn_id = %self.conn_id, count, grace = SLOW_CLIENT_GRACE_LIMIT, "send buffer full — grace {count}/{SLOW_CLIENT_GRACE_LIMIT}"); + warn!(conn_id = %self.conn_id, count, grace = self.grace_limit, "send buffer full — grace {count}/{}", self.grace_limit); } false } @@ -136,6 +134,7 @@ pub async fn handle_connection(socket: WebSocket, state: Arc, addr: So ctrl_tx: ctrl_tx.clone(), cancel: cancel.clone(), backpressure_count: Arc::clone(&backpressure_count), + grace_limit: state.config.slow_client_grace_limit, }); info!(conn_id = %conn_id, addr = %addr, "WebSocket connection established"); @@ -162,6 +161,7 @@ pub async fn handle_connection(socket: WebSocket, state: Arc, addr: So cancel.clone(), Arc::clone(&backpressure_count), subscriptions, + state.config.slow_client_grace_limit, ); let (ws_send, ws_recv) = socket.split(); @@ -283,9 +283,6 @@ async fn heartbeat_loop( } } -/// NIP-11 advertised max_message_length. Frames exceeding this are rejected. -pub const MAX_FRAME_BYTES: usize = 65536; - async fn recv_loop( mut ws_recv: futures_util::stream::SplitStream, conn: Arc, @@ -298,16 +295,38 @@ async fn recv_loop( msg = ws_recv.next() => { match msg { Some(Ok(WsMessage::Text(text))) => { - if text.len() > MAX_FRAME_BYTES { - warn!(conn_id = %conn.conn_id, bytes = text.len(), "frame too large — disconnecting"); + let max_frame_bytes = state.config.max_frame_bytes; + if text.len() > max_frame_bytes { + warn!( + conn_id = %conn.conn_id, + bytes = text.len(), + max_frame_bytes, + "frame too large — disconnecting" + ); + conn.send(format!( + r#"["NOTICE","error: frame too large ({} bytes, limit {})"]"#, + text.len(), + max_frame_bytes + )); break; } trace!(len = text.len(), "frame received"); handle_text_message(text.to_string(), Arc::clone(&conn), Arc::clone(&state)).await; } Some(Ok(WsMessage::Binary(bytes))) => { - if bytes.len() > MAX_FRAME_BYTES { - warn!(conn_id = %conn.conn_id, bytes = bytes.len(), "binary frame too large — disconnecting"); + let max_frame_bytes = state.config.max_frame_bytes; + if bytes.len() > max_frame_bytes { + warn!( + conn_id = %conn.conn_id, + bytes = bytes.len(), + max_frame_bytes, + "binary frame too large — disconnecting" + ); + conn.send(format!( + r#"["NOTICE","error: binary frame too large ({} bytes, limit {})"]"#, + bytes.len(), + max_frame_bytes + )); break; } // Binary frames: attempt UTF-8 decode and treat as text. Some clients diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 3c9a1cf93..0190b36ee 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -112,6 +112,57 @@ pub async fn filter_fanout_by_access( allowed } +/// Fan out one event received from Redis pub/sub to this relay's local subscribers. +pub async fn fan_out_pubsub_event(state: &Arc, channel_event: buzz_pubsub::ChannelEvent) { + // Nil UUID is the sentinel for channel-less global events (see + // `handle_ephemeral_event`'s global branch). Convert back to None so + // `fan_out()` uses the global subscriber index. + let channel_id = if channel_event.channel_id.is_nil() { + None + } else { + Some(channel_event.channel_id) + }; + let stored = StoredEvent::new(channel_event.event, channel_id); + + // Skip events that were already fanned out in-process (local echo). The + // cache has TTL-based eviction (60s) so entries are bounded regardless of + // subscriber health. + let event_id_bytes = stored.event.id.to_bytes(); + if state.local_event_ids.get(&event_id_bytes).is_some() { + state.local_event_ids.invalidate(&event_id_bytes); + return; + } + + let matches = state.sub_registry.fan_out(&stored); + let matches = filter_fanout_by_access(state, &stored, matches).await; + metrics::counter!("buzz_multinode_fanout_total").increment(1); + if matches.is_empty() { + return; + } + + let event_json = match serde_json::to_string(&stored.event) { + Ok(json) => json, + Err(e) => { + tracing::error!("Failed to serialize event for multi-node fan-out: {e}"); + return; + } + }; + let mut drop_count = 0u32; + for (conn_id, sub_id) in &matches { + let msg = format!(r#"["EVENT","{}",{}]"#, sub_id, event_json); + if !state.conn_manager.send_to(*conn_id, msg) { + drop_count += 1; + } + } + if drop_count > 0 { + tracing::warn!( + event_id = %stored.event.id.to_hex(), + drop_count, + "multi-node fan-out: {drop_count} connection(s) dropped" + ); + } +} + /// Publish a stored event to subscribers and kick off async side effects. pub(crate) async fn dispatch_persistent_event( state: &Arc, @@ -474,28 +525,9 @@ async fn handle_ephemeral_event( let _ = state.pubsub.set_presence(&auth_pubkey, &status).await; } - let stored_event = StoredEvent::new(event.clone(), None); - let matches = state.sub_registry.fan_out(&stored_event); - metrics::histogram!("buzz_fanout_recipients").record(matches.len() as f64); - let event_json = serde_json::to_string(&event) - .expect("nostr::Event serialization is infallible for well-formed events"); - let mut drop_count = 0u32; - for (target_conn_id, sub_id) in &matches { - let msg = format!(r#"["EVENT","{}",{}]"#, sub_id, event_json); - if !state.conn_manager.send_to(*target_conn_id, msg) { - drop_count += 1; - } - } - if drop_count > 0 { - tracing::warn!( - event_id = %event_id_hex, - drop_count, - "fan-out: {drop_count} connection(s) cancelled due to full/closed buffers" - ); - } - - conn.send(RelayMessage::ok(event_id_hex, true, "")); - return; + // Presence is a channel-less ephemeral event. After updating Redis + // presence state, let it fall through to the shared global ephemeral + // publish/fan-out path below so other relay nodes receive the live delta. } // Mesh status report (kind:24620). An authenticated relay member reports its @@ -1001,6 +1033,188 @@ mod tests { assert!(err.contains("NIP-44")); } + mod pubsub_fanout { + use std::collections::HashMap; + use std::sync::atomic::AtomicU8; + use std::sync::Arc; + + use axum::extract::ws::Message; + use buzz_core::kind::KIND_PRESENCE_UPDATE; + use buzz_pubsub::ChannelEvent; + use nostr::{EventBuilder, Filter, Keys, Kind}; + use tokio::sync::{mpsc, Mutex}; + use tokio_util::sync::CancellationToken; + use uuid::Uuid; + + use crate::handlers::event::fan_out_pubsub_event; + use crate::state::AppState; + + async fn test_state() -> Arc { + super::fanout_access::test_state().await + } + + fn register_presence_sub( + state: &AppState, + sub_id: &str, + ) -> (Uuid, mpsc::Receiver) { + let conn_id = Uuid::new_v4(); + let (tx, rx) = mpsc::channel(10); + state.conn_manager.register( + conn_id, + tx, + CancellationToken::new(), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + ); + state.sub_registry.register( + conn_id, + sub_id.to_string(), + vec![Filter::new().kind(Kind::Custom(KIND_PRESENCE_UPDATE as u16))], + None, + ); + (conn_id, rx) + } + + fn presence_event(status: &str) -> nostr::Event { + EventBuilder::new(Kind::Custom(KIND_PRESENCE_UPDATE as u16), status) + .sign_with_keys(&Keys::generate()) + .expect("sign presence") + } + + fn event_from_ws_message(msg: Message) -> nostr::Event { + let Message::Text(text) = msg else { + panic!("expected text ws message"); + }; + let v: serde_json::Value = serde_json::from_str(&text).expect("EVENT frame JSON"); + assert_eq!(v[0], "EVENT"); + serde_json::from_value(v[2].clone()).expect("nostr event") + } + + #[tokio::test] + async fn global_presence_pubsub_event_fans_out_to_local_subscribers() { + let state = test_state().await; + let (_conn_id, mut rx) = register_presence_sub(&state, "presence"); + let event = presence_event("online"); + let event_id = event.id; + + fan_out_pubsub_event( + &state, + ChannelEvent { + channel_id: Uuid::nil(), + event, + }, + ) + .await; + + let delivered = event_from_ws_message(rx.try_recv().expect("presence delivered")); + assert_eq!(delivered.id, event_id); + assert!(rx.try_recv().is_err(), "presence is delivered once"); + } + + #[tokio::test] + async fn local_echo_presence_pubsub_event_is_not_delivered_twice() { + let state = test_state().await; + let (_conn_id, mut rx) = register_presence_sub(&state, "presence"); + let event = presence_event("online"); + + state.mark_local_event(&event.id); + fan_out_pubsub_event( + &state, + ChannelEvent { + channel_id: Uuid::nil(), + event, + }, + ) + .await; + + assert!( + rx.try_recv().is_err(), + "Redis echo of locally fanned-out presence must be suppressed" + ); + } + + async fn redis_url_if_available() -> Option { + let redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + let pool = deadpool_redis::Config::from_url(&redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let mut conn = pool.get().await.ok()?; + redis::cmd("PING") + .query_async::(&mut conn) + .await + .ok()?; + Some(redis_url) + } + + fn spawn_pubsub_fanout_loop(state: Arc) -> tokio::task::JoinHandle<()> { + let mut rx = state.pubsub.subscribe_local(); + tokio::spawn(async move { + while let Ok(channel_event) = rx.recv().await { + fan_out_pubsub_event(&state, channel_event).await; + } + }) + } + + #[tokio::test] + async fn redis_presence_publish_reaches_second_relay_and_suppresses_origin_echo() { + let Some(redis_url) = redis_url_if_available().await else { + eprintln!("skipping Redis round-trip presence fan-out test: Redis unavailable"); + return; + }; + + let origin = super::fanout_access::test_state_with_redis_url(&redis_url).await; + let receiver = super::fanout_access::test_state_with_redis_url(&redis_url).await; + + let origin_subscriber = tokio::spawn(origin.pubsub.clone().run_subscriber()); + let receiver_subscriber = tokio::spawn(receiver.pubsub.clone().run_subscriber()); + let origin_fanout = spawn_pubsub_fanout_loop(origin.clone()); + let receiver_fanout = spawn_pubsub_fanout_loop(receiver.clone()); + + let (_origin_conn, mut origin_rx) = register_presence_sub(&origin, "origin-presence"); + let (_receiver_conn, mut receiver_rx) = + register_presence_sub(&receiver, "receiver-presence"); + + // Match buzz-pubsub's own Redis round-trip test: give PSUBSCRIBE a + // bounded moment to attach before publishing the single test event. + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + let event = presence_event("online"); + let event_id = event.id; + origin.mark_local_event(&event.id); + origin + .pubsub + .publish_event(Uuid::nil(), &event) + .await + .expect("publish presence through Redis"); + + let delivered = + tokio::time::timeout(std::time::Duration::from_secs(2), receiver_rx.recv()) + .await + .expect("presence reached second relay") + .expect("receiver connection still open"); + let delivered = event_from_ws_message(delivered); + assert_eq!(delivered.id, event_id); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), receiver_rx.recv()) + .await + .is_err(), + "second relay receives the presence event exactly once" + ); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(250), origin_rx.recv()) + .await + .is_err(), + "origin relay suppresses the Redis echo after local fan-out" + ); + + origin_subscriber.abort(); + receiver_subscriber.abort(); + origin_fanout.abort(); + receiver_fanout.abort(); + } + } + mod fanout_access { use std::collections::HashMap; use std::sync::atomic::AtomicU8; @@ -1015,15 +1229,16 @@ mod tests { use crate::handlers::event::filter_fanout_by_access; use crate::state::AppState; - fn test_config() -> crate::config::Config { + pub(super) fn test_config() -> crate::config::Config { let mut config = crate::config::Config::from_env().expect("default config loads"); config.require_relay_membership = false; config.redis_url = "redis://127.0.0.1:1".to_string(); config } - async fn test_state() -> Arc { - let config = test_config(); + pub(super) async fn test_state_with_redis_url(redis_url: &str) -> Arc { + let mut config = test_config(); + config.redis_url = redis_url.to_string(); let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); let db = buzz_db::Db::from_pool(pool.clone()); let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) @@ -1062,6 +1277,10 @@ mod tests { Arc::new(state) } + pub(super) async fn test_state() -> Arc { + test_state_with_redis_url("redis://127.0.0.1:1").await + } + fn register_conn(state: &AppState, pubkey: Option>) -> Uuid { let conn_id = Uuid::new_v4(); let (tx, _rx) = mpsc::channel(1); @@ -1071,6 +1290,7 @@ mod tests { CancellationToken::new(), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), + 3, ); if let Some(pk) = pubkey { state.conn_manager.set_authenticated_pubkey(conn_id, pk); diff --git a/crates/buzz-relay/src/handlers/mesh_signaling.rs b/crates/buzz-relay/src/handlers/mesh_signaling.rs index 4de6d15c1..0f3815169 100644 --- a/crates/buzz-relay/src/handlers/mesh_signaling.rs +++ b/crates/buzz-relay/src/handlers/mesh_signaling.rs @@ -550,6 +550,7 @@ mod tests { tokio_util::sync::CancellationToken::new(), std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + 3, ); state.sub_registry.register( conn_id, diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 544106500..1893c3c2b 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -20,7 +20,7 @@ use crate::connection::{AuthState, ConnectionState}; use crate::protocol::RelayMessage; use crate::state::AppState; -const MAX_HISTORICAL_LIMIT: i64 = 10_000; +const MAX_HISTORICAL_LIMIT: i64 = 2_000; const MAX_SUBSCRIPTIONS: usize = 1024; const P_GATED_KINDS: [u32; 5] = [ KIND_AGENT_OBSERVER_FRAME, @@ -253,6 +253,9 @@ pub async fn handle_req( return; } total_sent += 1; + if total_sent.is_multiple_of(100) { + tokio::task::yield_now().await; + } } } diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index d2bafd300..b555c3d9d 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -44,6 +44,7 @@ async fn main() -> anyhow::Result<()> { relay_url = %config.relay_url, health_port = config.health_port, metrics_port = config.metrics_port, + max_frame_bytes = config.max_frame_bytes, "Config loaded" ); @@ -493,63 +494,11 @@ async fn main() -> anyhow::Result<()> { loop { match rx.recv().await { Ok(channel_event) => { - // Nil UUID is the sentinel for channel-less global events - // (see event.rs `else` branch). Convert back to None so - // fan_out() uses the global subscriber index instead of - // looking up subscribers under Some(Uuid::nil()), which - // would find nothing and silently drop every cross-node - // global event. - let channel_id = if channel_event.channel_id.is_nil() { - None - } else { - Some(channel_event.channel_id) - }; - let stored = buzz_core::StoredEvent::new(channel_event.event, channel_id); - - // Skip events that were already fanned out in-process (local echo). - // The cache has TTL-based eviction (60s) so entries are bounded - // regardless of subscriber health. - let event_id_bytes = stored.event.id.to_bytes(); - if state_for_sub.local_event_ids.get(&event_id_bytes).is_some() { - state_for_sub.local_event_ids.invalidate(&event_id_bytes); - continue; - } - - let matches = state_for_sub.sub_registry.fan_out(&stored); - let matches = buzz_relay::handlers::event::filter_fanout_by_access( + buzz_relay::handlers::event::fan_out_pubsub_event( &state_for_sub, - &stored, - matches, + channel_event, ) .await; - metrics::counter!("buzz_multinode_fanout_total").increment(1); - if matches.is_empty() { - continue; - } - - let event_json = match serde_json::to_string(&stored.event) { - Ok(json) => json, - Err(e) => { - tracing::error!( - "Failed to serialize event for multi-node fan-out: {e}" - ); - continue; - } - }; - let mut drop_count = 0u32; - for (conn_id, sub_id) in &matches { - let msg = format!(r#"["EVENT","{}",{}]"#, sub_id, event_json); - if !state_for_sub.conn_manager.send_to(*conn_id, msg) { - drop_count += 1; - } - } - if drop_count > 0 { - tracing::warn!( - event_id = %stored.event.id.to_hex(), - drop_count, - "multi-node fan-out: {drop_count} connection(s) dropped" - ); - } } Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { metrics::counter!("buzz_multinode_fanout_lag_total").increment(n); diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index d797dc807..e988c03f3 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -2,7 +2,8 @@ use serde::{Deserialize, Serialize}; -use crate::connection::MAX_FRAME_BYTES; +#[cfg(test)] +use crate::config::DEFAULT_MAX_FRAME_BYTES; /// NIPs unconditionally supported by this relay, advertised in the NIP-11 /// document. Kept as a module-level constant so tests can verify it without @@ -82,14 +83,14 @@ pub struct RelayLimitation { /// unconditionally reject connections that are not in /// `AuthState::Authenticated`. This is independent of the REST API token /// toggle (`config.require_auth_token`). -fn relay_limitation() -> RelayLimitation { +fn relay_limitation(max_message_length: usize) -> RelayLimitation { let max_not_before_delta: u64 = std::env::var("SPROUT_MAX_NOT_BEFORE_DELTA") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(31_536_000); // 1 year default RelayLimitation { - max_message_length: Some(MAX_FRAME_BYTES as u64), + max_message_length: Some(max_message_length as u64), max_subscriptions: Some(1024), max_filters: Some(10), max_limit: Some(10_000), @@ -118,7 +119,11 @@ impl RelayInfo { /// gates on NIP-43 events — i.e. has a stable key AND enforces /// membership. NIP-43 events are verified against `self`, so it is a /// programmer error to advertise NIP-43 without a `relay_self`. - pub fn build(relay_self: Option<&str>, advertise_nip43: bool) -> Self { + pub fn build( + relay_self: Option<&str>, + advertise_nip43: bool, + max_message_length: usize, + ) -> Self { debug_assert!( !advertise_nip43 || relay_self.is_some(), "advertise_nip43=true requires relay_self=Some — NIP-43 events are verified against `self`" @@ -138,7 +143,7 @@ impl RelayInfo { supported_extensions: Some(vec!["nip-er".to_string()]), software: "https://github.com/block/buzz".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), - limitation: Some(relay_limitation()), + limitation: Some(relay_limitation(max_message_length)), relay_self: relay_self.map(|s| s.to_string()), } } @@ -149,7 +154,11 @@ pub async fn relay_info_handler( axum::extract::State(state): axum::extract::State>, ) -> axum::response::Json { let (relay_self, advertise_nip43) = nip11_facts(&state); - axum::response::Json(RelayInfo::build(relay_self.as_deref(), advertise_nip43)) + axum::response::Json(RelayInfo::build( + relay_self.as_deref(), + advertise_nip43, + state.config.max_frame_bytes, + )) } /// Derives the two NIP-11 facts that depend on runtime config: @@ -199,7 +208,7 @@ mod tests { #[test] fn build_advertises_buzz_repository_url() { - let info = RelayInfo::build(None, false); + let info = RelayInfo::build(None, false, DEFAULT_MAX_FRAME_BYTES); assert_eq!(info.software, "https://github.com/block/buzz"); } @@ -208,7 +217,14 @@ mod tests { // REQ, EVENT, and COUNT all unconditionally require // `AuthState::Authenticated` (see `crates/buzz-relay/src/handlers/`), // so the NIP-11 doc must advertise it. - assert!(relay_limitation().auth_required); + assert!(relay_limitation(DEFAULT_MAX_FRAME_BYTES).auth_required); + } + + #[test] + fn max_message_length_uses_configured_frame_limit() { + let info = RelayInfo::build(None, false, 262_144); + let limitation = info.limitation.expect("limitation"); + assert_eq!(limitation.max_message_length, Some(262_144)); } #[test] @@ -237,7 +253,7 @@ mod tests { /// Open relay, ephemeral key — both `self` and NIP-43 are absent. #[test] fn build_open_relay_ephemeral_key_omits_self_and_nip43() { - let info = RelayInfo::build(None, false); + let info = RelayInfo::build(None, false, DEFAULT_MAX_FRAME_BYTES); assert!(info.relay_self.is_none()); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -250,7 +266,7 @@ mod tests { #[test] fn build_open_relay_stable_key_advertises_self_but_not_nip43() { let pk = "0000000000000000000000000000000000000000000000000000000000000001"; - let info = RelayInfo::build(Some(pk), false); + let info = RelayInfo::build(Some(pk), false, DEFAULT_MAX_FRAME_BYTES); assert_eq!(info.relay_self.as_deref(), Some(pk)); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -259,7 +275,7 @@ mod tests { #[test] fn build_membership_relay_advertises_self_and_nip43() { let pk = "0000000000000000000000000000000000000000000000000000000000000001"; - let info = RelayInfo::build(Some(pk), true); + let info = RelayInfo::build(Some(pk), true, DEFAULT_MAX_FRAME_BYTES); assert_eq!(info.relay_self.as_deref(), Some(pk)); assert!(info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -270,6 +286,6 @@ mod tests { #[test] #[should_panic(expected = "advertise_nip43=true requires relay_self=Some")] fn build_nip43_without_self_panics_in_debug() { - let _ = RelayInfo::build(None, true); + let _ = RelayInfo::build(None, true, DEFAULT_MAX_FRAME_BYTES); } } diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 226592a07..d9a56887d 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -156,7 +156,11 @@ async fn nip11_or_ws_handler( let (relay_self, advertise_nip43) = nip11_facts(&state); if accept.contains("application/nostr+json") { - let info = RelayInfo::build(relay_self.as_deref(), advertise_nip43); + let info = RelayInfo::build( + relay_self.as_deref(), + advertise_nip43, + state.config.max_frame_bytes, + ); return Json(info).into_response(); } @@ -175,7 +179,11 @@ async fn nip11_or_ws_handler( } } // Not a WS request and not asking for nostr+json — serve NIP-11 as fallback. - let info = RelayInfo::build(relay_self.as_deref(), advertise_nip43); + let info = RelayInfo::build( + relay_self.as_deref(), + advertise_nip43, + state.config.max_frame_bytes, + ); Json(info).into_response() } } diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 3617975a0..76384775e 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -24,7 +24,7 @@ use deadpool_redis; use crate::audio::AudioRoomManager; use crate::config::Config; -use crate::connection::{ConnectionSubscriptions, SLOW_CLIENT_GRACE_LIMIT}; +use crate::connection::ConnectionSubscriptions; use crate::subscription::SubscriptionRegistry; /// Per-connection entry in the connection manager. @@ -36,6 +36,7 @@ struct ConnEntry { backpressure_count: Arc, subscriptions: ConnectionSubscriptions, authenticated_pubkey: Arc>>>, + grace_limit: u8, } /// Tracks active WebSocket connections and provides message routing by connection ID. @@ -52,7 +53,7 @@ impl ConnectionManager { } /// Registers a connection with its outbound sender, cancellation token, - /// shared backpressure counter, and mutable subscription map. + /// shared backpressure counter, mutable subscription map, and grace limit. pub fn register( &self, conn_id: Uuid, @@ -60,6 +61,7 @@ impl ConnectionManager { cancel: CancellationToken, backpressure_count: Arc, subscriptions: ConnectionSubscriptions, + grace_limit: u8, ) { self.connections.insert( conn_id, @@ -69,6 +71,7 @@ impl ConnectionManager { backpressure_count, subscriptions, authenticated_pubkey: Arc::new(std::sync::RwLock::new(None)), + grace_limit, }, ); } @@ -131,8 +134,8 @@ impl ConnectionManager { /// Sends a text message to the given connection. /// /// Returns `false` if the connection is gone or the buffer is full. - /// On sustained backpressure (>[`SLOW_CLIENT_GRACE_LIMIT`] consecutive full - /// buffers), cancels the connection. Transient stalls get a warning only. + /// On sustained backpressure (>grace_limit consecutive full buffers), + /// cancels the connection. Transient stalls get a warning only. pub fn send_to(&self, conn_id: Uuid, msg: String) -> bool { if let Some(entry) = self.connections.get(&conn_id) { let conn = entry.value(); @@ -143,12 +146,12 @@ impl ConnectionManager { } Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { let count = conn.backpressure_count.fetch_add(1, Ordering::Relaxed) + 1; - if count >= SLOW_CLIENT_GRACE_LIMIT { + if count >= conn.grace_limit { tracing::warn!(conn_id = %conn_id, count, "fan-out: sustained backpressure — cancelling slow client"); metrics::counter!("buzz_ws_backpressure_disconnects_total").increment(1); conn.cancel.cancel(); } else { - tracing::warn!(conn_id = %conn_id, count, grace = SLOW_CLIENT_GRACE_LIMIT, "fan-out: send buffer full — grace {count}/{SLOW_CLIENT_GRACE_LIMIT}"); + tracing::warn!(conn_id = %conn_id, count, grace = conn.grace_limit, "fan-out: send buffer full — grace {count}/{}", conn.grace_limit); } false } @@ -591,6 +594,7 @@ mod tests { cancel.clone(), Arc::clone(&bp), Arc::new(Mutex::new(HashMap::new())), + 3, ); (mgr, conn_id, rx, cancel, bp) } @@ -633,13 +637,13 @@ mod tests { fn send_to_cancels_after_grace_limit() { let (mgr, id, _rx, cancel, _bp) = setup_conn(1); assert!(mgr.send_to(id, "fill".into())); - // Exhaust grace: 3 consecutive Full events. - for _ in 0..SLOW_CLIENT_GRACE_LIMIT { + // Exhaust grace: 3 consecutive Full events (matches grace_limit=3 from setup_conn). + for _ in 0..3u8 { mgr.send_to(id, "overflow".into()); } assert!( cancel.is_cancelled(), - "should cancel after SLOW_CLIENT_GRACE_LIMIT overflows" + "should cancel after grace_limit overflows" ); } @@ -662,6 +666,7 @@ mod tests { ctrl_tx, cancel: cancel.clone(), backpressure_count: Arc::clone(&bp), + grace_limit: 3, }; let mgr = ConnectionManager::new(); @@ -671,6 +676,7 @@ mod tests { cancel.clone(), Arc::clone(&bp), Arc::clone(&conn.subscriptions), + 3, ); // Fill the buffer via direct send. @@ -705,7 +711,7 @@ mod tests { let cancel = CancellationToken::new(); let bp = Arc::new(AtomicU8::new(0)); let subscriptions = Arc::new(Mutex::new(HashMap::new())); - mgr.register(conn_id, tx, cancel, bp, Arc::clone(&subscriptions)); + mgr.register(conn_id, tx, cancel, bp, Arc::clone(&subscriptions), 3); let pubkey = vec![7u8; 32]; mgr.set_authenticated_pubkey(conn_id, pubkey.clone()); @@ -722,7 +728,7 @@ mod tests { let cancel = CancellationToken::new(); let bp = Arc::new(AtomicU8::new(0)); let subscriptions = Arc::new(Mutex::new(HashMap::new())); - mgr.register(conn_id, tx, cancel, bp, subscriptions); + mgr.register(conn_id, tx, cancel, bp, subscriptions, 3); assert_eq!(mgr.pubkey_for_conn(conn_id), None); let pubkey = vec![9u8; 32]; diff --git a/crates/buzz-search/src/query.rs b/crates/buzz-search/src/query.rs index e8fbf1700..c440d7bc0 100644 --- a/crates/buzz-search/src/query.rs +++ b/crates/buzz-search/src/query.rs @@ -100,7 +100,20 @@ pub struct SearchResult { #[derive(Debug, Deserialize)] struct TypesenseMultiSearchResponse { - results: Vec, + results: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum TypesenseSearchResult { + Ok(TypesenseSearchResponse), + Error(TypesenseSearchError), +} + +#[derive(Debug, Deserialize)] +struct TypesenseSearchError { + code: u16, + error: String, } #[derive(Debug, Deserialize)] @@ -175,13 +188,22 @@ pub async fn search( return Err(SearchError::Api { status, body }); } - // multi_search wraps results: {"results": []} + // multi_search wraps results: {"results": []}. Individual + // searches can fail inside an HTTP 200 response as `{code, error}`; surface + // those as API errors instead of deserializing them as JSON errors so callers + // and logs show the actual Typesense failure. let wrapper: TypesenseMultiSearchResponse = resp.json().await?; let ts_resp = wrapper.results.into_iter().next().ok_or(SearchError::Api { status: 200, body: "empty multi_search results".into(), })?; - parse_response(ts_resp) + match ts_resp { + TypesenseSearchResult::Ok(response) => parse_response(response), + TypesenseSearchResult::Error(error) => Err(SearchError::Api { + status: error.code, + body: error.error, + }), + } } fn parse_response(ts_resp: TypesenseSearchResponse) -> Result { @@ -332,4 +354,58 @@ mod tests { assert_eq!(result.found, 0); assert!(result.hits.is_empty()); } + + #[test] + fn test_multi_search_result_success_parses() { + let raw = json!({ + "results": [{ + "found": 1, + "page": 1, + "hits": [{ + "document": { + "id": "abc123", + "content": "hello buzz", + "kind": 1, + "pubkey": "deadbeef", + "channel_id": "chan-uuid", + "created_at": 1700000000i64, + "tags_flat": [] + }, + "text_match": 578730123i64 + }] + }] + }); + + let wrapper: TypesenseMultiSearchResponse = + serde_json::from_value(raw).expect("should parse multi_search success result"); + let response = match wrapper.results.into_iter().next().expect("one result") { + TypesenseSearchResult::Ok(response) => response, + TypesenseSearchResult::Error(err) => panic!("expected success result, got {err:?}"), + }; + + let result = parse_response(response).expect("should parse response"); + assert_eq!(result.found, 1); + assert_eq!(result.hits.len(), 1); + assert_eq!(result.hits[0].event_id, "abc123"); + } + + #[test] + fn test_multi_search_result_error_parses() { + let raw = json!({ + "results": [{ + "code": 400, + "error": "Could not find a filter field named `channel_id` in the schema." + }] + }); + + let wrapper: TypesenseMultiSearchResponse = + serde_json::from_value(raw).expect("should parse multi_search error result"); + let err = match wrapper.results.into_iter().next().expect("one result") { + TypesenseSearchResult::Ok(_) => panic!("expected error result"), + TypesenseSearchResult::Error(err) => err, + }; + + assert_eq!(err.code, 400); + assert!(err.error.contains("channel_id")); + } } diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index 6aaaf9ae4..fe9e308f3 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -39,7 +39,7 @@ fn relay_http_url() -> String { .to_string() } -/// Create a real channel via a signed kind:9007 event submitted to POST /api/events. +/// Create a real channel via a signed kind:9007 event submitted to POST /events. async fn create_test_channel(keys: &Keys) -> String { let client = reqwest::Client::new(); let pubkey_hex = keys.public_key().to_hex(); @@ -57,7 +57,7 @@ async fn create_test_channel(keys: &Keys) -> String { .unwrap(); let resp = client - .post(format!("{}/api/events", relay_http_url())) + .post(format!("{}/events", relay_http_url())) .header("X-Pubkey", &pubkey_hex) .header("Content-Type", "application/json") .body(serde_json::to_string(&event).unwrap()) @@ -151,6 +151,56 @@ async fn test_send_event_and_receive_via_subscription() { client_b.disconnect().await.expect("disconnect B"); } +#[tokio::test] +#[ignore] +async fn test_large_event_frame_below_configured_limit_is_accepted() { + let url = relay_url(); + let kind: u16 = 9; + + let keys = Keys::generate(); + let channel = create_test_channel(&keys).await; + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + + let h_tag = Tag::parse(["h", channel.as_str()]).expect("h tag"); + let content = "x".repeat(70_000); + let event = EventBuilder::new(Kind::Custom(kind), content) + .tags([h_tag]) + .sign_with_keys(&keys) + .expect("sign large event"); + + let frame = serde_json::to_string(&serde_json::json!(["EVENT", &event])).expect("frame JSON"); + assert!( + frame.len() > 65_536, + "test frame must exceed the old 64 KiB cap; got {} bytes", + frame.len() + ); + assert!( + frame.len() < 512 * 1024, + "test frame should fit under the new default cap; got {} bytes", + frame.len() + ); + + let ok = client.send_event(event).await.expect("send large event"); + assert!(ok.accepted, "large event rejected: {}", ok.message); + + let ok_after = client + .send_text_message( + &keys, + &channel, + "socket still usable after large frame", + kind, + ) + .await + .expect("send follow-up event"); + assert!( + ok_after.accepted, + "follow-up event rejected: {}", + ok_after.message + ); + + client.disconnect().await.expect("disconnect"); +} + #[tokio::test] #[ignore] async fn test_subscription_filters_by_kind() { @@ -1347,7 +1397,7 @@ async fn test_membership_notification_emitted_on_add() { .sign_with_keys(&owner_keys) .unwrap(); let resp = http_client - .post(format!("{}/api/events", relay_http_url())) + .post(format!("{}/events", relay_http_url())) .header("X-Pubkey", &owner_keys.public_key().to_hex()) .header("Content-Type", "application/json") .body(serde_json::to_string(&add_event).unwrap()) @@ -1619,7 +1669,7 @@ async fn test_membership_notification_emitted_on_remove() { .sign_with_keys(&owner_keys) .unwrap(); let resp = http_client - .post(format!("{}/api/events", relay_http_url())) + .post(format!("{}/events", relay_http_url())) .header("X-Pubkey", &owner_pubkey_hex) .header("Content-Type", "application/json") .body(serde_json::to_string(&add_event).unwrap()) @@ -1658,7 +1708,7 @@ async fn test_membership_notification_emitted_on_remove() { .sign_with_keys(&owner_keys) .unwrap(); let resp = http_client - .post(format!("{}/api/events", relay_http_url())) + .post(format!("{}/events", relay_http_url())) .header("X-Pubkey", &owner_pubkey_hex) .header("Content-Type", "application/json") .body(serde_json::to_string(&remove_event).unwrap()) diff --git a/crates/sprig/Cargo.toml b/crates/sprig/Cargo.toml index f0bf9a0e4..4e8c4ab41 100644 --- a/crates/sprig/Cargo.toml +++ b/crates/sprig/Cargo.toml @@ -1,6 +1,8 @@ [package] name = "sprig" -version.workspace = true +# Independent version: sprig ships as a pinnable artifact (sprig-v* tags), +# released on its own cadence. It does NOT inherit the workspace version. +version = "0.1.0" edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/deploy/compose/compose.yml b/deploy/compose/compose.yml index 696cc125a..2f2dc0abe 100644 --- a/deploy/compose/compose.yml +++ b/deploy/compose/compose.yml @@ -50,7 +50,7 @@ services: - buzz-net postgres: - image: postgres:18-alpine + image: postgres:17-alpine environment: POSTGRES_DB: ${POSTGRES_DB:-buzz} POSTGRES_USER: ${POSTGRES_USER:-buzz} @@ -69,7 +69,7 @@ services: - buzz-net redis: - image: redis:8-alpine + image: redis:7-alpine command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:?set REDIS_PASSWORD}"] environment: REDIS_PASSWORD: ${REDIS_PASSWORD:?set REDIS_PASSWORD} diff --git a/desktop/package.json b/desktop/package.json index d4b51d5d4..a174e6fa2 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.3.30", + "version": "0.3.31", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 7f7ccce4f..5c431eba7 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -47,6 +47,7 @@ export default defineConfig({ "**/relay-connectivity-screenshots.spec.ts", "**/unread-pill-screenshots.spec.ts", "**/sidebar-more-unread-overlap.spec.ts", + "**/home-collapsed-top-chrome-screenshots.spec.ts", "**/thread-unread-screenshots.spec.ts", "**/animated-avatar-screenshots.spec.ts", "**/reminders-screenshots.spec.ts", diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 6e362d8b1..cc6050e87 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -31,11 +31,20 @@ const rules = [ // file is broken up. Tracked as a follow-up. const overrides = new Map([ ["src-tauri/src/commands/agents.rs", 1294], - ["src-tauri/src/managed_agents/nest.rs", 1420], + // Residual repos_dir integration in ensure_nest_at: REPOS is provisioned + // outside NEST_DIRS (it may be a symlink), so it needs its own create + + // chmod-only-when-real-dir handling plus integration test coverage. The + // self-contained repos_dir functions and their unit tests live in repos.rs; + // this is the seam that must stay in nest.rs. Approved override; still queued + // to split with the rest of this list. + ["src-tauri/src/managed_agents/nest.rs", 1447], ["src-tauri/src/managed_agents/runtime.rs", 1953], ["src-tauri/src/managed_agents/personas.rs", 1080], ["src-tauri/src/managed_agents/persona_card.rs", 1050], - ["src/shared/api/tauri.ts", 1196], + // applyWorkspace reposDir parameter threaded through the Tauri invoke for + // configurable repos_dir — a 3-line overage from load-bearing parameter + // plumbing, not generic debt growth. Approved override; still queued to split. + ["src/shared/api/tauri.ts", 1198], ["src-tauri/src/nostr_convert.rs", 1126], ["src/shared/api/relayClientSession.ts", 1022], ["src-tauri/src/migration.rs", 1295], diff --git a/desktop/scripts/check-px-text.mjs b/desktop/scripts/check-px-text.mjs index ea70ca56d..f0d4fd1f4 100644 --- a/desktop/scripts/check-px-text.mjs +++ b/desktop/scripts/check-px-text.mjs @@ -23,7 +23,7 @@ const rules = [ // glyph is a fixed display size sized to its avatar box (not readable message // text), so it stays as the lone documented `text-[6rem]` literal. const overrides = new Set([ - "src/features/settings/ui/ProfileSettingsCard.tsx:573", + "src/features/settings/ui/ProfileSettingsCard.tsx:572", "src/features/onboarding/ui/AvatarStep.tsx:89", ]); diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 9714697c9..e3c4a1e9e 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -846,7 +846,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.3.30" +version = "0.3.31" dependencies = [ "anyhow", "atomic-write-file", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index a1f921b7c..7b0d42213 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "buzz-desktop" -version = "0.3.30" +version = "0.3.31" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 8a4e8401b..d527253b3 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -6,12 +6,12 @@ use tauri::{AppHandle, State}; use crate::{ app_state::AppState, managed_agents::{ - build_managed_agent_summary, default_agent_workdir, find_managed_agent_mut, - known_acp_runtime, load_managed_agents, load_personas, managed_agent_avatar_url, - missing_command_message, normalize_agent_args, resolve_command, - resolve_effective_prompt_model_provider, save_managed_agents, sync_managed_agent_processes, - try_regenerate_nest, AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest, - UpdateManagedAgentResponse, + build_databricks_defaults, build_managed_agent_summary, default_agent_workdir, + find_managed_agent_mut, known_acp_runtime, load_managed_agents, load_personas, + managed_agent_avatar_url, missing_command_message, normalize_agent_args, resolve_command, + resolve_effective_prompt_model_provider, runtime_metadata_env_vars, save_managed_agents, + sync_managed_agent_processes, try_regenerate_nest, AgentModelInfo, AgentModelsResponse, + UpdateManagedAgentRequest, UpdateManagedAgentResponse, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -27,7 +27,16 @@ pub async fn get_agent_models( app: AppHandle, state: State<'_, AppState>, ) -> Result { - let (resolved_acp, agent_command, agent_args, persisted_model, merged_env) = { + let ( + resolved_acp, + agent_command, + agent_args, + persisted_model, + runtime_default_env, + runtime_metadata_env, + databricks_defaults, + merged_env, + ) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -66,14 +75,51 @@ pub async fn get_agent_models( // Resolve the effective model from the linked persona so the ModelPicker // dropdown shows the current persona model as selected. let personas = load_personas(&app).unwrap_or_default(); - let (_prompt, effective_model, _provider) = resolve_effective_prompt_model_provider( - record.persona_id.as_deref(), - &personas, - record.system_prompt.clone(), - record.model.clone(), - ); - - (resolved, resolved_agent, args, effective_model, env) + let (_prompt, effective_model, effective_provider) = + resolve_effective_prompt_model_provider( + record.persona_id.as_deref(), + &personas, + record.system_prompt.clone(), + record.model.clone(), + ); + let runtime = known_acp_runtime(&record.agent_command); + let runtime_default_env: Vec<(String, String)> = runtime + .map(|meta| { + meta.default_env + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect() + }) + .unwrap_or_default(); + let runtime_metadata_env: Vec<(String, String)> = runtime + .map(|meta| { + runtime_metadata_env_vars( + meta.model_env_var, + meta.provider_env_var, + meta.provider_locked, + effective_model.as_deref(), + effective_provider.as_deref(), + ) + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect() + }) + .unwrap_or_default(); + let databricks_defaults: Vec<(String, String)> = build_databricks_defaults() + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(); + + ( + resolved, + resolved_agent, + args, + effective_model, + runtime_default_env, + runtime_metadata_env, + databricks_defaults, + env, + ) }; // store lock released — subprocess runs without holding the lock // Clone the env map for redaction below — `merged_env` is moved @@ -96,13 +142,17 @@ pub async fn get_agent_models( .arg("--json") .env("BUZZ_ACP_AGENT_COMMAND", &agent_command) .env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); - if let Some(meta) = known_acp_runtime(&agent_command) { - for (key, value) in meta.default_env { - if std::env::var(key).is_err() { - cmd.env(key, value); - } + for (key, value) in &runtime_default_env { + if std::env::var(key).is_err() { + cmd.env(key, value); } } + for (key, value) in &runtime_metadata_env { + cmd.env(key, value); + } + for (key, value) in &databricks_defaults { + cmd.env(key, value); + } // User env layering — written LAST so it overrides any Buzz-set env above. for (k, v) in &merged_env { cmd.env(k, v); @@ -123,6 +173,12 @@ pub async fn get_agent_models( // a failing child process echoed back. let stderr_redacted = crate::managed_agents::redact_env_values_in(stderr.as_ref(), &env_for_redaction); + if let Some(configuration_error) = model_configuration_error(&stderr_redacted) { + return Ok(unavailable_agent_models( + persisted_model, + configuration_error, + )); + } return Err(format!( "buzz-acp models failed (exit {}): {stderr_redacted}", output.status.code().unwrap_or(-1) @@ -380,5 +436,53 @@ fn normalize_agent_models( agent_default_model, selected_model: persisted_model, supports_switching, + configuration_error: None, + } +} + +fn unavailable_agent_models( + persisted_model: Option, + configuration_error: String, +) -> AgentModelsResponse { + AgentModelsResponse { + agent_name: "unknown".to_string(), + agent_version: "unknown".to_string(), + models: Vec::new(), + agent_default_model: None, + selected_model: persisted_model, + supports_switching: false, + configuration_error: Some(configuration_error), } } + +fn model_configuration_error(stderr: &str) -> Option { + let normalized = stderr.to_ascii_lowercase(); + + if normalized.contains("buzz_agent_provider required") { + return Some( + "This agent does not have an LLM provider configured. Set a provider and model on the persona or agent, then retry." + .to_string(), + ); + } + + if normalized.contains("anthropic_model required") + || normalized.contains("openai_compat_model required") + || normalized.contains("databricks_model required") + { + return Some( + "This agent does not have an LLM model configured. Set a model on the persona or agent, then retry." + .to_string(), + ); + } + + if normalized.contains("anthropic_api_key required") + || normalized.contains("openai_compat_api_key required") + { + return Some( + "This agent is missing credentials for its configured LLM provider. Add the provider credentials, then retry." + .to_string(), + ); + } + + None +} diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 4c0e6ce53..2afa45c44 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -64,30 +64,38 @@ pub fn get_media_proxy_port(state: State<'_, AppState>) -> u16 { } #[tauri::command] -pub fn sign_event( +pub async fn sign_event( kind: u16, content: String, created_at: Option, tags: Vec>, state: State<'_, AppState>, ) -> Result { - let keys = state.keys.lock().map_err(|error| error.to_string())?; + let keys = state + .keys + .lock() + .map_err(|error| error.to_string())? + .clone(); - let nostr_tags = tags - .into_iter() - .map(|tag| Tag::parse(tag).map_err(|error| format!("invalid tag: {error}"))) - .collect::, _>>()?; + tauri::async_runtime::spawn_blocking(move || { + let nostr_tags = tags + .into_iter() + .map(|tag| Tag::parse(tag).map_err(|error| format!("invalid tag: {error}"))) + .collect::, _>>()?; - let mut builder = EventBuilder::new(Kind::Custom(kind), content).tags(nostr_tags); - if let Some(created_at) = created_at { - builder = builder.custom_created_at(Timestamp::from(created_at)); - } + let mut builder = EventBuilder::new(Kind::Custom(kind), content).tags(nostr_tags); + if let Some(created_at) = created_at { + builder = builder.custom_created_at(Timestamp::from(created_at)); + } - let event = builder - .sign_with_keys(&keys) - .map_err(|error| format!("sign failed: {error}"))?; + let event = builder + .sign_with_keys(&keys) + .map_err(|error| format!("sign failed: {error}"))?; - Ok(event.as_json()) + Ok(event.as_json()) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] @@ -197,49 +205,67 @@ pub fn import_identity( } #[tauri::command] -pub fn create_auth_event( +pub async fn create_auth_event( challenge: String, relay_url: String, state: State<'_, AppState>, ) -> Result { - let keys = state.keys.lock().map_err(|error| error.to_string())?; + let keys = state + .keys + .lock() + .map_err(|error| error.to_string())? + .clone(); - let tags = vec![ - Tag::parse(vec!["relay", &relay_url]) - .map_err(|error| format!("relay tag failed: {error}"))?, - Tag::parse(vec!["challenge", &challenge]) - .map_err(|error| format!("challenge tag failed: {error}"))?, - ]; + tauri::async_runtime::spawn_blocking(move || { + let tags = vec![ + Tag::parse(vec!["relay", &relay_url]) + .map_err(|error| format!("relay tag failed: {error}"))?, + Tag::parse(vec!["challenge", &challenge]) + .map_err(|error| format!("challenge tag failed: {error}"))?, + ]; - let event = EventBuilder::new(Kind::Custom(22242), "") - .tags(tags) - .sign_with_keys(&keys) - .map_err(|error| format!("sign failed: {error}"))?; + let event = EventBuilder::new(Kind::Custom(22242), "") + .tags(tags) + .sign_with_keys(&keys) + .map_err(|error| format!("sign failed: {error}"))?; - Ok(event.as_json()) + Ok(event.as_json()) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] -pub fn nip44_encrypt_to_self( +pub async fn nip44_encrypt_to_self( plaintext: String, state: State<'_, AppState>, ) -> Result { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - nip44::encrypt( - keys.secret_key(), - &keys.public_key(), - &plaintext, - nip44::Version::V2, - ) - .map_err(|e| format!("nip44 encrypt failed: {e}")) + let keys = state.keys.lock().map_err(|e| e.to_string())?.clone(); + + tauri::async_runtime::spawn_blocking(move || { + nip44::encrypt( + keys.secret_key(), + &keys.public_key(), + &plaintext, + nip44::Version::V2, + ) + .map_err(|e| format!("nip44 encrypt failed: {e}")) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] -pub fn nip44_decrypt_from_self( +pub async fn nip44_decrypt_from_self( ciphertext: String, state: State<'_, AppState>, ) -> Result { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - nip44::decrypt(keys.secret_key(), &keys.public_key(), &ciphertext) - .map_err(|e| format!("nip44 decrypt failed: {e}")) + let keys = state.keys.lock().map_err(|e| e.to_string())?.clone(); + + tauri::async_runtime::spawn_blocking(move || { + nip44::decrypt(keys.secret_key(), &keys.public_key(), &ciphertext) + .map_err(|e| format!("nip44 decrypt failed: {e}")) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index fcf73818e..2a04666fc 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -360,19 +360,26 @@ fn event_has_client_marker(event: &Event, marker: &str) -> bool { async fn find_managed_agent_channel_message_by_marker( state: &AppState, - agent_pubkey: &str, + agent_pubkey: Option<&str>, channel_id: &str, marker: &str, ) -> Result, String> { + let author = agent_pubkey + .map(str::trim) + .filter(|pubkey| !pubkey.is_empty()) + .map(str::to_ascii_lowercase); + let mut until: Option = None; for _ in 0..10 { let mut filter = serde_json::json!({ - "authors": [agent_pubkey], "kinds": [buzz_core_pkg::kind::KIND_STREAM_MESSAGE], "#h": [channel_id], "limit": 500, }); + if let Some(author) = author.as_deref() { + filter["authors"] = serde_json::json!([author]); + } if let Some(until) = until { filter["until"] = serde_json::json!(until); } @@ -401,6 +408,16 @@ async fn find_managed_agent_channel_message_by_marker( Ok(None) } +fn marker_author_for_scope<'a>( + marker_scope: Option<&str>, + agent_pubkey: &'a str, +) -> Option<&'a str> { + match marker_scope { + Some("channel") => None, + _ => Some(agent_pubkey), + } +} + fn stored_managed_agent_auth_tag(auth_tag: Option<&str>) -> Option { auth_tag .map(str::trim) @@ -440,6 +457,7 @@ pub async fn send_managed_agent_channel_message( channel_id: String, content: String, marker: Option, + marker_scope: Option, app: AppHandle, state: State<'_, AppState>, ) -> Result { @@ -480,7 +498,7 @@ pub async fn send_managed_agent_channel_message( if let Some(marker) = marker.as_deref() { if let Some(existing) = find_managed_agent_channel_message_by_marker( &state, - &record.pubkey, + marker_author_for_scope(marker_scope.as_deref(), &record.pubkey), &channel_id, marker, ) @@ -526,6 +544,30 @@ pub async fn send_managed_agent_channel_message( mod tests { use super::*; + #[test] + fn marker_author_scope_defaults_to_agent() { + assert_eq!( + marker_author_for_scope(None, "agent-pubkey"), + Some("agent-pubkey") + ); + assert_eq!( + marker_author_for_scope(Some("agent"), "agent-pubkey"), + Some("agent-pubkey") + ); + assert_eq!( + marker_author_for_scope(Some("unknown"), "agent-pubkey"), + Some("agent-pubkey") + ); + } + + #[test] + fn marker_author_scope_can_dedupe_across_channel() { + assert_eq!( + marker_author_for_scope(Some("channel"), "agent-pubkey"), + None + ); + } + #[test] fn stored_managed_agent_auth_tag_trims_blank_values() { assert_eq!( diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index e218ca36e..64cc6256d 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -296,5 +296,6 @@ fn empty_profile_info(pubkey: &str) -> ProfileInfo { avatar_url: None, about: None, nip05_handle: None, + owner_pubkey: None, } } diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 79ca43f97..7c3529fc1 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -1,9 +1,9 @@ use nostr::Keys; use serde::Serialize; -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Emitter, State}; use crate::app_state::AppState; -use crate::managed_agents::try_regenerate_nest; +use crate::managed_agents::{ensure_repos_symlink, nest_dir, try_regenerate_nest}; use crate::relay; #[derive(Serialize)] @@ -26,11 +26,20 @@ pub fn get_active_workspace(state: State<'_, AppState>) -> Result, + repos_dir: Option, app: AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { @@ -42,6 +51,24 @@ pub fn apply_workspace( None => None, }; + // Normalize repos_dir to a trimmed non-empty value. `None`/empty clears + // the override (REPOS falls back to a real dir). A bad path is rejected + // here — before any mutation — so the dialog sees a clean Err. + let repos_dir = repos_dir + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + if let Some(dir) = repos_dir.as_deref() { + let nest = nest_dir().ok_or("cannot resolve home directory for nest")?; + // Validate without mutating the filesystem. Keeps the command's + // "validate-first, nothing below can fail" contract honest. Also emit + // the error so it surfaces even at the init call site (which swallows + // the returned Err to console for the relay/keys path). + if let Err(error) = crate::managed_agents::validate_repos_dir(&nest, dir) { + let _ = app.emit("repos-dir-error", error.clone()); + return Err(error); + } + } + // ── Apply all state changes (nothing below can fail) ────────────────── { let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?; @@ -53,6 +80,17 @@ pub fn apply_workspace( *keys_guard = keys; } + // ── Filesystem side-effect (non-fatal) ──────────────────────────────── + // Re-point REPOS to match repos_dir. Failure here (downgrade refused, + // external target gone) must NOT fail the command — relay/keys are already + // applied. Surface it via a `repos-dir-error` event the frontend toasts. + if let Some(nest) = nest_dir() { + if let Err(error) = ensure_repos_symlink(&nest, repos_dir.as_deref()) { + eprintln!("buzz-desktop: repos dir setup failed: {error}"); + let _ = app.emit("repos-dir-error", error); + } + } + try_regenerate_nest(&app); Ok(()) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 266733abe..d9b26841f 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -586,6 +586,15 @@ pub fn run() { eprintln!("buzz-desktop: failed to create nest: {error}"); } + // Carry the agent's knowledge from the legacy nest (~/.sprout) into + // the live nest (~/.buzz) after it exists. Must run after + // ensure_nest() so the destination is present. Non-fatal. + // On a real migration, emit a one-time hint so the user can delete + // the now-inert ~/.sprout; the frontend dedupes the toast. + if migration::migrate_legacy_nest() { + let _ = app_handle.emit("legacy-nest-migrated", ()); + } + // Create/update the local CLI symlink pointing to the // bundled CLI binary. Non-fatal: agents find CLI via PATH. if let Ok(exe) = std::env::current_exe() { diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index d22598ed8..18d6af277 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -9,6 +9,7 @@ mod personas; mod process_lifecycle; #[cfg(feature = "mesh-llm")] mod relay_mesh; +mod repos; mod restore; mod runtime; mod storage; @@ -26,6 +27,7 @@ pub use personas::*; pub use process_lifecycle::*; #[cfg(feature = "mesh-llm")] pub use relay_mesh::*; +pub use repos::{ensure_repos_symlink, validate_repos_dir}; pub use restore::*; pub use runtime::*; pub use storage::*; diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 8b1f3c62d..9fec60d6d 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -20,19 +20,22 @@ use tauri::{AppHandle, Manager}; use crate::managed_agents::discovery::known_skill_dirs; /// Subdirectories created inside the nest. +/// `REPOS` is intentionally absent: it is provisioned by +/// [`super::repos::ensure_repos_symlink`], which makes it either a real directory (default) +/// or a symlink to a user-configured `repos_dir`. Creating it here +/// unconditionally would race a future symlink re-point. const NEST_DIRS: &[&str] = &[ "GUIDES", "RESEARCH", "PLANS", "WORK_LOGS", - "REPOS", "OUTBOX", ".scratch", ]; /// Default AGENTS.md content written on first init. /// Fully static — no runtime interpolation, no secrets, no user paths. -const AGENTS_MD: &str = include_str!("nest_agents.md"); +pub(crate) const AGENTS_MD: &str = include_str!("nest_agents.md"); /// Default SKILL.md content for the buzz-cli skill. /// Written to ~/.buzz/.agents/skills/buzz-cli/SKILL.md on first init. @@ -109,6 +112,11 @@ pub fn ensure_nest_at(root: &Path) -> Result<(), String> { fs::create_dir_all(&path).map_err(|e| format!("create {}: {e}", path.display()))?; } + // REPOS is provisioned separately from NEST_DIRS: it may be a symlink to a + // user-configured repos_dir (applied later via apply_workspace), so setup + // must not clobber an existing configured symlink. See repos.rs. + super::repos::ensure_repos_setup_default(root)?; + // Write AGENTS.md only if it doesn't already exist. // Uses create_new (O_CREAT|O_EXCL) to atomically check-and-create, // closing the TOCTOU gap that exists() + write() would leave open. @@ -184,6 +192,18 @@ pub fn ensure_nest_at(root: &Path) -> Result<(), String> { .map_err(|e| format!("set permissions on {}: {e}", path.display()))?; } } + // REPOS is provisioned outside NEST_DIRS (it may be a symlink). Only + // chmod it when it is a real directory — chmod on a symlink would + // affect the user's external repos_dir target. + let repos_path = root.join("REPOS"); + let repos_is_symlink = repos_path + .symlink_metadata() + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false); + if !repos_is_symlink { + fs::set_permissions(&repos_path, perms.clone()) + .map_err(|e| format!("set permissions on {}: {e}", repos_path.display()))?; + } // Skill directory trees inside root get 700. // Build the list from canonical path + all known provider skill dirs. let mut skill_perm_dirs = Vec::new(); @@ -618,6 +638,9 @@ mod tests { for dir in NEST_DIRS { assert!(root.join(dir).is_dir(), "{dir}/ should exist"); } + // REPOS is provisioned separately (may be a symlink); with no + // repos_dir configured it lands as a real directory. + assert!(root.join("REPOS").is_dir(), "REPOS/ should exist"); // AGENTS.md was written with default content. let content = fs::read_to_string(root.join("AGENTS.md")).unwrap(); @@ -633,6 +656,12 @@ mod tests { let mode = fs::metadata(root.join(dir)).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o700, "{dir}/ should be 700"); } + let repos_mode = fs::metadata(root.join("REPOS")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(repos_mode, 0o700, "REPOS/ should be 700"); } } diff --git a/desktop/src-tauri/src/managed_agents/nest_agents.md b/desktop/src-tauri/src/managed_agents/nest_agents.md index 427018be7..7cb7489b8 100644 --- a/desktop/src-tauri/src/managed_agents/nest_agents.md +++ b/desktop/src-tauri/src/managed_agents/nest_agents.md @@ -11,7 +11,7 @@ Your persistent workspace. Created once by the Buzz desktop app. The static cont | `RESEARCH/` | Findings, notes, and reference material | | `WORK_LOGS/` | Session logs — what was tried, learned, decided | | `OUTBOX/` | Shareable docs for external readers (no frontmatter) | -| `REPOS/` | Cloned repositories (clone freely here for exploration) | +| `REPOS/` | Source checkouts. Work in an existing local checkout when one exists; clone here only when none does | | `.scratch/` | Temporary working files — treat as disposable between sessions | Filenames: `ALL_CAPS_WITH_UNDERSCORES.md` (e.g., `OAUTH_FLOW_NOTES.md`). diff --git a/desktop/src-tauri/src/managed_agents/repos.rs b/desktop/src-tauri/src/managed_agents/repos.rs new file mode 100644 index 000000000..dd81950c4 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/repos.rs @@ -0,0 +1,398 @@ +//! Per-workspace `REPOS` directory provisioning. +//! +//! The nest's `REPOS` directory is either a real directory (the default) or a +//! symlink to a user-configured `repos_dir`, letting agents work in existing +//! local checkouts instead of re-cloning. [`ensure_repos_symlink`] reconciles +//! `REPOS` with the configured path; [`validate_repos_dir`] guards the input. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +/// Validate a user-supplied `repos_dir`, returning the canonical target path. +/// +/// Requires an **existing absolute directory**. Rejects relative paths, +/// `~`-prefixed paths (shell tilde is not expanded by `std::fs` — the FE +/// expands before save, so a `~` reaching here is a bug to surface loudly), +/// non-directories, and a path that is the nest itself or an ancestor of it +/// (symlinking `REPOS` into its own parent would create a cycle). Never +/// creates the target — a typo must not silently mint a stray directory. +pub fn validate_repos_dir(nest_root: &Path, repos_dir: &str) -> Result { + let trimmed = repos_dir.trim(); + if trimmed.starts_with('~') { + return Err(format!( + "repos dir must be an absolute path (got `{trimmed}`); use e.g. /Users/you/Development" + )); + } + let target = Path::new(trimmed); + if !target.is_absolute() { + return Err(format!( + "repos dir must be an absolute path (got `{trimmed}`)" + )); + } + // Resolve symlinks/`..` so the directory check and ancestor check both + // operate on the real location. Fails loudly on a missing or unreadable + // path rather than falling back to a real REPOS dir. + let canonical = target + .canonicalize() + .map_err(|e| format!("repos dir `{trimmed}` is not accessible: {e}"))?; + if !canonical.is_dir() { + return Err(format!("repos dir `{trimmed}` is not a directory")); + } + // Refuse the nest itself or any ancestor of it — pointing REPOS there + // would nest the symlink inside its own target. + if let Ok(nest_canonical) = nest_root.canonicalize() { + if nest_canonical == canonical || nest_canonical.starts_with(&canonical) { + return Err(format!( + "repos dir `{trimmed}` is the nest or an ancestor of it; choose a separate directory" + )); + } + } + Ok(canonical) +} + +/// Ensure `nest_root/REPOS` matches the configured `repos_dir`. +/// +/// - **`repos_dir` = `None`/empty** → ensure `REPOS` is a real in-nest +/// directory (the default). A pre-existing symlink (from a prior +/// `repos_dir`) is removed first so clearing the field genuinely reverts; +/// removing a symlink never touches its target. Idempotent otherwise. +/// - **`repos_dir` set, `REPOS` absent** → create a symlink to the target. +/// - **`repos_dir` set, `REPOS` is a symlink** (any target) → replace it +/// (`remove_file` + re-symlink). Removing a symlink never touches the +/// target's contents, so this is data-safe. +/// - **`repos_dir` set, `REPOS` is an empty real dir** → remove it and +/// symlink. Converting an empty dir loses nothing. +/// - **`repos_dir` set, `REPOS` is a NON-EMPTY real dir** → refuse and warn. +/// Never `remove_dir_all` — that would destroy repos the agent cloned +/// in-nest. The user must clear or relocate them first. +/// +/// Validation (`validate_repos_dir`) runs before any filesystem mutation, so +/// an invalid path returns `Err` with `REPOS` left exactly as it was. +#[cfg(unix)] +pub fn ensure_repos_symlink(nest_root: &Path, repos_dir: Option<&str>) -> Result<(), String> { + let repos_path = nest_root.join("REPOS"); + + let Some(target) = repos_dir + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|raw| validate_repos_dir(nest_root, raw)) + .transpose()? + else { + // No repos_dir: REPOS must be a real in-nest directory. If it is + // currently a symlink (from a prior repos_dir), remove the link first + // — create_dir_all follows a symlink and would leave the stale link in + // place. remove_file never touches the link's target. + if let Ok(meta) = repos_path.symlink_metadata() { + if meta.file_type().is_symlink() { + fs::remove_file(&repos_path) + .map_err(|e| format!("remove symlink {}: {e}", repos_path.display()))?; + } + } + fs::create_dir_all(&repos_path) + .map_err(|e| format!("create {}: {e}", repos_path.display()))?; + return Ok(()); + }; + + match repos_path.symlink_metadata() { + // Existing symlink → replace it if it points elsewhere. Re-pointing a + // symlink is data-safe; remove_file never follows the link. + Ok(meta) if meta.file_type().is_symlink() => { + if repos_path.read_link().ok().as_deref() == Some(target.as_path()) { + return Ok(()); // already correct + } + fs::remove_file(&repos_path) + .map_err(|e| format!("remove symlink {}: {e}", repos_path.display()))?; + symlink_repos(&target, &repos_path) + } + // Existing real directory → convert only if empty; otherwise refuse. + Ok(meta) if meta.is_dir() => { + let empty = fs::read_dir(&repos_path) + .map_err(|e| format!("read {}: {e}", repos_path.display()))? + .next() + .is_none(); + if !empty { + return Err(format!( + "{} holds repositories; move or delete them before pointing repos dir elsewhere", + repos_path.display() + )); + } + fs::remove_dir(&repos_path) + .map_err(|e| format!("remove {}: {e}", repos_path.display()))?; + symlink_repos(&target, &repos_path) + } + // Exists but is neither symlink nor dir (e.g. a file) → refuse. + Ok(_) => Err(format!( + "{} exists and is not a directory; cannot point repos dir there", + repos_path.display() + )), + // Absent → create the symlink. + Err(e) if e.kind() == io::ErrorKind::NotFound => symlink_repos(&target, &repos_path), + Err(e) => Err(format!("stat {}: {e}", repos_path.display())), + } +} + +#[cfg(unix)] +fn symlink_repos(target: &Path, link: &Path) -> Result<(), String> { + std::os::unix::fs::symlink(target, link) + .map_err(|e| format!("symlink {} → {}: {e}", link.display(), target.display())) +} + +#[cfg(not(unix))] +pub fn ensure_repos_symlink(nest_root: &Path, _repos_dir: Option<&str>) -> Result<(), String> { + let repos_path = nest_root.join("REPOS"); + fs::create_dir_all(&repos_path).map_err(|e| format!("create {}: {e}", repos_path.display())) +} + +/// Provision `REPOS` at nest setup, before any configured `repos_dir` is known. +/// +/// Leaves an existing symlink untouched — `apply_workspace` is the sole +/// authority over a configured symlink. Clearing it here with `None` would +/// destroy a symlink restored from a prior session; async-restored agents +/// would then write into the fresh real dir, and the later FE re-point would +/// refuse the now-non-empty REPOS — silently breaking `repos_dir` on restart. +/// Otherwise (absent, or a real dir) lands the default real-dir fallback. +pub fn ensure_repos_setup_default(nest_root: &Path) -> Result<(), String> { + let repos_path = nest_root.join("REPOS"); + let is_symlink = repos_path + .symlink_metadata() + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false); + if is_symlink { + return Ok(()); + } + ensure_repos_symlink(nest_root, None) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── ensure_repos_symlink ────────────────────────────────────────────── + + #[cfg(unix)] + #[test] + fn ensure_repos_symlink_none_creates_real_dir() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + + ensure_repos_symlink(&root, None).unwrap(); + + let repos = root.join("REPOS"); + assert!(repos.is_dir(), "REPOS should be a real directory"); + assert!( + !repos.symlink_metadata().unwrap().file_type().is_symlink(), + "REPOS should not be a symlink when repos_dir is None" + ); + } + + #[cfg(unix)] + #[test] + fn ensure_repos_symlink_none_reverts_existing_symlink_to_real_dir() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + let external = tmp.path().join("Development"); + fs::create_dir_all(&external).unwrap(); + let payload = external.join("KEEP.md"); + fs::write(&payload, "data").unwrap(); + + // First point REPOS at the external dir, then clear the field. + ensure_repos_symlink(&root, Some(external.to_str().unwrap())).unwrap(); + ensure_repos_symlink(&root, None).unwrap(); + + let repos = root.join("REPOS"); + assert!( + repos.is_dir(), + "REPOS should be a real directory after clear" + ); + assert!( + !repos.symlink_metadata().unwrap().file_type().is_symlink(), + "REPOS should no longer be a symlink after clearing repos_dir" + ); + assert!( + payload.exists(), + "clearing repos_dir must not touch the external target's contents" + ); + } + + #[cfg(unix)] + #[test] + fn ensure_repos_symlink_absent_creates_symlink() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + let external = tmp.path().join("Development"); + fs::create_dir_all(&external).unwrap(); + + ensure_repos_symlink(&root, Some(external.to_str().unwrap())).unwrap(); + + let repos = root.join("REPOS"); + assert!(repos.symlink_metadata().unwrap().file_type().is_symlink()); + assert_eq!(repos.read_link().unwrap(), external.canonicalize().unwrap()); + } + + #[cfg(unix)] + #[test] + fn ensure_repos_symlink_repoints_existing_wrong_symlink() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + let old = tmp.path().join("old"); + let new = tmp.path().join("new"); + fs::create_dir_all(&old).unwrap(); + fs::create_dir_all(&new).unwrap(); + let payload = old.join("KEEP.md"); + fs::write(&payload, "data").unwrap(); + + ensure_repos_symlink(&root, Some(old.to_str().unwrap())).unwrap(); + ensure_repos_symlink(&root, Some(new.to_str().unwrap())).unwrap(); + + let repos = root.join("REPOS"); + assert_eq!(repos.read_link().unwrap(), new.canonicalize().unwrap()); + assert!( + payload.exists(), + "re-pointing a symlink must not touch the old target's contents" + ); + } + + #[cfg(unix)] + #[test] + fn ensure_repos_symlink_correct_symlink_is_noop() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + let external = tmp.path().join("Development").canonicalize_or_make(); + + ensure_repos_symlink(&root, Some(external.to_str().unwrap())).unwrap(); + ensure_repos_symlink(&root, Some(external.to_str().unwrap())).unwrap(); + + let repos = root.join("REPOS"); + assert_eq!(repos.read_link().unwrap(), external); + } + + #[cfg(unix)] + #[test] + fn ensure_repos_symlink_empty_real_dir_converts() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(root.join("REPOS")).unwrap(); + let external = tmp.path().join("Development"); + fs::create_dir_all(&external).unwrap(); + + ensure_repos_symlink(&root, Some(external.to_str().unwrap())).unwrap(); + + let repos = root.join("REPOS"); + assert!( + repos.symlink_metadata().unwrap().file_type().is_symlink(), + "an empty real REPOS should convert to a symlink" + ); + } + + #[cfg(unix)] + #[test] + fn ensure_repos_symlink_nonempty_real_dir_refuses_and_preserves() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + let repos = root.join("REPOS"); + fs::create_dir_all(&repos).unwrap(); + let checkout = repos.join("buzz"); + fs::create_dir_all(&checkout).unwrap(); + fs::write(checkout.join("code.rs"), "fn main() {}").unwrap(); + let external = tmp.path().join("Development"); + fs::create_dir_all(&external).unwrap(); + + let result = ensure_repos_symlink(&root, Some(external.to_str().unwrap())); + + assert!(result.is_err(), "non-empty real REPOS must refuse"); + assert!( + !repos.symlink_metadata().unwrap().file_type().is_symlink(), + "refused REPOS must stay a real directory" + ); + assert!( + checkout.join("code.rs").exists(), + "refusal must never delete existing repositories" + ); + } + + // ensure_nest_at must NOT clobber an existing REPOS symlink on startup. + // Regression guard for Finding 1: the startup `ensure_repos_symlink(_, None)` + // call used to remove a configured symlink and mint an empty real REPOS, + // which async-restored agents could write into — the FE re-point then + // refused the now-non-empty dir, silently breaking a configured repos_dir. + #[cfg(unix)] + #[test] + fn ensure_nest_startup_preserves_existing_repos_symlink() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + + // First launch creates the real nest with a real REPOS dir. + crate::managed_agents::ensure_nest_at(&root).unwrap(); + + // Simulate a configured repos_dir: REPOS points at an external dir + // holding agent checkouts. + let external = tmp.path().join("Development"); + fs::create_dir(&external).unwrap(); + fs::write(external.join("KEEP.md"), "data").unwrap(); + fs::remove_dir(root.join("REPOS")).unwrap(); + std::os::unix::fs::symlink(&external, root.join("REPOS")).unwrap(); + + // Next launch must leave the configured symlink intact. + crate::managed_agents::ensure_nest_at(&root).unwrap(); + + let repos = root.join("REPOS"); + assert!( + repos.symlink_metadata().unwrap().file_type().is_symlink(), + "an existing REPOS symlink must survive startup" + ); + assert_eq!(repos.read_link().unwrap(), external); + assert!( + external.join("KEEP.md").exists(), + "the symlink's target contents must be untouched" + ); + } + + #[cfg(unix)] + #[test] + fn validate_repos_dir_rejects_tilde_relative_and_missing() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + + assert!(validate_repos_dir(&root, "~/Development").is_err()); + assert!(validate_repos_dir(&root, "relative/path").is_err()); + assert!(validate_repos_dir(&root, "/no/such/dir/here").is_err()); + + let file = tmp.path().join("afile"); + fs::write(&file, "x").unwrap(); + assert!(validate_repos_dir(&root, file.to_str().unwrap()).is_err()); + } + + #[cfg(unix)] + #[test] + fn validate_repos_dir_rejects_nest_ancestor() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("home").join(".buzz"); + fs::create_dir_all(&root).unwrap(); + let parent = root.parent().unwrap(); + + assert!( + validate_repos_dir(&root, parent.to_str().unwrap()).is_err(), + "a parent of the nest would nest REPOS inside its own target" + ); + } + + /// Test helper: canonicalize a path, creating it as a directory first. + #[cfg(unix)] + trait CanonicalizeOrMake { + fn canonicalize_or_make(&self) -> PathBuf; + } + #[cfg(unix)] + impl CanonicalizeOrMake for PathBuf { + fn canonicalize_or_make(&self) -> PathBuf { + fs::create_dir_all(self).unwrap(); + self.canonicalize().unwrap() + } + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b0470bd70..970ea808d 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1783,7 +1783,7 @@ fn child_rust_log_filter() -> String { /// Databricks host/model baked in at compile time for internal builds. Empty /// in OSS builds, where the `BUZZ_BUILD_DATABRICKS_*` env is unset. -fn build_databricks_defaults() -> Vec<(&'static str, &'static str)> { +pub(crate) fn build_databricks_defaults() -> Vec<(&'static str, &'static str)> { let mut defaults = Vec::new(); if let Some(host) = option_env!("BUZZ_DESKTOP_BUILD_DATABRICKS_HOST") { if !host.is_empty() { @@ -1915,7 +1915,7 @@ pub fn stop_managed_agent_process( /// switching need the initial bootstrap value. Provider injection is skipped /// when `provider_locked` is true (e.g. Claude runtimes that only work with /// Anthropic). -fn runtime_metadata_env_vars<'a>( +pub(crate) fn runtime_metadata_env_vars<'a>( model_env_var: Option<&'a str>, provider_env_var: Option<&'a str>, provider_locked: bool, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 51761a0f9..ba74c9c16 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -88,6 +88,9 @@ pub struct ManagedAgentRecord { pub name: String, #[serde(default)] pub persona_id: Option, + /// `#[serde(default)]` so an old build still parses a store whose inline + /// key was stripped after a keyring build migrated it into the Keychain. + #[serde(default)] pub private_key_nsec: String, /// NIP-OA auth tag JSON. Computed at agent creation time. /// @@ -477,6 +480,8 @@ pub struct AgentModelsResponse { pub selected_model: Option, /// Whether this agent supports model switching. pub supports_switching: bool, + /// Human-readable setup issue that prevents model discovery. + pub configuration_error: Option, } /// A single model available from an agent. diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 8f7f495bf..b6d3262d8 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -133,6 +133,134 @@ pub fn migrate_legacy_app_data_dir(app: &tauri::AppHandle) { } } +/// Knowledge directories and files carried from the legacy nest into the live +/// nest. Deliberately excludes `REPOS/`: cloned repositories are re-clonable by +/// definition (Will's stranded `REPOS/` measured 62 GB of checkouts plus build +/// artifacts), so copying them would block desktop startup for minutes on every +/// cold launch while recovering nothing the agent "remembers". Agents re-clone +/// what they need into the live nest. The agent's accumulated knowledge — notes, +/// plans, logs — is what must survive the rename, and it totals a few hundred KB. +/// +/// All entries are plain files or directories of plain files on the observed +/// disk, so `copy_dir_all`'s symlink branch is not exercised. This is a +/// content-dependent property, not a structural guarantee: `copy_dir_all` +/// recurses with `symlink_metadata`, so a symlink later dropped into one of +/// these dirs (e.g. by a skill writing into `.scratch/`) would hit that branch's +/// clobber/abort hazard. The per-entry log-and-continue below bounds the blast +/// radius of such a failure to the single offending entry. +const LEGACY_NEST_KNOWLEDGE: &[&str] = &[ + "AGENTS.md", + "RESEARCH", + "PLANS", + "GUIDES", + "WORK_LOGS", + "OUTBOX", + ".scratch", +]; + +/// Migrate the legacy agent nest (`~/.sprout`) into the current nest (`~/.buzz`). +/// +/// PR #960 renamed the nest directory but shipped no migration, stranding the +/// agent's accumulated knowledge in `~/.sprout` while `~/.buzz` booted empty — +/// so agents searched `$HOME` for files they "remembered", triggering macOS TCC +/// prompts. This copies only the knowledge directories (see +/// [`LEGACY_NEST_KNOWLEDGE`]), never `REPOS/`. +/// +/// Non-fatal and idempotent, mirroring [`migrate_legacy_app_data_dir`]: a copy +/// error is logged and never aborts startup. There is no completion sentinel — +/// the migration re-runs on every launch while `~/.sprout` exists, which is +/// cheap because the copy is tiny and `copy_dir_all` skips files that already +/// exist in the destination. This relies on `REPOS/` being out of scope; if it +/// is ever added back, a sentinel or off-thread copy becomes mandatory. +/// +/// Returns `true` when a legacy `~/.sprout` nest was present (migration ran), +/// so the caller can emit a one-time hint inviting the user to delete it. The +/// frontend dedupes the hint, so re-firing while `~/.sprout` lingers is benign. +pub fn migrate_legacy_nest() -> bool { + let Some(home) = dirs::home_dir() else { + eprintln!("buzz-desktop: nest-migration: cannot resolve home directory"); + return false; + }; + migrate_legacy_nest_at(&home.join(".sprout"), &home.join(".buzz")) +} + +/// Copy the [`LEGACY_NEST_KNOWLEDGE`] entries from `legacy` to `current`. +/// +/// Each entry is copied independently with its own log-and-continue, so a +/// failure on one entry never skips the rest. No-ops cleanly when `legacy` is +/// absent or an entry does not exist. Returns `true` when `legacy` existed. +fn migrate_legacy_nest_at(legacy: &Path, current: &Path) -> bool { + if !legacy.exists() { + return false; + } + for name in LEGACY_NEST_KNOWLEDGE { + let src = legacy.join(name); + if !src.exists() { + continue; + } + let dst = current.join(name); + let result = if src.is_dir() { + copy_dir_all(&src, &dst) + } else if *name == "AGENTS.md" { + // `ensure_nest` writes a default `~/.buzz/AGENTS.md` before this + // migration runs, so the plain absent-only guard would always skip + // the legacy file and strand the user's instructions. Overwrite the + // destination only when it is still the untouched generated default; + // a user-edited file is left alone. + copy_file_over_generated_default(&src, &dst) + } else { + copy_file_if_absent(&src, &dst) + }; + match result { + Ok(()) => eprintln!( + "buzz-desktop: nest-migration: migrated {} to {}", + src.display(), + dst.display() + ), + Err(error) => eprintln!( + "buzz-desktop: nest-migration: failed to migrate {} to {}: {error}", + src.display(), + dst.display() + ), + } + } + true +} + +/// Copy a single file only if the destination does not already exist, matching +/// `copy_dir_all`'s non-destructive guard for top-level files (e.g. `AGENTS.md`). +fn copy_file_if_absent(src: &Path, dst: &Path) -> std::io::Result<()> { + if dst.exists() { + return Ok(()); + } + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(src, dst).map(|_| ()) +} + +/// Copy `src` over `dst` when `dst` is absent or still the untouched generated +/// default `AGENTS.md` (byte-equal to the embedded template). A user-edited +/// destination — or an older default left by a since-bumped template — is +/// preserved. +/// +/// On a first-time migration `ensure_nest` has just written the generated +/// default, so `copy_file_if_absent` would always skip the legacy file and +/// strand the user's instructions. This lets the legacy `AGENTS.md` win over +/// that pristine default while never clobbering content a user has changed. +fn copy_file_over_generated_default(src: &Path, dst: &Path) -> std::io::Result<()> { + if dst.exists() { + let current = std::fs::read_to_string(dst)?; + if current != crate::managed_agents::AGENTS_MD { + return Ok(()); + } + } + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(src, dst).map(|_| ()) +} + /// Read a JSON array of objects from `path`, apply `f` to each object, /// and write back if any mutation returned `true`. fn patch_json_records( diff --git a/desktop/src-tauri/src/migration_tests.rs b/desktop/src-tauri/src/migration_tests.rs index 0687d852d..4a79ae02b 100644 --- a/desktop/src-tauri/src/migration_tests.rs +++ b/desktop/src-tauri/src/migration_tests.rs @@ -842,3 +842,142 @@ fn reconcile_mcp_commands_skips_record_without_agent_command() { reconcile_mcp_commands_in_file(&path); assert_eq!(before, std::fs::read_to_string(&path).unwrap()); } + +#[test] +fn migrate_legacy_nest_carries_knowledge_and_skips_repos() { + let dir = tempfile::tempdir().unwrap(); + let legacy = dir.path().join(".sprout"); + let current = dir.path().join(".buzz"); + + // Knowledge: a top-level file plus a nested dir. + std::fs::create_dir_all(legacy.join("RESEARCH")).unwrap(); + std::fs::write(legacy.join("AGENTS.md"), "agents").unwrap(); + std::fs::write(legacy.join("RESEARCH/NOTES.md"), "notes").unwrap(); + // A fat REPOS/ that must NOT be copied. + std::fs::create_dir_all(legacy.join("REPOS/buzz")).unwrap(); + std::fs::write(legacy.join("REPOS/buzz/huge.bin"), "checkout").unwrap(); + + let migrated = super::migrate_legacy_nest_at(&legacy, ¤t); + + assert!(migrated, "migration ran because legacy nest existed"); + assert!( + !current.join("REPOS").exists(), + "REPOS/ must never be migrated" + ); + assert_eq!( + std::fs::read_to_string(current.join("AGENTS.md")).unwrap(), + "agents" + ); + assert_eq!( + std::fs::read_to_string(current.join("RESEARCH/NOTES.md")).unwrap(), + "notes" + ); +} + +#[test] +fn migrate_legacy_nest_does_not_clobber_existing_destination() { + let dir = tempfile::tempdir().unwrap(); + let legacy = dir.path().join(".sprout"); + let current = dir.path().join(".buzz"); + + std::fs::create_dir_all(legacy.join("RESEARCH")).unwrap(); + std::fs::write(legacy.join("AGENTS.md"), "legacy-agents").unwrap(); + std::fs::write(legacy.join("RESEARCH/NOTES.md"), "legacy-notes").unwrap(); + // Pre-existing live content the migration must preserve. + std::fs::create_dir_all(current.join("RESEARCH")).unwrap(); + std::fs::write(current.join("AGENTS.md"), "live-agents").unwrap(); + std::fs::write(current.join("RESEARCH/NOTES.md"), "live-notes").unwrap(); + + super::migrate_legacy_nest_at(&legacy, ¤t); + + assert_eq!( + std::fs::read_to_string(current.join("AGENTS.md")).unwrap(), + "live-agents", + "existing top-level file must not be clobbered" + ); + assert_eq!( + std::fs::read_to_string(current.join("RESEARCH/NOTES.md")).unwrap(), + "live-notes", + "existing nested file must not be clobbered" + ); +} + +#[test] +fn migrate_legacy_nest_is_idempotent_on_rerun() { + let dir = tempfile::tempdir().unwrap(); + let legacy = dir.path().join(".sprout"); + let current = dir.path().join(".buzz"); + + std::fs::create_dir_all(legacy.join("PLANS")).unwrap(); + std::fs::write(legacy.join("PLANS/PLAN.md"), "plan").unwrap(); + + super::migrate_legacy_nest_at(&legacy, ¤t); + super::migrate_legacy_nest_at(&legacy, ¤t); + + assert_eq!( + std::fs::read_to_string(current.join("PLANS/PLAN.md")).unwrap(), + "plan" + ); +} + +#[test] +fn migrate_legacy_nest_noops_when_legacy_absent() { + let dir = tempfile::tempdir().unwrap(); + let legacy = dir.path().join(".sprout"); + let current = dir.path().join(".buzz"); + + let migrated = super::migrate_legacy_nest_at(&legacy, ¤t); + + assert!(!migrated, "no migration when legacy nest is absent"); + assert!( + !current.exists(), + "no destination created when legacy absent" + ); +} + +#[test] +fn migrate_legacy_nest_overwrites_generated_default_agents_md() { + let dir = tempfile::tempdir().unwrap(); + let legacy = dir.path().join(".sprout"); + let current = dir.path().join(".buzz"); + + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(legacy.join("AGENTS.md"), "legacy team instructions").unwrap(); + + // First-time launch order: ensure_nest writes the generated default into + // ~/.buzz/AGENTS.md, then migration runs. + crate::managed_agents::ensure_nest_at(¤t).unwrap(); + assert_eq!( + std::fs::read_to_string(current.join("AGENTS.md")).unwrap(), + crate::managed_agents::AGENTS_MD, + "precondition: ensure_nest writes the generated default" + ); + + super::migrate_legacy_nest_at(&legacy, ¤t); + + assert_eq!( + std::fs::read_to_string(current.join("AGENTS.md")).unwrap(), + "legacy team instructions", + "legacy AGENTS.md must overwrite the untouched generated default" + ); +} + +#[test] +fn migrate_legacy_nest_preserves_user_edited_agents_md() { + let dir = tempfile::tempdir().unwrap(); + let legacy = dir.path().join(".sprout"); + let current = dir.path().join(".buzz"); + + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(legacy.join("AGENTS.md"), "legacy team instructions").unwrap(); + std::fs::create_dir_all(¤t).unwrap(); + std::fs::write(current.join("AGENTS.md"), "user-edited live AGENTS").unwrap(); + + super::migrate_legacy_nest_at(&legacy, ¤t); + + assert_eq!( + std::fs::read_to_string(current.join("AGENTS.md")).unwrap(), + "user-edited live AGENTS", + "a user-edited live AGENTS.md must never be clobbered" + ); +} diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 99007d0c4..dc6f03ca8 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -15,6 +15,7 @@ pub struct ProfileInfo { pub avatar_url: Option, pub about: Option, pub nip05_handle: Option, + pub owner_pubkey: Option, } #[derive(Serialize, Deserialize)] @@ -22,6 +23,7 @@ pub struct UserProfileSummaryInfo { pub display_name: Option, pub avatar_url: Option, pub nip05_handle: Option, + pub owner_pubkey: Option, #[serde(default)] pub is_agent: bool, } @@ -38,6 +40,7 @@ pub struct UserSearchResultInfo { pub display_name: Option, pub avatar_url: Option, pub nip05_handle: Option, + pub owner_pubkey: Option, #[serde(default)] pub is_agent: bool, } diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index aeb7f8c59..0ef035f48 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -55,16 +55,16 @@ fn tags_named<'a>(event: &'a Event, name: &'a str) -> impl Iterator bool { +pub(crate) fn profile_valid_oa_owner_pubkey(event: &Event) -> Option { let target_hex = event.pubkey.to_hex(); let Ok(target_pubkey) = nostr::PublicKey::from_hex(&target_hex) else { - return false; + return None; }; for tag in event.tags.iter() { @@ -75,12 +75,16 @@ pub(crate) fn profile_has_valid_oa_owner(event: &Event) -> bool { let Ok(json) = serde_json::to_string(slice) else { continue; }; - if buzz_sdk_pkg::nip_oa::verify_auth_tag(&json, &target_pubkey).is_ok() { - return true; + if let Ok(owner_pubkey) = buzz_sdk_pkg::nip_oa::verify_auth_tag(&json, &target_pubkey) { + return Some(owner_pubkey.to_hex()); } } - false + None +} + +pub(crate) fn profile_has_valid_oa_owner(event: &Event) -> bool { + profile_valid_oa_owner_pubkey(event).is_some() } // ── kind:39000 / 39002 (NIP-29) ───────────────────────────────────────────── @@ -299,6 +303,7 @@ pub fn profile_info_from_event(event: &Event) -> Result { avatar_url, about, nip05_handle, + owner_pubkey: profile_valid_oa_owner_pubkey(event), }) } @@ -326,6 +331,7 @@ pub fn users_batch_from_events( let mut profiles = HashMap::new(); for (pk, ev) in &latest { let v: Value = serde_json::from_str(&ev.content).unwrap_or(Value::Null); + let owner_pubkey = profile_valid_oa_owner_pubkey(ev); let summary = UserProfileSummaryInfo { display_name: v .get("display_name") @@ -334,7 +340,8 @@ pub fn users_batch_from_events( .map(str::to_string), avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string), nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string), - is_agent: profile_has_valid_oa_owner(ev), + is_agent: owner_pubkey.is_some(), + owner_pubkey, }; profiles.insert(pk.clone(), summary); } @@ -586,7 +593,7 @@ mod tests { } /// Build a kind:0 profile with a valid NIP-OA auth tag. - fn oa_profile_event(content: &str) -> Event { + fn oa_profile_event(content: &str) -> (Event, String) { let agent_keys = Keys::generate(); let owner_keys = Keys::generate(); let agent_pubkey = agent_keys.public_key(); @@ -595,10 +602,11 @@ mod tests { let tag_values: Vec = serde_json::from_str(&tag_json).expect("parse auth tag json"); let auth_tag = Tag::parse(tag_values).expect("parse auth tag"); - EventBuilder::new(Kind::Metadata, content) + let event = EventBuilder::new(Kind::Metadata, content) .tags(vec![auth_tag]) .sign_with_keys(&agent_keys) - .expect("sign") + .expect("sign"); + (event, owner_keys.public_key().to_hex()) } #[test] @@ -760,6 +768,15 @@ mod tests { assert_eq!(p.about.as_deref(), Some("hi")); assert_eq!(p.nip05_handle.as_deref(), Some("alice@x")); assert_eq!(p.pubkey, e.pubkey.to_hex()); + assert!(p.owner_pubkey.is_none()); + } + + #[test] + fn profile_info_extracts_valid_nip_oa_owner() { + let (event, owner_pubkey) = oa_profile_event(r#"{"display_name":"Mira"}"#); + let p = profile_info_from_event(&event).unwrap(); + + assert_eq!(p.owner_pubkey.as_deref(), Some(owner_pubkey.as_str())); } #[test] @@ -803,12 +820,16 @@ mod tests { #[test] fn users_batch_marks_valid_nip_oa_profiles_as_agents() { - let agent = oa_profile_event(r#"{"display_name":"Mira"}"#); + let (agent, owner_pubkey) = oa_profile_event(r#"{"display_name":"Mira"}"#); let pubkey = agent.pubkey.to_hex(); let resp = users_batch_from_events(std::slice::from_ref(&agent), std::slice::from_ref(&pubkey)); assert!(resp.profiles[&pubkey].is_agent); + assert_eq!( + resp.profiles[&pubkey].owner_pubkey.as_deref(), + Some(owner_pubkey.as_str()) + ); } #[test] diff --git a/desktop/src-tauri/src/nostr_convert/user_search.rs b/desktop/src-tauri/src/nostr_convert/user_search.rs index 85a68df14..c170d895c 100644 --- a/desktop/src-tauri/src/nostr_convert/user_search.rs +++ b/desktop/src-tauri/src/nostr_convert/user_search.rs @@ -5,11 +5,12 @@ use serde_json::Value; use crate::models::{SearchUsersResponse, UserSearchResultInfo}; -use super::profile_has_valid_oa_owner; +use super::profile_valid_oa_owner_pubkey; /// Convert a single kind:0 event to a [`UserSearchResultInfo`]. pub fn user_search_result_from_event(ev: &Event) -> UserSearchResultInfo { let v: Value = serde_json::from_str(&ev.content).unwrap_or(Value::Null); + let owner_pubkey = profile_valid_oa_owner_pubkey(ev); UserSearchResultInfo { pubkey: ev.pubkey.to_hex(), display_name: v @@ -19,7 +20,8 @@ pub fn user_search_result_from_event(ev: &Event) -> UserSearchResultInfo { .map(str::to_string), avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string), nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string), - is_agent: profile_has_valid_oa_owner(ev), + is_agent: owner_pubkey.is_some(), + owner_pubkey, } } @@ -224,9 +226,15 @@ mod tests { #[test] fn user_search_result_marks_valid_nip_oa_profile_as_agent() { let event = oa_profile_event(r#"{"display_name":"Mira"}"#); + let owner_pubkey = event + .tags + .iter() + .find_map(|tag| tag.as_slice().get(1).cloned()) + .expect("owner pubkey"); let result = user_search_result_from_event(&event); assert_eq!(result.display_name.as_deref(), Some("Mira")); + assert_eq!(result.owner_pubkey.as_deref(), Some(owner_pubkey.as_str())); assert!(result.is_agent); } diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index d9babe959..1043c8240 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.3.30", + "version": "0.3.31", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index a7a03d806..4be18f502 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -18,6 +18,7 @@ import { OnboardingSlideTransition } from "@/features/onboarding/ui/OnboardingSl import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow"; import type { Workspace } from "@/features/workspaces/types"; import { useWorkspaceInit } from "@/features/workspaces/useWorkspaceInit"; +import { useNestNotifications } from "@/features/workspaces/useNestNotifications"; import { useWorkspaces } from "@/features/workspaces/useWorkspaces"; import { WelcomeSetup } from "@/features/workspaces/ui/WelcomeSetup"; import { createBuzzQueryClient } from "@/shared/api/queryClient"; @@ -242,6 +243,11 @@ export function App() { void unlisten.then((fn) => fn()); }; }, [addWorkspace, switchWorkspace, reconnectWorkspace]); + // Surface nest-related backend events (repos-dir errors, legacy migration) + // as toasts. Mounted before useWorkspaceInit so the listeners are registered + // ahead of the first apply_workspace call. + useNestNotifications(); + // Composite key: changes when workspace ID changes OR when // the active workspace's config is updated (relayUrl/token). const workspaceKey = `${activeWorkspace?.id ?? "none"}-${reinitKey}`; diff --git a/desktop/src/app/AppShell.helpers.test.mjs b/desktop/src/app/AppShell.helpers.test.mjs new file mode 100644 index 000000000..73505d179 --- /dev/null +++ b/desktop/src/app/AppShell.helpers.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { shouldBounceForChannelNotification } from "./AppShell.helpers.ts"; + +test("shouldBounceForChannelNotification_allowsTopLevelChannelMessages", () => { + assert.equal(shouldBounceForChannelNotification([["h", "channel"]]), true); +}); + +test("shouldBounceForChannelNotification_suppressesThreadReplies", () => { + assert.equal( + shouldBounceForChannelNotification([ + ["h", "channel"], + ["e", "root", "", "reply"], + ]), + false, + ); +}); + +test("shouldBounceForChannelNotification_allowsBroadcastReplies", () => { + assert.equal( + shouldBounceForChannelNotification([ + ["h", "channel"], + ["e", "root", "", "reply"], + ["broadcast", "1"], + ]), + true, + ); +}); diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index 8ce0b8ca1..7b89240ab 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -1,3 +1,4 @@ +import { isThreadReply } from "@/features/messages/lib/threading"; import type { DesktopNotificationTarget } from "@/features/notifications/lib/desktop"; import type { SearchHit } from "@/shared/api/types"; @@ -25,6 +26,10 @@ export function isWindowDragHandleEvent(event: MouseEvent | PointerEvent) { ); } +export function shouldBounceForChannelNotification(tags: string[][]): boolean { + return !isThreadReply(tags); +} + export function toSearchHit( target: DesktopNotificationTarget, ): SearchHit | null { diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 5507c7245..b4b2ef77b 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -6,6 +6,7 @@ import { Outlet, useLocation } from "@tanstack/react-router"; import { deriveShellRoute, isWindowDragHandleEvent, + shouldBounceForChannelNotification, toSearchHit, } from "@/app/AppShell.helpers"; import { AppShellProvider } from "@/app/AppShellContext"; @@ -52,6 +53,7 @@ import { resolveSlotSound, } from "@/features/notifications/lib/sound"; import { PreventSleepProvider } from "@/features/agents/usePreventSleep"; +import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent"; import { usePresenceSession, usePresenceSubscription, @@ -99,10 +101,8 @@ const LazySettingsScreen = React.lazy(async () => { export function AppShell() { useWebviewZoomShortcuts(); - const workspacesHook = useWorkspaces(); const [isAddWorkspaceOpen, setIsAddWorkspaceOpen] = React.useState(false); - const [isChannelManagementOpen, setIsChannelManagementOpen] = React.useState(false); const [searchFocusRequest, setSearchFocusRequest] = React.useState(0); @@ -142,7 +142,6 @@ export function AppShell() { ) ? locationSearchSection : DEFAULT_SETTINGS_SECTION; - const startupReady = useDeferredStartup(); const identityQuery = useIdentityQuery(); @@ -179,7 +178,8 @@ export function AppShell() { refetchHomeFeedFromLiveSignal, ); const handleChannelNotification = React.useEffectEvent( - (_channelId: string, _event: RelayEvent) => { + (_channelId: string, event: RelayEvent) => { + if (!shouldBounceForChannelNotification(event.tags)) return; if (!notificationSettings.settings.desktopEnabled) return; void requestDockBounce(); }, @@ -390,11 +390,6 @@ export function AppShell() { channels, ); - // Badge count is computed here (rather than inside useHomeFeedNotifications) - // so it can consume the NIP-RS read-state lifted from the single - // ReadStateManager mounted via useUnreadChannels above. Channel-backed - // feed items contribute to the badge iff strictly newer than that - // channel's read marker; non-channel items keep their seen-set fallback. const { homeBadgeCount, homeBadgeCountExcludingHighPriority } = useHomeFeedNotificationState( homeFeedQuery.data, @@ -412,8 +407,6 @@ export function AppShell() { getThreadReadAt, ); - // Raw add to the in-app nav badge, mirroring the inbox filter badge; gated by - // homeBadgeEnabled to match every other badge contribution. const dueReminderBadge = useDueReminderBadgeCount( identityQuery.data?.pubkey, notificationSettings.settings.homeBadgeEnabled, @@ -592,14 +585,18 @@ export function AppShell() { }, []); React.useEffect(() => { - const numericCount = + const count = unreadChannelNotificationCount + homeBadgeCountExcludingHighPriority; - if (numericCount > 0) { - void setDesktopAppBadge({ kind: "count", count: numericCount }); - } else { - void setDesktopAppBadge({ kind: "none" }); - } - }, [homeBadgeCountExcludingHighPriority, unreadChannelNotificationCount]); + void setDesktopAppBadge( + count + ? { kind: "count", count } + : { kind: unreadChannelIds.size ? "dot" : "none" }, + ); + }, [ + homeBadgeCountExcludingHighPriority, + unreadChannelIds, + unreadChannelNotificationCount, + ]); // Dispatch `buzz://message` deep links into the router. useMessageDeepLinks(); @@ -630,12 +627,10 @@ export function AppShell() { }, []); const handleOpenNewDm = React.useCallback(() => setIsNewDmOpen(true), []); - const handleOpenCreateChannel = React.useCallback( () => setIsCreateChannelOpen(true), [], ); - React.useLayoutEffect(() => { if (settingsOpen) { return; @@ -690,13 +685,11 @@ export function AppShell() { goHome, settingsOpen, ]); - useSettingsShortcuts({ onClose: handleCloseSettings, onOpenSettings: handleOpenSettings, open: settingsOpen, }); - useMarkAsReadShortcuts({ activeChannelId: activeChannel?.id ?? null, activeChannelLastMessageAt: activeChannel?.lastMessageAt, @@ -849,6 +842,9 @@ export function AppShell() { onUpdateWorkspace={workspacesHook.updateWorkspace} onRemoveWorkspace={workspacesHook.removeWorkspace} onSwitchWorkspace={workspacesHook.switchWorkspace} + onCreateAgent={() => + void goAgents().then(requestOpenCreateAgent) + } selfPresenceStatus={presenceSession.currentStatus} workspaces={workspacesHook.workspaces} onCreateChannel={async ({ diff --git a/desktop/src/app/AppShellOverlays.tsx b/desktop/src/app/AppShellOverlays.tsx index 44b99a041..514492a29 100644 --- a/desktop/src/app/AppShellOverlays.tsx +++ b/desktop/src/app/AppShellOverlays.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import type { Channel } from "@/shared/api/types"; +import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen"; const ChannelBrowserDialog = React.lazy(async () => { const module = await import("@/features/channels/ui/ChannelBrowserDialog"); @@ -39,17 +40,37 @@ export function AppShellOverlays({ onDeleteActiveChannel, onSelectChannel, }: AppShellOverlaysProps) { + const [visibleBrowseDialogType, setVisibleBrowseDialogType] = + React.useState(null); + const { cancelDeferredModalOpen, openNextFrame: openModalNextFrame } = + useDeferredModalOpen(); + + React.useEffect(() => { + if (browseDialogType === null) { + cancelDeferredModalOpen(); + setVisibleBrowseDialogType(null); + return; + } + + setVisibleBrowseDialogType(null); + openModalNextFrame(() => { + setVisibleBrowseDialogType(browseDialogType); + }); + }, [browseDialogType, cancelDeferredModalOpen, openModalNextFrame]); + + const renderedBrowseDialogType = visibleBrowseDialogType ?? browseDialogType; + return ( <> {browseDialogType !== null ? ( ) : null} diff --git a/desktop/src/app/routes/channels.$channelId.tsx b/desktop/src/app/routes/channels.$channelId.tsx index 3c64f3ec9..5bffcbd9f 100644 --- a/desktop/src/app/routes/channels.$channelId.tsx +++ b/desktop/src/app/routes/channels.$channelId.tsx @@ -1,13 +1,17 @@ import * as React from "react"; import { createFileRoute } from "@tanstack/react-router"; +import { + parseProfilePanelView, + type ProfilePanelView, +} from "@/features/profile/ui/UserProfilePanelUtils"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type ChannelRouteSearch = { agentSession?: string; messageId?: string; profile?: string; - profileView?: "memories" | "channels"; + profileView?: ProfilePanelView; thread?: string; threadRootId?: string; }; @@ -16,10 +20,6 @@ function nonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } -function profileViewValue(value: unknown): "memories" | "channels" | undefined { - return value === "memories" || value === "channels" ? value : undefined; -} - function validateChannelSearch( search: Record, ): ChannelRouteSearch { @@ -27,7 +27,7 @@ function validateChannelSearch( agentSession: nonEmptyString(search.agentSession), messageId: nonEmptyString(search.messageId), profile: nonEmptyString(search.profile), - profileView: profileViewValue(search.profileView), + profileView: parseProfilePanelView(search.profileView) ?? undefined, thread: nonEmptyString(search.thread), threadRootId: nonEmptyString(search.threadRootId), }; diff --git a/desktop/src/app/routes/pulse.tsx b/desktop/src/app/routes/pulse.tsx index e1a5a001e..949b56d0b 100644 --- a/desktop/src/app/routes/pulse.tsx +++ b/desktop/src/app/routes/pulse.tsx @@ -1,6 +1,10 @@ import * as React from "react"; import { createFileRoute } from "@tanstack/react-router"; +import { + parseProfilePanelView, + type ProfilePanelView, +} from "@/features/profile/ui/UserProfilePanelUtils"; import { usePreviewFeatureWarning } from "@/shared/features"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; @@ -11,7 +15,7 @@ const PulseScreen = React.lazy(async () => { type PulseRouteSearch = { profile?: string; - profileView?: "memories" | "channels"; + profileView?: ProfilePanelView; }; function validatePulseSearch( @@ -22,10 +26,7 @@ function validatePulseSearch( typeof search.profile === "string" && search.profile.length > 0 ? search.profile : undefined, - profileView: - search.profileView === "memories" || search.profileView === "channels" - ? search.profileView - : undefined, + profileView: parseProfilePanelView(search.profileView) ?? undefined, }; } diff --git a/desktop/src/app/useSettingsShortcuts.ts b/desktop/src/app/useSettingsShortcuts.ts index 569d26200..0d0813a35 100644 --- a/desktop/src/app/useSettingsShortcuts.ts +++ b/desktop/src/app/useSettingsShortcuts.ts @@ -26,6 +26,7 @@ export function useSettingsShortcuts({ } event.preventDefault(); + event.stopImmediatePropagation(); if (open) { onClose(); return; @@ -34,9 +35,9 @@ export function useSettingsShortcuts({ onOpenSettings(); } - window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keydown", handleKeyDown, true); return () => { - window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keydown", handleKeyDown, true); }; }, [onClose, onOpenSettings, open]); } diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 02b9129ba..ed4680978 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -125,16 +125,17 @@ function invalidateManagedAgentQueriesInBackground( ); } -export function useAcpRuntimesQuery() { +export function useAcpRuntimesQuery(options?: { enabled?: boolean }) { return useQuery({ + enabled: options?.enabled ?? true, queryKey: acpRuntimesQueryKey, queryFn: discoverAcpRuntimes, staleTime: 60_000, }); } -export function useAvailableAcpRuntimes() { - const query = useAcpRuntimesQuery(); +export function useAvailableAcpRuntimes(options?: { enabled?: boolean }) { + const query = useAcpRuntimesQuery(options); const available = React.useMemo( () => (query.data ?? []).filter( @@ -155,8 +156,9 @@ export function useInstallAcpRuntimeMutation() { }); } -export function useBackendProvidersQuery() { +export function useBackendProvidersQuery(options?: { enabled?: boolean }) { return useQuery({ + enabled: options?.enabled ?? true, queryKey: backendProvidersQueryKey, queryFn: discoverBackendProviders, staleTime: 30_000, @@ -175,11 +177,13 @@ export function usePersonasQuery() { export function useManagedAgentPrereqsQuery( acpCommand: string, mcpCommand: string, + options?: { enabled?: boolean }, ) { const normalizedAcpCommand = acpCommand.trim(); const normalizedMcpCommand = mcpCommand.trim(); return useQuery({ + enabled: options?.enabled ?? true, queryKey: [ ...managedAgentPrereqsQueryKey, normalizedAcpCommand, diff --git a/desktop/src/features/agents/openCreateAgentEvent.ts b/desktop/src/features/agents/openCreateAgentEvent.ts new file mode 100644 index 000000000..69c7eac6b --- /dev/null +++ b/desktop/src/features/agents/openCreateAgentEvent.ts @@ -0,0 +1,30 @@ +const OPEN_CREATE_AGENT_EVENT = "buzz:open-create-agent"; + +let pendingOpenCreateAgent = false; + +export function requestOpenCreateAgent() { + pendingOpenCreateAgent = true; + window.dispatchEvent(new Event(OPEN_CREATE_AGENT_EVENT)); +} + +export function consumePendingOpenCreateAgent() { + if (!pendingOpenCreateAgent) { + return false; + } + + pendingOpenCreateAgent = false; + return true; +} + +export function subscribeOpenCreateAgent(handler: () => void) { + function handleOpenCreateAgent() { + pendingOpenCreateAgent = false; + handler(); + } + + window.addEventListener(OPEN_CREATE_AGENT_EVENT, handleOpenCreateAgent); + + return () => { + window.removeEventListener(OPEN_CREATE_AGENT_EVENT, handleOpenCreateAgent); + }; +} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 9e5216acf..fb88919dd 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -1,5 +1,10 @@ +import * as React from "react"; import { topChromeInset } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; +import { + consumePendingOpenCreateAgent, + subscribeOpenCreateAgent, +} from "@/features/agents/openCreateAgentEvent"; import { AddAgentToChannelDialog } from "./AddAgentToChannelDialog"; import { AddTeamToChannelDialog } from "./AddTeamToChannelDialog"; import { BatchImportDialog } from "./BatchImportDialog"; @@ -44,6 +49,16 @@ export function AgentsView() { teamActions.updateTeamMutation.isPending || teamActions.deleteTeamMutation.isPending; + React.useEffect(() => { + if (consumePendingOpenCreateAgent()) { + agents.setIsCreateOpen(true); + } + + return subscribeOpenCreateAgent(() => { + agents.setIsCreateOpen(true); + }); + }, [agents.setIsCreateOpen]); + return ( <>
- { - agents.setLogAgentPubkey(result.agent.pubkey); - agents.setCreatedAgent(result); - }} - onOpenChange={agents.setIsCreateOpen} - open={agents.isCreateOpen} - /> - { - if (!open) { - agents.setAgentToAddToChannel(null); + {agents.isCreateOpen ? ( + { + agents.setLogAgentPubkey(result.agent.pubkey); + agents.setCreatedAgent(result); + }} + onOpenChange={agents.setIsCreateOpen} + open={agents.isCreateOpen} + /> + ) : null} + {agents.agentToAddToChannel ? ( + { + if (!open) { + agents.setAgentToAddToChannel(null); + } + }} + open={agents.agentToAddToChannel !== null} + /> + ) : null} + {agents.createdAgent ? ( + { + if (!open) { + agents.setCreatedAgent(null); + } + }} + /> + ) : null} + {personas.personaDialogState ? ( + - { - if (!open) { - agents.setCreatedAgent(null); + isPending={ + personas.createPersonaMutation.isPending || + personas.updatePersonaMutation.isPending } - }} - /> - { + if (!open) { + personas.setPersonaDialogState(null); + } + }} + onSubmit={personas.handleSubmit} + open={personas.personaDialogState !== null} + submitLabel={personas.personaDialogState.submitLabel} + title={personas.personaDialogState.title} + /> + ) : null} + {personas.personaToDelete ? ( + { + void personas.handleDelete(persona); + }} + onOpenChange={(open) => { + if (!open) { + personas.setPersonaToDelete(null); + } + }} + open={personas.personaToDelete !== null} + persona={personas.personaToDelete} + /> + ) : null} + {personas.isCatalogDialogOpen ? ( + { - if (!open) { - personas.setPersonaDialogState(null); } - }} - onSubmit={personas.handleSubmit} - open={personas.personaDialogState !== null} - submitLabel={personas.personaDialogState?.submitLabel ?? "Save"} - title={personas.personaDialogState?.title ?? "Persona"} - /> - { - void personas.handleDelete(persona); - }} - onOpenChange={(open) => { - if (!open) { - personas.setPersonaToDelete(null); + feedbackErrorMessage={ + personas.personaFeedbackSurface === "catalog" + ? personas.personaErrorMessage + : null } - }} - open={personas.personaToDelete !== null} - persona={personas.personaToDelete} - /> - { - personas.clearFeedback("catalog"); - }} - onOpenChange={personas.setIsCatalogDialogOpen} - onSelectPersona={(persona, active) => { - void personas.handleSetActive(persona, active, "catalog"); - }} - open={personas.isCatalogDialogOpen} - personas={personas.catalogPersonas} - /> - { - if (!open) { - teamActions.setTeamDialogState(null); } - }} - onDeleteRemovedPersonas={teamActions.handleDeleteRemovedPersonas} - onSubmit={teamActions.handleTeamSubmit} - open={teamActions.teamDialogState !== null} - personas={personas.libraryPersonas} - submitLabel={teamActions.teamDialogState?.submitLabel ?? "Save"} - title={teamActions.teamDialogState?.title ?? "Team"} - /> - { - void teamActions.handleDeleteTeam(team); - }} - onOpenChange={(open) => { - if (!open) { - teamActions.setTeamToDelete(null); + isLoading={personas.personasQuery.isLoading} + isPending={personas.setPersonaActiveMutation.isPending} + onClearFeedback={() => { + personas.clearFeedback("catalog"); + }} + onOpenChange={personas.setIsCatalogDialogOpen} + onSelectPersona={(persona, active) => { + void personas.handleSetActive(persona, active, "catalog"); + }} + open={personas.isCatalogDialogOpen} + personas={personas.catalogPersonas} + /> + ) : null} + {teamActions.teamDialogState ? ( + - { - if (!open) { - teamActions.setTeamToAddToChannel(null); + onImportUpdateFile={teamActions.handleEditDialogImportUpdateFile} + onOpenChange={(open) => { + if (!open) { + teamActions.setTeamDialogState(null); + } + }} + onDeleteRemovedPersonas={teamActions.handleDeleteRemovedPersonas} + onSubmit={teamActions.handleTeamSubmit} + open={teamActions.teamDialogState !== null} + personas={personas.libraryPersonas} + submitLabel={teamActions.teamDialogState.submitLabel} + title={teamActions.teamDialogState.title} + /> + ) : null} + {teamActions.teamToDelete ? ( + { + void teamActions.handleDeleteTeam(team); + }} + onOpenChange={(open) => { + if (!open) { + teamActions.setTeamToDelete(null); + } + }} + open={teamActions.teamToDelete !== null} + team={teamActions.teamToDelete} + /> + ) : null} + {teamActions.teamToAddToChannel ? ( + { + if (!open) { + teamActions.setTeamToAddToChannel(null); + } + }} + open={teamActions.teamToAddToChannel !== null} + personas={personas.libraryPersonas} + team={teamActions.teamToAddToChannel} + /> + ) : null} + {personas.batchImportResult ? ( + { + if (!open) { + personas.setBatchImportResult(null); + } + }} + open={personas.batchImportResult !== null} + result={personas.batchImportResult} + /> + ) : null} + {teamActions.teamImportPreview ? ( + { + if (!open) { + teamActions.setTeamImportPreview(null); + } + }} + open={teamActions.teamImportPreview !== null} + preview={teamActions.teamImportPreview.preview} + /> + ) : null} + {teamActions.teamImportTarget ? ( + - { - if (!open) { - personas.setBatchImportResult(null); + onApply={teamActions.handleTeamImportUpdateApply} + onClear={teamActions.clearImportUpdateAndReturnToEdit} + onOpenChange={(open) => { + if (!open) { + teamActions.closeImportUpdateDialog(); + } + }} + open={teamActions.teamImportTarget !== null} + personas={personas.libraryPersonas} + preview={teamActions.teamImportTargetPreview?.preview ?? null} + team={teamActions.teamImportTarget} + /> + ) : null} + {personas.personaImportActions.personaImportTarget ? ( + - { - if (!open) { - teamActions.setTeamImportPreview(null); + isPending={ + personas.personaImportActions.isApplyingPersonaImportUpdate || + personas.updatePersonaMutation.isPending } - }} - open={teamActions.teamImportPreview !== null} - preview={teamActions.teamImportPreview?.preview ?? null} - /> - { - if (!open) { - teamActions.closeImportUpdateDialog(); + onApply={personas.personaImportActions.handleImportUpdateApply} + onClear={ + personas.personaImportActions.clearImportUpdateAndReturnToEdit } - }} - open={teamActions.teamImportTarget !== null} - personas={personas.libraryPersonas} - preview={teamActions.teamImportTargetPreview?.preview ?? null} - team={teamActions.teamImportTarget} - /> - { - if (!open) { - personas.personaImportActions.closeImportUpdateDialog(); + onOpenChange={(open) => { + if (!open) { + personas.personaImportActions.closeImportUpdateDialog(); + } + }} + open={personas.personaImportActions.personaImportTarget !== null} + persona={personas.personaImportActions.personaImportTarget} + preview={ + personas.personaImportActions.personaImportTargetPreview?.preview ?? + null } - }} - open={personas.personaImportActions.personaImportTarget !== null} - persona={personas.personaImportActions.personaImportTarget} - preview={ - personas.personaImportActions.personaImportTargetPreview?.preview ?? - null - } - /> + /> + ) : null} ); } diff --git a/desktop/src/features/agents/ui/CopyButton.tsx b/desktop/src/features/agents/ui/CopyButton.tsx index a8d0b621f..4833fcae3 100644 --- a/desktop/src/features/agents/ui/CopyButton.tsx +++ b/desktop/src/features/agents/ui/CopyButton.tsx @@ -1,24 +1,31 @@ import { Copy } from "lucide-react"; import { toast } from "sonner"; -import { Button } from "@/shared/ui/button"; +import { Button, type ButtonProps } from "@/shared/ui/button"; export function CopyButton({ - value, + className, label, + size = "sm", + value, + variant = "outline", }: { - value: string; + className?: string; label?: string; + size?: ButtonProps["size"]; + value: string; + variant?: ButtonProps["variant"]; }) { return ( + ) : !modelsData.supportsSwitching ? (
{agent.model ? ( diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index 7d8013791..432544ee2 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -31,7 +31,8 @@ import { export function useManagedAgentActions() { const relayAgentsQuery = useRelayAgentsQuery(); const managedAgentsQuery = useManagedAgentsQuery(); - const channelsQuery = useChannelsQuery(); + const [shouldLoadChannels, setShouldLoadChannels] = React.useState(false); + const channelsQuery = useChannelsQuery({ enabled: shouldLoadChannels }); const startMutation = useStartManagedAgentMutation(); const stopMutation = useStopManagedAgentMutation(); const deleteMutation = useDeleteManagedAgentMutation(); @@ -53,6 +54,13 @@ export function useManagedAgentActions() { const managedAgentLogQuery = useManagedAgentLogQuery(logAgentPubkey); + React.useEffect(() => { + const timeoutId = window.setTimeout(() => { + setShouldLoadChannels(true); + }, 0); + return () => window.clearTimeout(timeoutId); + }, []); + const managedAgents = React.useMemo( () => [...(managedAgentsQuery.data ?? [])].sort((left, right) => { @@ -151,14 +159,24 @@ export function useManagedAgentActions() { } } + async function getChannelsForAction() { + if (channelsQuery.data) { + return channelsQuery.data; + } + + const result = await channelsQuery.refetch(); + return result.data ?? []; + } + async function handleStop(pubkey: string) { clearFeedback(); try { const agent = managedAgents.find((a) => a.pubkey === pubkey); if (!agent) return; + const channels = await getChannelsForAction(); const result = await stopManagedAgentWithRules({ agent, - channels: channelsQuery.data ?? [], + channels, relayAgents: relayAgentsQuery.data ?? [], stopManagedAgent: stopMutation.mutateAsync, }); @@ -193,9 +211,10 @@ export function useManagedAgentActions() { try { const agent = managedAgents.find((a) => a.pubkey === pubkey); if (!agent) return; + const channels = await getChannelsForAction(); const result = await deleteManagedAgentWithRules({ agent, - channels: channelsQuery.data ?? [], + channels, deleteManagedAgent: deleteMutation.mutateAsync, presenceLookup: managedPresenceQuery.data, relayAgents: relayAgentsQuery.data ?? [], diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index 478c57ebd..e8f1930e1 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -36,7 +36,11 @@ type PersonaFeedbackSurface = "catalog" | "library"; export function usePersonaActions() { const queryClient = useQueryClient(); const personasQuery = usePersonasQuery(); - const acpRuntimesQuery = useAcpRuntimesQuery(); + const [shouldLoadAcpRuntimes, setShouldLoadAcpRuntimes] = + React.useState(false); + const acpRuntimesQuery = useAcpRuntimesQuery({ + enabled: shouldLoadAcpRuntimes, + }); const createPersonaMutation = useCreatePersonaMutation(); const updatePersonaMutation = useUpdatePersonaMutation(); const deletePersonaMutation = useDeletePersonaMutation(); @@ -141,6 +145,7 @@ export function usePersonaActions() { try { const result = await parsePersonaFiles(fileBytes, fileName); if (isSingleItemFile(fileBytes) && result.personas.length === 1) { + setShouldLoadAcpRuntimes(true); setPersonaDialogState(importPersonaDialogState(result.personas[0])); } else if (result.personas.length > 0) { setBatchImportResult(result); @@ -182,16 +187,19 @@ export function usePersonaActions() { function openCreate() { clearFeedback("library"); + setShouldLoadAcpRuntimes(true); setPersonaDialogState(createPersonaDialogState()); } function openEdit(persona: AgentPersona) { clearFeedback("library"); + setShouldLoadAcpRuntimes(true); setPersonaDialogState(editPersonaDialogState(persona)); } function openDuplicate(persona: AgentPersona) { clearFeedback("library"); + setShouldLoadAcpRuntimes(true); setPersonaDialogState(duplicatePersonaDialogState(persona)); } diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 25994aa02..b6d4d7299 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -101,8 +101,9 @@ function setChannelArchivedState( ); } -export function useChannelsQuery() { +export function useChannelsQuery(options?: { enabled?: boolean }) { return useQuery({ + enabled: options?.enabled ?? true, queryKey: channelsQueryKey, queryFn: async () => sortChannels(await getChannels()), staleTime: 60_000, diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 5b72e5b92..e858535a3 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -202,7 +202,7 @@ export function AgentSessionThreadPanel({ "flex cursor-default select-none items-center", isSinglePanelView ? `relative ${PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS} -mb-[4.75rem] min-h-[4.75rem] shrink-0 gap-2.5 bg-background/80 pb-1 pl-4 pr-2 pt-[2.625rem] backdrop-blur-md supports-[backdrop-filter]:bg-background/70 sm:pl-6 sm:pr-3 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55` - : "relative z-50 min-h-11 shrink-0 gap-3 bg-background/80 px-3 py-1.5 backdrop-blur-md supports-[backdrop-filter]:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55", + : "relative z-50 min-h-14 shrink-0 gap-3 bg-background/80 px-5 py-2 backdrop-blur-md supports-[backdrop-filter]:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55", )} data-tauri-drag-region > diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 79c5e7939..4c56f91f8 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -50,7 +50,11 @@ import { import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions"; import { Button } from "@/shared/ui/button"; import type { useChannelFind } from "@/features/search/useChannelFind"; -import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; +import { + buildMainTimelineEntries, + type MainTimelineEntry, +} from "@/features/messages/lib/threadPanel"; +import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration"; import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { isWelcomeChannel } from "@/features/onboarding/welcome"; @@ -563,6 +567,16 @@ export const ChannelPane = React.memo(function ChannelPane({ return messages.filter((message) => !isWelcomeSetupSystemMessage(message)); }, [activeChannel, messages]); + const mainTimelineEntries = React.useMemo( + () => buildMainTimelineEntries(visibleMessages), + [visibleMessages], + ); + useRenderScopedReactionHydration({ + activeChannel, + mainTimelineEntries, + threadHeadMessage, + threadMessages, + }); const videoReviewCommentsByRootId = React.useMemo( () => buildVideoReviewCommentsByRootId(messages), [messages], @@ -677,6 +691,7 @@ export const ChannelPane = React.memo(function ChannelPane({ : "No channel selected" } isLoading={isTimelineLoading} + mainEntries={mainTimelineEntries} messages={visibleMessages} firstUnreadMessageId={firstUnreadMessageId} unreadCount={unreadCount} @@ -948,6 +963,7 @@ export const ChannelPane = React.memo(function ChannelPane({ layout={useSplitAuxiliaryPane ? "split" : "standalone"} onClose={onCloseProfilePanel} onOpenDm={onOpenDm} + onOpenProfile={onOpenProfilePanel} onViewChange={onProfilePanelViewChange} pubkey={profilePanelPubkey} splitPaneClamp diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index cda079449..311d22449 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -92,7 +92,6 @@ export function ChannelScreenHeader({ +
{children} - {action ? ( - {action} - ) : null} + {action ? {action} : null}
); } diff --git a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts index 53d236f43..adfb37652 100644 --- a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts +++ b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts @@ -1,6 +1,9 @@ import * as React from "react"; -import type { ProfilePanelView } from "@/features/profile/ui/UserProfilePanel"; +import { + profilePanelViewFromSearch, + type ProfilePanelView, +} from "@/features/profile/ui/UserProfilePanelUtils"; import { type HistorySearchSetterOptions, useHistorySearchState, @@ -32,10 +35,6 @@ const CHANNEL_SEARCH_KEYS = [ "threadRootId", ] as const; -function asProfilePanelView(value: string | null): ProfilePanelView { - return value === "memories" || value === "channels" ? value : "summary"; -} - export function useChannelPanelHistoryState() { const { applyPatch, values } = useHistorySearchState(CHANNEL_SEARCH_KEYS); @@ -74,7 +73,7 @@ export function useChannelPanelHistoryState() { openAgentSessionPubkey: values.agentSession, openThreadHeadId: values.thread, profilePanelPubkey: values.profile, - profilePanelView: asProfilePanelView(values.profileView), + profilePanelView: profilePanelViewFromSearch(values.profileView), setOpenAgentSessionPubkey, setOpenThreadHeadId, setProfilePanelPubkey, diff --git a/desktop/src/features/channels/unreadChannelCounts.ts b/desktop/src/features/channels/unreadChannelCounts.ts index 0b96055a1..2a4239cfe 100644 --- a/desktop/src/features/channels/unreadChannelCounts.ts +++ b/desktop/src/features/channels/unreadChannelCounts.ts @@ -3,8 +3,30 @@ export type ObservedUnreadEvent = { createdAt: number; rootId: string | null; highPriority: boolean; + countsTowardBadge: boolean; + countsTowardAppBadge: boolean; }; +export function makeObservedUnreadEvent(input: { + id: string; + createdAt: number; + rootId: string | null; + highPriority: boolean; + channelType: string | undefined; + isThreadedReply: boolean; +}): ObservedUnreadEvent { + const isDm = input.channelType === "dm"; + return { + id: input.id, + createdAt: input.createdAt, + rootId: input.rootId, + highPriority: input.highPriority, + countsTowardBadge: isDm || input.isThreadedReply || input.highPriority, + countsTowardAppBadge: + isDm || (!input.isThreadedReply && input.highPriority), + }; +} + export function mapsEqual( a: ReadonlyMap, b: ReadonlyMap, @@ -54,6 +76,34 @@ export function countUnreadObservedEvents( return count; } +export function countUnreadBadgeObservedEvents( + eventsById: ReadonlyMap | undefined, + getReadAt: (event: ObservedUnreadEvent) => number | null, +): number { + if (!eventsById) return 0; + let count = 0; + for (const event of eventsById.values()) { + if (!event.countsTowardBadge) continue; + const readAt = getReadAt(event); + if (readAt === null || event.createdAt > readAt) count += 1; + } + return count; +} + +export function countUnreadAppBadgeObservedEvents( + eventsById: ReadonlyMap | undefined, + getReadAt: (event: ObservedUnreadEvent) => number | null, +): number { + if (!eventsById) return 0; + let count = 0; + for (const event of eventsById.values()) { + if (!event.countsTowardAppBadge) continue; + const readAt = getReadAt(event); + if (readAt === null || event.createdAt > readAt) count += 1; + } + return count; +} + export function countUnreadHighPriorityObservedEvents( eventsById: ReadonlyMap | undefined, getReadAt: (event: ObservedUnreadEvent) => number | null, diff --git a/desktop/src/features/channels/unreadReadMarker.test.mjs b/desktop/src/features/channels/unreadReadMarker.test.mjs index 406e3c05c..521414e8a 100644 --- a/desktop/src/features/channels/unreadReadMarker.test.mjs +++ b/desktop/src/features/channels/unreadReadMarker.test.mjs @@ -3,6 +3,8 @@ import test from "node:test"; import { computeChannelUnreadMarker } from "../messages/lib/unreadMarker.ts"; import { + countUnreadAppBadgeObservedEvents, + countUnreadBadgeObservedEvents, countUnreadHighPriorityObservedEvents, countUnreadObservedEvents, observedUnreadEventReadAt, @@ -136,8 +138,22 @@ test("observedUnreadEventReadAt_nullChannelMarkerThreadMarkerCanClear", () => { // --- Fix 2b: sidebar badge evaluates all observed events, not a single aggregate frontier --- -function observed(id, createdAt, rootId = null, highPriority = false) { - return { id, createdAt, rootId, highPriority }; +function observed( + id, + createdAt, + rootId = null, + highPriority = false, + countsTowardBadge = true, + countsTowardAppBadge = countsTowardBadge, +) { + return { + id, + createdAt, + rootId, + highPriority, + countsTowardBadge, + countsTowardAppBadge, + }; } function readAtFor(channelMarker, threadMarkers) { @@ -219,6 +235,51 @@ test("countUnreadObservedEvents_topLevelUsesChannelMarker", () => { assert.equal(countUnreadObservedEvents(events, readAtFor(300, new Map())), 1); }); +test("countUnreadBadgeObservedEvents_skipsBoldOnlyGeneralChannelItems", () => { + const events = new Map([ + ["plain", observed("plain", 500, null, false, false)], + ["thread", observed("thread", 600, "root-1")], + ]); + + assert.equal(countUnreadObservedEvents(events, readAtFor(300, new Map())), 2); + assert.equal( + countUnreadBadgeObservedEvents(events, readAtFor(300, new Map())), + 1, + ); + assert.equal( + countUnreadAppBadgeObservedEvents(events, readAtFor(300, new Map())), + 1, + ); +}); + +test("countUnreadObservedEvents_countsThreadRepliesForChannelUnread", () => { + const events = new Map([ + ["reply", observed("reply", 500, "root-1", false, true, false)], + ]); + + assert.equal(countUnreadObservedEvents(events, readAtFor(300, new Map())), 1); + assert.equal( + countUnreadBadgeObservedEvents(events, readAtFor(300, new Map())), + 1, + ); + assert.equal( + countUnreadAppBadgeObservedEvents(events, readAtFor(300, new Map())), + 0, + ); +}); + +test("highPriorityObservedEvents_countsMentionBadgeForGeneralMessage", () => { + const events = new Map([ + ["mention", observed("mention", 500, null, true, true)], + ]); + const getReadAt = readAtFor(300, new Map()); + + assert.equal(countUnreadObservedEvents(events, getReadAt), 1); + assert.equal(countUnreadBadgeObservedEvents(events, getReadAt), 1); + assert.equal(countUnreadAppBadgeObservedEvents(events, getReadAt), 1); + assert.equal(countUnreadHighPriorityObservedEvents(events, getReadAt), 1); +}); + test("recordObservedUnreadEvent_reportsOutOfOrderInsertForInvalidation", () => { const channelId = "chan"; const observedByChannel = new Map(); diff --git a/desktop/src/features/channels/unreadRootIdStore.ts b/desktop/src/features/channels/unreadRootIdStore.ts new file mode 100644 index 000000000..247b8a4b1 --- /dev/null +++ b/desktop/src/features/channels/unreadRootIdStore.ts @@ -0,0 +1,30 @@ +// Per-pubkey JSON array of thread root ids, capped to newest entries and +// tolerant of malformed or unavailable localStorage. +export function makeRootIdStore(prefix: string, maxEntries = 1000) { + const storageKey = (pubkey: string) => `${prefix}:${pubkey}`; + return { + read(pubkey: string): Set { + try { + const raw = window.localStorage.getItem(storageKey(pubkey)); + if (!raw) return new Set(); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + return new Set( + parsed.filter((id): id is string => typeof id === "string"), + ); + } catch { + return new Set(); + } + }, + write(pubkey: string, rootIds: Set): void { + try { + const arr = [...rootIds]; + const capped = + arr.length > maxEntries ? arr.slice(arr.length - maxEntries) : arr; + window.localStorage.setItem(storageKey(pubkey), JSON.stringify(capped)); + } catch { + // Ignore storage errors (private browsing, quota exceeded). + } + }, + }; +} diff --git a/desktop/src/features/channels/useLiveChannelUpdates.test.mjs b/desktop/src/features/channels/useLiveChannelUpdates.test.mjs deleted file mode 100644 index f6949bcaf..000000000 --- a/desktop/src/features/channels/useLiveChannelUpdates.test.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { shouldRouteChannelUnreadEvent } from "./useLiveChannelUpdates.ts"; - -test("main-channel messages route to channel unread tracking", () => { - assert.equal(shouldRouteChannelUnreadEvent(undefined, false), true); -}); - -test("non-DM thread replies do not route to channel unread tracking", () => { - assert.equal( - shouldRouteChannelUnreadEvent({ channelType: "stream" }, true), - false, - ); -}); - -test("DM thread replies route to channel unread tracking", () => { - assert.equal( - shouldRouteChannelUnreadEvent({ channelType: "dm" }, true), - true, - ); -}); diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index a645c6c1a..f5a03a9a2 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -28,12 +28,11 @@ export type UseLiveChannelUpdatesOptions = { onDmMessage?: (event: RelayEvent, channel: Channel) => void; onLiveMention?: () => void; /** - * Fired for live main-channel "new content" events in a member channel - * authored by someone other than the current user. Non-DM thread replies - * are routed through onThreadReplyNotification instead; DM thread replies - * also fire this callback so the DM unread dot/count stays channel-level. - * Used to drive the in-session "latest message at" map that powers sidebar - * unread badges. See `UNREAD_TRIGGER_KINDS` for the exact kind set. + * Fired for live "new content" events in a member channel authored by + * someone other than the current user. Thread replies also fire + * onThreadReplyNotification so Home inbox activity stays in sync. Used to + * drive the observed unread-event map that powers sidebar unread state. + * See `UNREAD_TRIGGER_KINDS` for the exact kind set. */ onChannelMessage?: (channelId: string, event: RelayEvent) => void; /** @@ -73,13 +72,6 @@ const UNREAD_TRIGGER_KINDS = new Set(CHANNEL_MESSAGE_EVENT_KINDS); export const EMPTY_SET: ReadonlySet = new Set(); -export function shouldRouteChannelUnreadEvent( - channel: Pick | undefined, - isThreadedReply: boolean, -): boolean { - return !isThreadedReply || channel?.channelType === "dm"; -} - function isExternalMentionEvent(event: RelayEvent, currentPubkey: string) { return ( currentPubkey.length > 0 && event.pubkey.toLowerCase() !== currentPubkey @@ -237,18 +229,11 @@ export function useLiveChannelUpdates( if (isThreadedReply) { options.onThreadReplyCandidate?.(channelId, event); } - } else if ( - shouldRouteChannelUnreadEvent( - dmChannelMap.get(channelId), - isThreadedReply, - ) - ) { + } else { options.onChannelMessage?.(channelId, event); if (isThreadedReply) { options.onThreadReplyNotification?.(channelId, event); } - } else { - options.onThreadReplyNotification?.(channelId, event); } if (shouldNotify && isThreadedReply) { diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index b9d8eb21e..8cf6808d9 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -1,19 +1,22 @@ import * as React from "react"; import { EMPTY_SET, - shouldRouteChannelUnreadEvent, useLiveChannelUpdates, type UseLiveChannelUpdatesOptions, } from "@/features/channels/useLiveChannelUpdates"; import { + countUnreadAppBadgeObservedEvents, + countUnreadBadgeObservedEvents, countUnreadHighPriorityObservedEvents, countUnreadObservedEvents, + makeObservedUnreadEvent, mapsEqual, observedUnreadEventReadAt, recordObservedUnreadEvent, type ObservedUnreadEvent, } from "@/features/channels/unreadChannelCounts"; import { useReadState } from "@/features/channels/readState/useReadState"; +import { makeRootIdStore } from "@/features/channels/unreadRootIdStore"; import { getThreadReference, isBroadcastReply, @@ -40,41 +43,6 @@ type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions & { // per-channel limit elsewhere in the app. const CATCH_UP_LIMIT = 1000; -// All four thread root-id sets (participation, authored, mentioned, muted) -// share the same localStorage shape: a per-pubkey JSON array of ids, capped to -// the newest N entries on write and tolerant of malformed/absent data on read. -// One factory yields the read/write pair for each so the only difference is the -// key prefix. The closures capture the prefix lexically (no `this`), so a -// caller can alias one store's `write` into a variable and call it bare. -function makeRootIdStore(prefix: string, maxEntries = 1000) { - const storageKey = (pubkey: string) => `${prefix}:${pubkey}`; - return { - read(pubkey: string): Set { - try { - const raw = window.localStorage.getItem(storageKey(pubkey)); - if (!raw) return new Set(); - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) return new Set(); - return new Set( - parsed.filter((id): id is string => typeof id === "string"), - ); - } catch { - return new Set(); - } - }, - write(pubkey: string, rootIds: Set): void { - try { - const arr = [...rootIds]; - const capped = - arr.length > maxEntries ? arr.slice(arr.length - maxEntries) : arr; - window.localStorage.setItem(storageKey(pubkey), JSON.stringify(capped)); - } catch { - // Ignore storage errors (private browsing, quota exceeded). - } - }, - }; -} - const participationStore = makeRootIdStore("buzz-thread-participation.v1"); const authoredStore = makeRootIdStore("buzz-thread-authored.v1"); // Thread roots where an external message @-mentioned the current user. The @@ -436,12 +404,20 @@ export function useUnreadChannels( channel?.channelType === "dm" || (normalizedPubkey !== null && isHighPriorityEventForUser(event, normalizedPubkey)); - const didRecordUnreadEvent = recordUnreadEvent(channelId, { - id: event.id, - createdAt: event.created_at, - rootId: resolveObservedUnreadRootId(event.tags), - highPriority: isHighPriority, - }); + const isThreadedReply = + getThreadReference(event.tags).parentId !== null && + !isBroadcastReply(event.tags); + const didRecordUnreadEvent = recordUnreadEvent( + channelId, + makeObservedUnreadEvent({ + id: event.id, + createdAt: event.created_at, + rootId: resolveObservedUnreadRootId(event.tags), + highPriority: isHighPriority, + channelType: channel?.channelType, + isThreadedReply, + }), + ); const current = latestByChannelRef.current.get(channelId) ?? 0; if (event.created_at > current) { latestByChannelRef.current.set(channelId, event.created_at); @@ -694,21 +670,23 @@ export function useUnreadChannels( const evtRef = getThreadReference(event.tags); const isThreadedReply = evtRef.parentId !== null && !isBroadcastReply(event.tags); - if (shouldRouteChannelUnreadEvent(ch, isThreadedReply)) { - if (event.created_at > maxExternal) { - maxExternal = event.created_at; - } - const isHighPriority = - chType === "dm" || - (normalizedPubkey !== null && - isHighPriorityEventForUser(event, normalizedPubkey)); - unreadEvents.push({ + if (event.created_at > maxExternal) { + maxExternal = event.created_at; + } + const isHighPriority = + chType === "dm" || + (normalizedPubkey !== null && + isHighPriorityEventForUser(event, normalizedPubkey)); + unreadEvents.push( + makeObservedUnreadEvent({ id: event.id, createdAt: event.created_at, rootId: resolveObservedUnreadRootId(event.tags), highPriority: isHighPriority, - }); - } + channelType: chType, + isThreadedReply, + }), + ); if (isThreadedReply) { threadReplies.push({ id: event.id, @@ -821,12 +799,14 @@ export function useUnreadChannels( unreadChannelIds: new Set(), highPriorityUnreadChannelIds: new Set(), unreadChannelCounts: new Map(), + unreadChannelNotificationCount: 0, }; } const unread = new Set(); const highPriority = new Set(); const counts = new Map(); + let unreadChannelNotificationCount = 0; for (const channel of channels) { if (channel.id === activeChannelId) continue; @@ -835,6 +815,7 @@ export function useUnreadChannels( // Forced-unread is dot tier only — not high-priority. unread.add(channel.id); counts.set(channel.id, 1); + unreadChannelNotificationCount += 1; continue; } @@ -856,7 +837,15 @@ export function useUnreadChannels( if (unreadCount === 0) continue; unread.add(channel.id); - counts.set(channel.id, unreadCount); + const badgeCount = countUnreadBadgeObservedEvents( + observedEvents, + readAtForObservedEvent, + ); + counts.set(channel.id, badgeCount); + unreadChannelNotificationCount += countUnreadAppBadgeObservedEvents( + observedEvents, + readAtForObservedEvent, + ); // DM channels: any unread DM is high-priority. if (channel.channelType === "dm") { @@ -877,6 +866,7 @@ export function useUnreadChannels( unreadChannelIds: unread, highPriorityUnreadChannelIds: highPriority, unreadChannelCounts: counts, + unreadChannelNotificationCount, }; }, [ activeChannelId, @@ -919,9 +909,8 @@ export function useUnreadChannels( ? prevUnreadCountsRef.current : rawUnread.unreadChannelCounts; prevUnreadCountsRef.current = unreadChannelCounts; - const unreadChannelNotificationCount = [ - ...unreadChannelCounts.values(), - ].reduce((total, count) => total + count, 0); + const unreadChannelNotificationCount = + rawUnread.unreadChannelNotificationCount; const unreadChannelIdsRef = React.useRef(unreadChannelIds); unreadChannelIdsRef.current = unreadChannelIds; diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index 8981361d6..5cbc8ec20 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -25,7 +25,6 @@ type ChatHeaderProps = { belowSystemChrome?: boolean; /** Ref to the outer chrome wrapper when `belowSystemChrome` is true. */ chromeWrapperRef?: React.Ref; - density?: "default" | "compact"; title: string; description?: string; channelType?: ChannelType; @@ -87,7 +86,6 @@ export function ChatHeader({ actions, belowSystemChrome = false, chromeWrapperRef, - density = "default", title, description, channelType, @@ -117,13 +115,8 @@ export function ChatHeader({ const header = (
-
-

Custom Emoji

-

- Add your own custom emoji for everyone on this relay to use. Type{" "} - :name: in messages and reactions. -

-
+ + Add your own custom emoji for everyone on this relay to use. Type{" "} + :name: in messages and reactions. + + } + />
-
-
+
+
@@ -51,8 +51,8 @@ export function HomeLoadingState() {
-
-
+
+
diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index dd45d879f..0b74041ca 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -246,8 +246,8 @@ export function InboxDetailPane({ >
-
-
+
+
option.value === filter); const isReminders = filter === "reminders"; const scrollRef = React.useRef(null); @@ -325,13 +329,18 @@ export function InboxListPane({ )} > -
-
+
+
); + const isDiagnosticsLikeView = view === "diagnostics" || view === "logs"; const profileBody = (
setView("info")} - onOpenAgentSettings={() => setView("settings")} + onOpenActivity={handleOpenActivity} + onOpenAgentConfiguration={() => setView("configuration")} onOpenChannels={() => setView("channels")} onOpenDiagnostics={() => setView("diagnostics")} - onOpenInstruction={() => setView("instructions")} onOpenMemories={() => setView("memories")} - onOpenModel={() => setView("model")} onOpenDm={onOpenDm} + onToggleAutoStart={handleToggleAgentAutoStart} persona={resolvedPersona} presenceStatus={presenceStatus} profile={profile} @@ -790,51 +813,35 @@ export function UserProfilePanel({ userStatus={userStatus} /> ) : null} - {view === "memories" && effectivePubkey ? ( ) : null} - - {view === "instructions" ? ( - - ) : null} - {view === "info" ? ( ) : null} - - {view === "model" ? ( - void managedAgentsQuery.refetch()} - /> - ) : null} - - {view === "settings" ? ( - ) : null} - {view === "diagnostics" ? ( setView("logs")} - pubkey={effectivePubkey} /> ) : null} - {view === "channels" ? ( ) : null} - {view === "logs" ? ( - ) : null}
); - const editAgentDialog = canEditAgent && managedAgent ? ( void; +}) { + const runtimeConfigurationFields = fields.filter((field) => + AGENT_DETAILS_FIELD_LABELS.has(field.label), + ); + + return ( +
+ +
+ ); +} + +function AgentConfigurationRows({ + fields, + instruction, + managedAgent, + modelLabel, + showInstructionPlaceholder, + showModel, +}: { + fields: ProfileField[]; + instruction?: string | null; + managedAgent: ManagedAgent | undefined; + modelLabel: string; + showInstructionPlaceholder?: boolean; + showModel: boolean; +}) { + const hasRows = hasAgentConfigurationRows({ + fields, + instruction, + managedAgent, + modelLabel, + showInstructionPlaceholder, + showModel, + }); + + if (!hasRows) { + return null; + } + + return ( +
+ +
+ ); +} + +export function AgentDetailsRows({ + fields, + instruction, + managedAgent, + modelLabel, + showInstructionPlaceholder, + showModel = false, +}: { + fields: ProfileField[]; + instruction?: string | null; + managedAgent?: ManagedAgent | undefined; + modelLabel?: string; + showInstructionPlaceholder?: boolean; + showModel?: boolean; +}) { + const trimmedInstruction = instruction?.trim() ?? ""; + const showInstructions = + trimmedInstruction.length > 0 || showInstructionPlaceholder === true; + const showModelRow = + showModel === true && + (managedAgent !== undefined || (modelLabel?.trim().length ?? 0) > 0); + + if (!showInstructions && !showModelRow && fields.length === 0) { + return null; + } + + return ( + <> + {showInstructions ? ( + + ) : null} + + {showModelRow ? ( + + ) : null} + + {fields.length > 0 ? : null} + + ); +} + +function hasAgentConfigurationRows({ + fields, + instruction, + managedAgent, + modelLabel, + showInstructionPlaceholder, + showModel, +}: { + fields: ProfileField[]; + instruction?: string | null; + managedAgent: ManagedAgent | undefined; + modelLabel: string; + showInstructionPlaceholder?: boolean; + showModel: boolean; +}) { + const trimmedInstruction = instruction?.trim() ?? ""; + + return ( + trimmedInstruction.length > 0 || + showInstructionPlaceholder === true || + (showModel === true && + (managedAgent !== undefined || modelLabel.trim().length > 0)) || + fields.length > 0 + ); +} + +function AgentInstructionRow({ instruction }: { instruction: string | null }) { + const trimmedInstruction = instruction?.trim() ?? ""; + + return ( +
+ + + +
+
Instructions
+ {trimmedInstruction ? ( +
+ +
+ ) : ( +

+ No instruction set. +

+ )} +
+
+ ); +} + +function AgentModelRow({ modelLabel }: { modelLabel: string }) { + return ( +
+ + + + + Model + + {modelLabel} + + +
+ ); +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx index 7aa6d5aba..d173566b1 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx @@ -3,8 +3,8 @@ import { Activity, Copy, Cpu, + Ear, Fingerprint, - MessageSquare, Server, Terminal, UserRound, @@ -20,6 +20,7 @@ import type { Profile, RelayAgent, } from "@/shared/api/types"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; const RUNTIME_LABELS: Record = { goose: "Goose", @@ -49,6 +50,7 @@ export type ProfileField = { const AGENT_INFO_LABELS = new Set([ "Public key", "Owned by", + "Owned by & responds to", "NIP-05", "Agent type", "Capabilities", @@ -80,8 +82,11 @@ export function useProfileFieldBuckets({ isBot, isOwner, managedAgent, + onOpenOwner, + ownerAvatarUrl, ownerDisplayName, ownerHandle, + ownerPubkey, persona, presenceLoaded, presenceStatus, @@ -92,8 +97,11 @@ export function useProfileFieldBuckets({ isBot: boolean; isOwner: boolean | undefined; managedAgent: ManagedAgent | undefined; + onOpenOwner?: () => void; + ownerAvatarUrl: string | null; ownerDisplayName: string | null; ownerHandle: string | null; + ownerPubkey: string | null; persona: AgentPersona | undefined; presenceLoaded: boolean; presenceStatus: "online" | "away" | "offline" | undefined; @@ -104,11 +112,15 @@ export function useProfileFieldBuckets({ return React.useMemo(() => { const metadataFields = [ ...buildPublicFields({ pubkey, profile, relayAgent, isBot, persona }), - ...(isOwner === true + ...(ownerDisplayName || isOwner === true ? buildOwnerFields({ + includeOperationalFields: isOwner === true, managedAgent, + onOpenOwner, + ownerAvatarUrl, ownerDisplayName, ownerHandle, + ownerPubkey, persona, presenceLoaded, presenceStatus, @@ -133,8 +145,11 @@ export function useProfileFieldBuckets({ isBot, isOwner, managedAgent, + onOpenOwner, + ownerAvatarUrl, ownerDisplayName, ownerHandle, + ownerPubkey, persona, presenceLoaded, presenceStatus, @@ -212,34 +227,85 @@ export function buildPublicFields({ } export function buildOwnerFields({ + includeOperationalFields, managedAgent, + onOpenOwner, + ownerAvatarUrl, ownerDisplayName, ownerHandle, + ownerPubkey, persona, presenceLoaded, presenceStatus, relayAgent, }: { + includeOperationalFields: boolean; managedAgent: ManagedAgent | undefined; + onOpenOwner?: () => void; + ownerAvatarUrl: string | null; ownerDisplayName: string | null; ownerHandle: string | null; + ownerPubkey: string | null; persona?: AgentPersona; presenceLoaded: boolean; presenceStatus: "online" | "away" | "offline" | undefined; relayAgent: RelayAgent | undefined; }): ProfileField[] { const fields: ProfileField[] = []; + const combinesOwnerRespondTo = + managedAgent?.respondTo === "owner-only" && Boolean(ownerDisplayName); + const respondToOwner = + managedAgent?.respondTo === "owner-only" && ownerDisplayName; + const respondToDisplayValue = managedAgent + ? respondToOwner + ? ownerDisplayName + : managedAgent.respondTo.replace(/-/g, " ") + : null; if (ownerDisplayName) { fields.push({ - copyValue: ownerHandle ?? undefined, + copyValue: onOpenOwner + ? undefined + : (ownerPubkey ?? ownerHandle ?? undefined), displayValue: ownerDisplayName, + displayNode: onOpenOwner ? ( + + ) : undefined, icon: UserRound, - label: "Owned by", + label: combinesOwnerRespondTo ? "Owned by & responds to" : "Owned by", testId: "user-profile-owned-by", }); } + if (!includeOperationalFields) { + if (managedAgent && !respondToOwner && respondToDisplayValue) { + fields.push({ + displayValue: respondToDisplayValue, + icon: Ear, + label: "Respond to", + testId: "user-profile-respond-to", + }); + } + return fields; + } + if (managedAgent?.agentCommand) { fields.push({ copyValue: managedAgent.agentCommand, @@ -340,12 +406,14 @@ export function buildOwnerFields({ label: "Start on launch", testId: "user-profile-start-on-launch", }); - fields.push({ - displayValue: managedAgent.respondTo.replace(/-/g, " "), - icon: MessageSquare, - label: "Respond to", - testId: "user-profile-respond-to", - }); + if (!respondToOwner && respondToDisplayValue) { + fields.push({ + displayValue: respondToDisplayValue, + icon: Ear, + label: "Respond to", + testId: "user-profile-respond-to", + }); + } } if (managedAgent?.lastError) { @@ -361,17 +429,22 @@ export function buildOwnerFields({ return fields; } -export function ProfileFieldGroup({ fields }: { fields: ProfileField[] }) { +function orderProfileFields(fields: ProfileField[]) { const publicKeyLabel = "Public key"; const ownedByLabel = "Owned by"; + const ownedByRespondsToLabel = "Owned by & responds to"; const statusLabel = "Status"; - const orderedFields = [ + return [ ...fields.filter((field) => field.label === publicKeyLabel), - ...fields.filter((field) => field.label === ownedByLabel), + ...fields.filter( + (field) => + field.label === ownedByLabel || field.label === ownedByRespondsToLabel, + ), ...fields.filter( (field) => field.label !== publicKeyLabel && field.label !== ownedByLabel && + field.label !== ownedByRespondsToLabel && field.copyValue, ), ...fields.filter((field) => field.label === statusLabel), @@ -379,6 +452,7 @@ export function ProfileFieldGroup({ fields }: { fields: ProfileField[] }) { if ( field.label === publicKeyLabel || field.label === ownedByLabel || + field.label === ownedByRespondsToLabel || field.label === statusLabel ) { return false; @@ -386,13 +460,23 @@ export function ProfileFieldGroup({ fields }: { fields: ProfileField[] }) { return !field.copyValue; }), ]; +} + +export function ProfileFieldRows({ fields }: { fields: ProfileField[] }) { + return ( + <> + {orderProfileFields(fields).map((field) => ( + + ))} + + ); +} +export function ProfileFieldGroup({ fields }: { fields: ProfileField[] }) { return (
- {orderedFields.map((field) => ( - - ))} +
); diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index a52a912b4..3ac139633 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -7,18 +7,16 @@ import { ChevronDown, ChevronRight, ChevronUp, + CircleAlert, Cpu, - FileText, Hash, - Info, MessageSquare, Pencil, Play, - Power, - Settings, Square, UserMinus, UserPlus, + Wrench, } from "lucide-react"; import { toast } from "sonner"; @@ -26,7 +24,7 @@ import { MemorySection } from "@/features/agent-memory/ui/MemorySection"; import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; import { getManagedAgentPrimaryActionLabel } from "@/features/agents/lib/managedAgentControlActions"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; -import { ModelPicker } from "@/features/agents/ui/ModelPicker"; +import { ManagedAgentLogPanel } from "@/features/agents/ui/ManagedAgentLogPanel"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { getPresenceLabel } from "@/features/presence/lib/presence"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; @@ -38,7 +36,12 @@ import type { import { type ProfileField, ProfileFieldGroup, + ProfileFieldRows, } from "@/features/profile/ui/UserProfilePanelFields"; +import { + AGENT_DETAILS_FIELD_LABELS, + AgentDetailsRows, +} from "@/features/profile/ui/UserProfilePanelAgentDetails"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; @@ -51,9 +54,8 @@ import type { import { useFeatureEnabled } from "@/shared/features"; import { cn } from "@/shared/lib/cn"; import { useNow } from "@/shared/lib/useNow"; +import { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert"; import { Badge } from "@/shared/ui/badge"; -import { Button } from "@/shared/ui/button"; -import { Markdown } from "@/shared/ui/markdown"; // ── Summary view ───────────────────────────────────────────────────────────── @@ -89,15 +91,13 @@ export type ProfileSummaryViewProps = { agentSettingsFields: ProfileField[]; diagnosticsFields: ProfileField[]; diagnosticsSummary: string | null; - modelLabel: string; - onOpenAgentInfo: () => void; - onOpenAgentSettings: () => void; + onOpenActivity: () => void; + onOpenAgentConfiguration: () => void; onOpenChannels: () => void; onOpenDiagnostics: () => void; - onOpenInstruction: () => void; onOpenMemories: () => void; - onOpenModel: () => void; onOpenDm?: (pubkeys: string[]) => void; + onToggleAutoStart: () => void; persona?: AgentPersona; presenceStatus: "online" | "away" | "offline" | undefined; profile: ReturnType["data"]; @@ -139,15 +139,13 @@ export function ProfileSummaryView({ agentSettingsFields, diagnosticsFields, diagnosticsSummary, - modelLabel, - onOpenAgentInfo, - onOpenAgentSettings, + onOpenActivity, + onOpenAgentConfiguration, onOpenChannels, onOpenDiagnostics, - onOpenInstruction, onOpenMemories, - onOpenModel, onOpenDm, + onToggleAutoStart, persona, presenceStatus, profile, @@ -166,12 +164,44 @@ export function ProfileSummaryView({ const showChannelsIngress = channelsLoading || channelCount > 0 || isBot || relayAgent !== undefined; const showModelIngress = isOwner === true && isBot; - const showAgentSettingsIngress = + const runtimeConfigurationFields = agentSettingsFields.filter((field) => + AGENT_DETAILS_FIELD_LABELS.has(field.label), + ); + const summaryAgentDetailFields = agentSettingsFields.filter( + (field) => !AGENT_DETAILS_FIELD_LABELS.has(field.label), + ); + const showRuntimeConfigurationIngress = isOwner === true && - (agentSettingsFields.length > 0 || managedAgent?.backend.type === "local"); + isBot && + (showInstructionIngress || + showModelIngress || + runtimeConfigurationFields.length > 0); + const showAgentSettingsRows = summaryAgentDetailFields.length > 0; + const showAgentConfigurationRows = showAgentSettingsRows; const showDiagnosticsIngress = - diagnosticsFields.length > 0 || canOpenAgentLogs || canViewActivity; - const showAgentInfoIngress = agentInfoFields.length > 0; + diagnosticsFields.length > 0 || canOpenAgentLogs; + const showActivityIngress = canViewActivity; + const diagnosticsStatusField = diagnosticsFields.find( + (field) => field.label === "Status", + ); + const diagnosticsErrorField = diagnosticsFields.find( + (field) => field.label === "Last error", + ); + const diagnosticsTrailing = + diagnosticsErrorField !== undefined ? ( + + Error + + ) : ( + (diagnosticsStatusField?.displayNode ?? diagnosticsSummary ?? "View") + ); + const topLevelAgentInfoFields = agentInfoFields.filter( + (field) => + field.label === "Public key" || + field.label === "Owned by" || + field.label === "Owned by & responds to", + ); + const showTopLevelAgentInfo = topLevelAgentInfoFields.length > 0; const personaActionKey = persona?.id; return ( @@ -232,32 +262,14 @@ export function ProfileSummaryView({
) : null} - {showInstructionIngress || - showModelIngress || + {showAgentConfigurationRows || showMemoriesIngress || showChannelsIngress || - showAgentSettingsIngress || + showRuntimeConfigurationIngress || showDiagnosticsIngress || - showAgentInfoIngress ? ( + showActivityIngress || + showTopLevelAgentInfo ? (
- {showInstructionIngress ? ( - - ) : null} - {showModelIngress ? ( - - ) : null} {showMemoriesIngress ? ( ) : null} - {showAgentSettingsIngress ? ( - - ) : null} {showDiagnosticsIngress ? ( ) : null} - {showAgentInfoIngress ? ( + {showActivityIngress ? ( ) : null} + {showAgentConfigurationRows || + showRuntimeConfigurationIngress || + showTopLevelAgentInfo ? ( +
+ {showTopLevelAgentInfo ? ( + + ) : null} + + {showRuntimeConfigurationIngress ? ( + + ) : null} +
+ ) : null}
) : null} @@ -325,6 +341,7 @@ export function ProfileSummaryView({ onDelete={handleDeleteAgent} onDuplicatePersona={handleDuplicatePersona} onExportPersona={handleExportPersona} + onToggleAutoStart={onToggleAutoStart} personaActionKey={personaActionKey} /> ) : null} @@ -341,6 +358,25 @@ export function ProfileSummaryView({ ); } +function AdvancedDetailsRow({ onClick }: { onClick: () => void }) { + return ( + + ); +} + function ProfileWorkingBadge({ channelId, name, @@ -690,8 +726,10 @@ function ProfileIngressRow({ label: string; onClick: () => void; testId: string; - trailing?: string; + trailing?: React.ReactNode; }) { + const trailingTitle = typeof trailing === "string" ? trailing : undefined; + return ( + {hasLog ? ( +
+ +
) : null}
); diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs index d7839790d..a37b41399 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs @@ -157,10 +157,8 @@ test("parseProfilePanelView accepts all profile panel subviews", () => { for (const view of [ "summary", "info", - "settings", + "configuration", "diagnostics", - "model", - "instructions", "memories", "channels", "logs", @@ -169,6 +167,12 @@ test("parseProfilePanelView accepts all profile panel subviews", () => { } }); +test("parseProfilePanelView maps legacy agent config subviews to configuration", () => { + for (const view of ["instructions", "model", "settings"]) { + assert.equal(parseProfilePanelView(view), "configuration"); + } +}); + test("profilePanelViewFromSearch falls back to summary for invalid values", () => { assert.equal(parseProfilePanelView("missing"), null); assert.equal(profilePanelViewFromSearch("missing"), "summary"); diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index c7abfb129..b11a5d5c3 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -18,10 +18,8 @@ export type ProfileChannelLink = { export type ProfilePanelView = | "summary" | "info" - | "settings" + | "configuration" | "diagnostics" - | "model" - | "instructions" | "memories" | "channels" | "logs"; @@ -29,10 +27,8 @@ export type ProfilePanelView = export const PROFILE_PANEL_VIEW_TITLES: Record = { summary: "Profile", info: "Agent info", - settings: "Agent settings", + configuration: "Advanced", diagnostics: "Diagnostics", - model: "Model", - instructions: "Agent instruction", memories: "Memories", channels: "Channels", logs: "Harness log", @@ -42,11 +38,22 @@ const PROFILE_PANEL_VIEWS = new Set( Object.keys(PROFILE_PANEL_VIEW_TITLES) as ProfilePanelView[], ); +const LEGACY_PROFILE_PANEL_VIEW_ALIASES: Record = { + instructions: "configuration", + model: "configuration", + settings: "configuration", +}; + export function parseProfilePanelView(value: unknown): ProfilePanelView | null { - return typeof value === "string" && - PROFILE_PANEL_VIEWS.has(value as ProfilePanelView) - ? (value as ProfilePanelView) - : null; + if (typeof value !== "string") { + return null; + } + + if (PROFILE_PANEL_VIEWS.has(value as ProfilePanelView)) { + return value as ProfilePanelView; + } + + return LEGACY_PROFILE_PANEL_VIEW_ALIASES[value] ?? null; } export function profilePanelViewFromSearch(value: unknown): ProfilePanelView { @@ -60,6 +67,7 @@ export type UserProfilePanelProps = { layout?: "standalone" | "split"; onClose: () => void; onOpenDm?: (pubkeys: string[]) => void; + onOpenProfile?: (pubkey: string) => void; onResetWidth?: () => void; onResizeStart?: (event: React.PointerEvent) => void; onViewChange?: ( @@ -132,6 +140,7 @@ export function buildPersonaDraftProfile(persona: AgentPersona): Profile { avatarUrl: persona.avatarUrl, about: null, nip05Handle: null, + ownerPubkey: null, }; } diff --git a/desktop/src/features/pulse/ui/PulseScreen.tsx b/desktop/src/features/pulse/ui/PulseScreen.tsx index 6a0563ec8..2bb6f9cc8 100644 --- a/desktop/src/features/pulse/ui/PulseScreen.tsx +++ b/desktop/src/features/pulse/ui/PulseScreen.tsx @@ -6,6 +6,7 @@ import { type ProfilePanelView, UserProfilePanel, } from "@/features/profile/ui/UserProfilePanel"; +import { profilePanelViewFromSearch } from "@/features/profile/ui/UserProfilePanelUtils"; import { PulseView } from "@/features/pulse/ui/PulseView"; import { useIdentityQuery } from "@/shared/api/hooks"; import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; @@ -18,10 +19,7 @@ export function PulseScreen() { const identityQuery = useIdentityQuery(); const { applyPatch, values } = useHistorySearchState(PULSE_PANEL_SEARCH_KEYS); const profilePanelPubkey = values.profile; - const profilePanelView: ProfilePanelView = - values.profileView === "memories" || values.profileView === "channels" - ? values.profileView - : "summary"; + const profilePanelView = profilePanelViewFromSearch(values.profileView); const handleOpenProfilePanel = React.useCallback( (pubkey: string) => applyPatch({ profile: pubkey, profileView: null }), [applyPatch], @@ -59,6 +57,7 @@ export function PulseScreen() { currentPubkey={identityQuery.data?.pubkey} onClose={handleCloseProfilePanel} onOpenDm={handleOpenDm} + onOpenProfile={handleOpenProfilePanel} onResetWidth={threadPanelWidth.onResetWidth} onResizeStart={threadPanelWidth.onResizeStart} onViewChange={handleProfilePanelViewChange} diff --git a/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx b/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx index 5a6f101cb..49efc2bdc 100644 --- a/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx +++ b/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx @@ -36,6 +36,7 @@ import { DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; import { Input } from "@/shared/ui/input"; +import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; import { VirtualizedList } from "@/shared/ui/VirtualizedList"; type AssignableRelayRole = Exclude; @@ -353,13 +354,15 @@ export function RelayMembersSettingsCard({ return (
-
-

Relay Access

-

- Manage who can connect to this relay. Owners can invite admins or - members; admins can invite members. -

-
+ + Manage who can connect to this relay. Owners can invite admins or + members; admins can invite members. + + } + />
diff --git a/desktop/src/features/search/ui/SearchPromptPlaceholder.tsx b/desktop/src/features/search/ui/SearchPromptPlaceholder.tsx new file mode 100644 index 000000000..801fe7e33 --- /dev/null +++ b/desktop/src/features/search/ui/SearchPromptPlaceholder.tsx @@ -0,0 +1,206 @@ +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import * as React from "react"; + +const SEARCH_PROMPT_WORDS = [ + "everything", + "a channel", + "a message", + "a thread", + "an agent", +] as const; +const SEARCH_PROMPT_ROTATION_MS = 3200; +const SEARCH_PROMPT_EASE = [0.22, 1, 0.36, 1] as const; +const SEARCH_PROMPT_EXIT_EASE = [0.64, 0, 0.78, 0] as const; +const SEARCH_PROMPT_ENTER_DURATION_SECONDS = 0.54; +const SEARCH_PROMPT_EXIT_DURATION_SECONDS = 0.32; +const SEARCH_PROMPT_ENTER_STAGGER_SECONDS = 0.014; +const SEARCH_PROMPT_EXIT_STAGGER_SECONDS = 0.008; +const SEARCH_PROMPT_Y_OFFSET = "0.5rem"; +const SEARCH_PROMPT_NEGATIVE_Y_OFFSET = "-0.5rem"; +const SEARCH_PROMPT_BLUR = "0.25rem"; + +const searchPromptPhraseVariants = { + animate: { + transition: { + staggerChildren: SEARCH_PROMPT_ENTER_STAGGER_SECONDS, + }, + }, + exit: { + transition: { + staggerChildren: SEARCH_PROMPT_EXIT_STAGGER_SECONDS, + }, + }, + initial: {}, +}; + +const searchPromptCharacterVariants = { + animate: { + filter: "blur(0)", + opacity: 1, + transition: { + duration: SEARCH_PROMPT_ENTER_DURATION_SECONDS, + ease: SEARCH_PROMPT_EASE, + }, + y: 0, + }, + exit: { + filter: `blur(${SEARCH_PROMPT_BLUR})`, + opacity: 0, + transition: { + duration: SEARCH_PROMPT_EXIT_DURATION_SECONDS, + ease: SEARCH_PROMPT_EXIT_EASE, + }, + y: SEARCH_PROMPT_NEGATIVE_Y_OFFSET, + }, + initial: { + filter: `blur(${SEARCH_PROMPT_BLUR})`, + opacity: 0, + y: SEARCH_PROMPT_Y_OFFSET, + }, +}; + +function getPromptCharacters(value: string) { + const characterCounts = new Map(); + + return [...value].map((character) => { + const occurrence = characterCounts.get(character) ?? 0; + characterCounts.set(character, occurrence + 1); + + return { + character, + key: `${character}-${occurrence}`, + }; + }); +} + +function getPromptEnterTotalSeconds(characterCount: number) { + return ( + SEARCH_PROMPT_ENTER_DURATION_SECONDS + + Math.max(0, characterCount - 1) * SEARCH_PROMPT_ENTER_STAGGER_SECONDS + ); +} + +export function SearchPromptPlaceholder() { + const shouldReduceMotion = useReducedMotion(); + const [wordIndex, setWordIndex] = React.useState(0); + const activeWord = SEARCH_PROMPT_WORDS[wordIndex]; + const activeCharacters = React.useMemo( + () => getPromptCharacters(activeWord), + [activeWord], + ); + const widthAnimationDurationSeconds = getPromptEnterTotalSeconds( + activeCharacters.length, + ); + const measureRef = React.useRef(null); + const pendingWordWidthRef = React.useRef(null); + const [wordWidth, setWordWidth] = React.useState(null); + + React.useEffect(() => { + if (shouldReduceMotion) { + setWordIndex(0); + return; + } + + const intervalId = window.setInterval(() => { + setWordIndex((currentIndex) => { + return (currentIndex + 1) % SEARCH_PROMPT_WORDS.length; + }); + }, SEARCH_PROMPT_ROTATION_MS); + + return () => window.clearInterval(intervalId); + }, [shouldReduceMotion]); + + React.useLayoutEffect(() => { + if (shouldReduceMotion || activeWord.length === 0) { + return; + } + + const width = measureRef.current?.getBoundingClientRect().width; + if (typeof width === "number" && Number.isFinite(width)) { + if (wordWidth === null) { + setWordWidth(width); + } else { + pendingWordWidthRef.current = width; + } + } + }, [activeWord, shouldReduceMotion, wordWidth]); + + const handleWordExitComplete = React.useCallback(() => { + const nextWidth = pendingWordWidthRef.current; + if (nextWidth === null) { + return; + } + + pendingWordWidthRef.current = null; + setWordWidth(nextWidth); + }, []); + + if (shouldReduceMotion) { + return ( + + ); + } + + return ( + + ); +} diff --git a/desktop/src/features/search/ui/SearchResultItem.tsx b/desktop/src/features/search/ui/SearchResultItem.tsx index b8b583dd8..02e4b8324 100644 --- a/desktop/src/features/search/ui/SearchResultItem.tsx +++ b/desktop/src/features/search/ui/SearchResultItem.tsx @@ -1,32 +1,66 @@ import type * as React from "react"; -import { ArrowRight, FileText, Hash, type LucideIcon } from "lucide-react"; +import { + ArrowRight, + Bot, + FileText, + Hash, + MessageCircle, + Plus, + User, + type LucideIcon, +} from "lucide-react"; import { resolveUserLabel, resolveUserSecondaryLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; -import type { Channel, SearchHit } from "@/shared/api/types"; +import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types"; import { Badge } from "@/shared/ui/badge"; import { UserAvatar } from "@/shared/ui/UserAvatar"; export type SearchResult = + | { + kind: "action"; + action: { + description?: string; + id: "create-agent" | "create-channel"; + title: string; + }; + } | { kind: "channel"; channel: Channel } + | { kind: "user"; user: UserSearchResult } | { kind: "message"; hit: SearchHit }; export function resultKey(result: SearchResult) { + if (result.kind === "action") { + return `action-${result.action.id}`; + } + if (result.kind === "channel") { return `channel-${result.channel.id}`; } + if (result.kind === "user") { + return `user-${result.user.pubkey}`; + } + return `message-${result.hit.eventId}`; } export function resultTestId(result: SearchResult) { + if (result.kind === "action") { + return `search-result-action-${result.action.id}`; + } + if (result.kind === "channel") { return `search-result-channel-${result.channel.id}`; } + if (result.kind === "user") { + return `search-result-user-${result.user.pubkey}`; + } + return `search-result-${result.hit.eventId}`; } @@ -34,6 +68,14 @@ export function resultIcon( result: SearchResult, channelLookup: ReadonlyMap, ) { + if (result.kind === "action") { + return result.action.id === "create-agent" ? Bot : Plus; + } + + if (result.kind === "user") { + return result.user.isAgent ? Bot : User; + } + const channelType = result.kind === "channel" ? result.channel.channelType @@ -41,7 +83,15 @@ export function resultIcon( ? channelLookup.get(result.hit.channelId)?.channelType : undefined; - return channelType === "forum" ? FileText : Hash; + if (channelType === "forum") { + return FileText; + } + + if (channelType === "dm") { + return MessageCircle; + } + + return Hash; } export function SearchResultShell({ diff --git a/desktop/src/features/search/ui/TopbarSearch.tsx b/desktop/src/features/search/ui/TopbarSearch.tsx index da7452012..47719682e 100644 --- a/desktop/src/features/search/ui/TopbarSearch.tsx +++ b/desktop/src/features/search/ui/TopbarSearch.tsx @@ -12,42 +12,58 @@ import { resultTestId, type SearchResult, } from "@/features/search/ui/SearchResultItem"; -import type { Channel, SearchHit } from "@/shared/api/types"; +import { SearchPromptPlaceholder } from "@/features/search/ui/SearchPromptPlaceholder"; +import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog"; +import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen"; import { - Dialog, - DialogContent, - DialogTitle, - DialogTrigger, -} from "@/shared/ui/dialog"; + MENTION_CHIP_BASE_CLASSES, + MESSAGE_MARKDOWN_CLASS, +} from "@/shared/ui/mentionChip"; import { Skeleton } from "@/shared/ui/skeleton"; import { UserAvatar } from "@/shared/ui/UserAvatar"; type TopbarSearchProps = { + channelLabels?: Record; channels: Channel[]; className?: string; currentPubkey?: string; focusRequest?: number; onOpenChannel: (channelId: string) => void; onOpenResult: (hit: SearchHit) => void; + onOpenUser?: (user: UserSearchResult) => void | Promise; + onCreateAgent?: () => void | Promise; + onCreateChannel?: () => void | Promise; + suggestionChannels?: Channel[]; + variant?: "bar" | "icon"; }; -function describeSearchHit(hit: SearchHit) { - switch (hit.kind) { - case 45001: - return "Forum post"; - case 45003: - return "Forum reply"; - case 43001: - return "Agent job"; - case 43003: - return "Agent update"; - case 46010: - return "Approval"; - default: - return "Message"; - } -} +const MAX_SEARCH_SUGGESTIONS = 4; +const SEARCH_SECTION_TITLE_CLASS = + "px-3 pb-1.5 pt-2 text-xs font-medium text-muted-foreground/70"; +const SEARCH_RESULT_SECTION_ORDER = [ + "channels", + "direct-messages", + "people", + "agents", + "messages", + "actions", +] as const; + +type SearchResultSectionKey = (typeof SEARCH_RESULT_SECTION_ORDER)[number]; + +type SearchResultSection = { + key: SearchResultSectionKey; + results: SearchResult[]; + title: string; +}; + +type SearchHitContextLabel = { + channelLabel: string | null; + text: string; +}; function truncateResultText(content: string, maxLength = 96) { const trimmed = content.trim(); @@ -66,15 +82,19 @@ function formatRelativeTime(unixSeconds: number) { const diff = Math.floor(Date.now() / 1_000) - unixSeconds; if (diff < 60) { - return "now"; + return "just now"; } if (diff < 60 * 60) { - return `${Math.floor(diff / 60)}m`; + return `${Math.floor(diff / 60)}m ago`; } if (diff < 60 * 60 * 24) { - return `${Math.floor(diff / (60 * 60))}h`; + return `${Math.floor(diff / (60 * 60))}h ago`; + } + + if (diff < 60 * 60 * 24 * 7) { + return `${Math.floor(diff / (60 * 60 * 24))}d ago`; } return new Intl.DateTimeFormat("en-US", { @@ -83,6 +103,231 @@ function formatRelativeTime(unixSeconds: number) { }).format(new Date(unixSeconds * 1_000)); } +function getChannelActivityTime(channel: Channel) { + if (!channel.lastMessageAt) { + return 0; + } + + const timestamp = Date.parse(channel.lastMessageAt); + return Number.isFinite(timestamp) ? timestamp : 0; +} + +function getChannelSuggestionMeta(channel: Channel) { + const activityTime = getChannelActivityTime(channel); + + if (activityTime > 0) { + return formatRelativeTime(Math.floor(activityTime / 1_000)); + } + + return null; +} + +function getChannelDisplayName( + channel: Channel, + channelLabels?: Record, +) { + return channelLabels?.[channel.id]?.trim() || channel.name; +} + +function getChannelPreview(channel: Channel) { + if (channel.channelType === "dm") { + return ""; + } + + if (channel.description.trim()) { + return channel.description; + } + + return ""; +} + +function getUserDisplayName(user: UserSearchResult) { + return ( + user.displayName?.trim() || + user.nip05Handle?.trim() || + `${normalizePubkey(user.pubkey).slice(0, 8)}...` + ); +} + +function getUserSecondaryLabel(user: UserSearchResult) { + const displayName = user.displayName?.trim(); + const nip05Handle = user.nip05Handle?.trim(); + + if (nip05Handle && nip05Handle !== displayName) { + return nip05Handle; + } + + return null; +} + +function getSearchHitChannelName( + hit: SearchHit, + channelLookup: ReadonlyMap, + channelLabels?: Record, +) { + const channel = hit.channelId ? channelLookup.get(hit.channelId) : null; + const channelName = + (hit.channelId ? channelLabels?.[hit.channelId]?.trim() : null) || + hit.channelName?.trim() || + channel?.name.trim() || + null; + + if (!channelName) { + return null; + } + + return channelName; +} + +function getSearchHitContextLabel( + hit: SearchHit, + channelLookup: ReadonlyMap, + channelLabels?: Record, +): SearchHitContextLabel { + const channel = hit.channelId ? channelLookup.get(hit.channelId) : null; + const channelName = getSearchHitChannelName( + hit, + channelLookup, + channelLabels, + ); + + if (channel?.channelType === "dm") { + return { + channelLabel: null, + text: "Direct message", + }; + } + + const isThread = hit.kind === 45003 || Boolean(hit.threadRootId); + + return { + channelLabel: channelName, + text: channelName + ? `${isThread ? "Thread" : "Message"} in` + : isThread + ? "Thread" + : "Message", + }; +} + +function getResultSectionKey(result: SearchResult): SearchResultSectionKey { + if (result.kind === "channel") { + return result.channel.channelType === "dm" ? "direct-messages" : "channels"; + } + + if (result.kind === "user") { + return result.user.isAgent ? "agents" : "people"; + } + + if (result.kind === "action") { + return "actions"; + } + + return "messages"; +} + +function getSectionTitle(sectionKey: SearchResultSectionKey) { + switch (sectionKey) { + case "channels": + return "Channels"; + case "direct-messages": + return "Direct messages"; + case "people": + return "People"; + case "agents": + return "Agents"; + case "messages": + return "Most relevant"; + case "actions": + return "Actions"; + } +} + +function SearchHitContextLine({ label }: { label: SearchHitContextLabel }) { + return ( + + {label.text} + {label.channelLabel ? ( + + #{label.channelLabel} + + ) : null} + + ); +} + +function groupSearchResults(results: SearchResult[]): SearchResultSection[] { + const resultsBySection = new Map(); + + for (const result of results) { + const sectionKey = getResultSectionKey(result); + const sectionResults = resultsBySection.get(sectionKey) ?? []; + sectionResults.push(result); + resultsBySection.set(sectionKey, sectionResults); + } + + return SEARCH_RESULT_SECTION_ORDER.flatMap((sectionKey) => { + const sectionResults = resultsBySection.get(sectionKey); + + if (!sectionResults || sectionResults.length === 0) { + return []; + } + + return [ + { + key: sectionKey, + results: sectionResults, + title: getSectionTitle(sectionKey), + }, + ]; + }); +} + +function getSuggestedSearchResults(channels: Channel[]) { + return channels + .filter( + (channel) => + !channel.archivedAt && + (channel.isMember || channel.channelType === "dm"), + ) + .sort((a, b) => { + const activityDiff = + getChannelActivityTime(b) - getChannelActivityTime(a); + if (activityDiff !== 0) { + return activityDiff; + } + + const typeRank = (channel: Channel) => + channel.channelType === "dm" + ? 0 + : channel.channelType === "stream" + ? 1 + : 2; + const rankDiff = typeRank(a) - typeRank(b); + if (rankDiff !== 0) { + return rankDiff; + } + + return a.name.localeCompare(b.name); + }) + .slice(0, MAX_SEARCH_SUGGESTIONS) + .map((channel) => ({ + kind: "channel" as const, + channel, + })); +} + const searchSkeletonRows = [ { iconShape: "rounded-md", @@ -140,17 +385,25 @@ function SearchResultsSkeleton() { } export function TopbarSearch({ + channelLabels, channels, className, currentPubkey, focusRequest = 0, onOpenChannel, onOpenResult, + onOpenUser, + onCreateAgent, + onCreateChannel, + suggestionChannels, + variant = "bar", }: TopbarSearchProps) { const [isOpen, setIsOpen] = React.useState(false); const [selectedMenuIndex, setSelectedMenuIndex] = React.useState(0); const triggerRef = React.useRef(null); const dialogInputRef = React.useRef(null); + const { cancelDeferredModalOpen, openAfterExit, openNextFrame } = + useDeferredModalOpen(); const { channelLookup, debouncedQuery, @@ -159,8 +412,89 @@ export function TopbarSearch({ results, searchQuery, setQuery, - } = useSearchResults({ channels, enabled: isOpen, limit: 8 }); + userSearchQuery, + } = useSearchResults({ channelLabels, channels, enabled: isOpen, limit: 8 }); const trimmedQuery = query.trim(); + const isIconVariant = variant === "icon"; + const currentPubkeyNormalized = currentPubkey + ? normalizePubkey(currentPubkey) + : null; + const suggestedResults = React.useMemo( + () => getSuggestedSearchResults(suggestionChannels ?? channels), + [channels, suggestionChannels], + ); + const suggestionActionResults = React.useMemo(() => { + const actions: SearchResult[] = []; + + if (onCreateChannel) { + actions.push({ + kind: "action", + action: { + id: "create-channel", + title: "Create a new channel", + }, + }); + } + + if (onCreateAgent) { + actions.push({ + kind: "action", + action: { + id: "create-agent", + title: "Create a new agent", + }, + }); + } + + return actions; + }, [onCreateAgent, onCreateChannel]); + const suggestionResults = React.useMemo( + () => [...suggestedResults, ...suggestionActionResults], + [suggestedResults, suggestionActionResults], + ); + const isShowingSuggestions = + debouncedQuery.length < MIN_SEARCH_QUERY_LENGTH && + trimmedQuery.length < MIN_SEARCH_QUERY_LENGTH; + const searchableResults = React.useMemo( + () => + results.filter( + (result) => + result.kind !== "user" || + normalizePubkey(result.user.pubkey) !== currentPubkeyNormalized, + ), + [currentPubkeyNormalized, results], + ); + const searchResultSections = React.useMemo( + () => groupSearchResults(searchableResults), + [searchableResults], + ); + const groupedSearchResults = React.useMemo( + () => searchResultSections.flatMap((section) => section.results), + [searchResultSections], + ); + const activeResults = isShowingSuggestions + ? suggestionResults + : groupedSearchResults; + const isSearchLoading = searchQuery.isLoading || userSearchQuery.isLoading; + + const openSearchDialog = React.useCallback(() => { + setSelectedMenuIndex(0); + openNextFrame(() => setIsOpen(true)); + }, [openNextFrame]); + + const handleSearchOpenChange = React.useCallback( + (nextOpen: boolean) => { + if (nextOpen) { + openSearchDialog(); + return; + } + + cancelDeferredModalOpen(); + setSelectedMenuIndex(0); + setIsOpen(false); + }, + [cancelDeferredModalOpen, openSearchDialog], + ); const openResult = React.useCallback( (result: SearchResult) => { @@ -172,9 +506,36 @@ export function TopbarSearch({ return; } + if (result.kind === "user") { + void onOpenUser?.(result.user); + return; + } + + if (result.kind === "action") { + setSelectedMenuIndex(0); + if (result.action.id === "create-channel") { + openAfterExit(() => { + void onCreateChannel?.(); + }); + } else { + openAfterExit(() => { + void onCreateAgent?.(); + }); + } + return; + } + onOpenResult(result.hit); }, - [onOpenChannel, onOpenResult, setQuery], + [ + onCreateAgent, + onCreateChannel, + onOpenChannel, + onOpenResult, + onOpenUser, + openAfterExit, + setQuery, + ], ); React.useEffect(() => { @@ -182,9 +543,9 @@ export function TopbarSearch({ return; } - setIsOpen(true); + openSearchDialog(); triggerRef.current?.focus(); - }, [focusRequest]); + }, [focusRequest, openSearchDialog]); React.useEffect(() => { if (!isOpen) { @@ -202,25 +563,25 @@ export function TopbarSearch({ React.useEffect(() => { setSelectedMenuIndex((current) => { - if (results.length === 0) { + if (activeResults.length === 0) { return 0; } - return Math.min(current, results.length - 1); + return Math.min(current, activeResults.length - 1); }); - }, [results]); + }, [activeResults]); const handleDialogInputKeyDown = React.useCallback( (event: React.KeyboardEvent) => { - if (event.key === "ArrowDown" && results.length > 0) { + if (event.key === "ArrowDown" && activeResults.length > 0) { event.preventDefault(); setSelectedMenuIndex((current) => - Math.min(current + 1, results.length - 1), + Math.min(current + 1, activeResults.length - 1), ); return; } - if (event.key === "ArrowUp" && results.length > 0) { + if (event.key === "ArrowUp" && activeResults.length > 0) { event.preventDefault(); setSelectedMenuIndex((current) => Math.max(current - 1, 0)); return; @@ -228,132 +589,261 @@ export function TopbarSearch({ if (event.key === "Enter" && !event.nativeEvent.isComposing) { event.preventDefault(); - const result = results[selectedMenuIndex]; + const result = activeResults[selectedMenuIndex]; if (result) { openResult(result); } } }, - [openResult, results, selectedMenuIndex], + [activeResults, openResult, selectedMenuIndex], ); - const searchResultContent = - debouncedQuery.length < MIN_SEARCH_QUERY_LENGTH ? ( -
-

Type at least two characters for live suggestions.

-
- ) : searchQuery.isLoading && results.length === 0 ? ( - - ) : searchQuery.error instanceof Error && results.length === 0 ? ( -

- {searchQuery.error.message} -

- ) : results.length === 0 ? ( -

- No matches for {trimmedQuery}. -

- ) : ( -
- {results.map((result, index) => ( - - ))} -
+ )} + + {result.kind !== "message" && trailingLabel ? ( + + {trailingLabel} + + ) : null} + ); + }; + + const renderSearchResultSections = (sections: SearchResultSection[]) => { + let resultIndex = 0; + + return sections.map((section) => ( +
+
{section.title}
+ {section.results.map((result) => + renderSearchResultRow(result, resultIndex++), + )} +
+ )); + }; + + const searchResultContent = isShowingSuggestions ? ( + suggestionResults.length === 0 ? ( +
+

No recent activity yet.

+
+ ) : ( +
+ {(() => { + let resultIndex = 0; + + return ( + <> + {suggestedResults.length > 0 ? ( +
+
+ Recent activity +
+ {suggestedResults.map((result) => + renderSearchResultRow(result, resultIndex++), + )} +
+ ) : null} + {suggestionActionResults.length > 0 ? ( +
+
Actions
+ {suggestionActionResults.map((result) => + renderSearchResultRow(result, resultIndex++), + )} +
+ ) : null} + + ); + })()} +
+ ) + ) : isSearchLoading && searchableResults.length === 0 ? ( + + ) : searchQuery.error instanceof Error && searchableResults.length === 0 ? ( +

+ {searchQuery.error.message} +

+ ) : searchableResults.length === 0 ? ( +

+ No matches for {trimmedQuery}. +

+ ) : ( +
+ {renderSearchResultSections(searchResultSections)} +
+ ); return (
- - - - + + { event.preventDefault(); @@ -368,22 +858,28 @@ export function TopbarSearch({ Search everything
- { - setQuery(event.target.value); - setSelectedMenuIndex(0); - }} - onKeyDown={handleDialogInputKeyDown} - placeholder="Search everything" - spellCheck={false} - value={query} - /> +
+ {query.length === 0 ? ( + + + + ) : null} + { + setQuery(event.target.value); + setSelectedMenuIndex(0); + }} + onKeyDown={handleDialogInputKeyDown} + spellCheck={false} + value={query} + /> +
ESC diff --git a/desktop/src/features/search/useSearchResults.ts b/desktop/src/features/search/useSearchResults.ts index 116f72744..6c71b0b72 100644 --- a/desktop/src/features/search/useSearchResults.ts +++ b/desktop/src/features/search/useSearchResults.ts @@ -1,17 +1,46 @@ import * as React from "react"; -import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { + useManagedAgentsQuery, + useRelayAgentsQuery, +} from "@/features/agents/hooks"; +import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; +import { + useUserSearchQuery, + useUsersBatchQuery, +} from "@/features/profile/hooks"; +import { rankUserCandidatesBySearch } from "@/features/profile/lib/userCandidateSearch"; import { useSearchMessagesQuery } from "@/features/search/hooks"; import type { SearchResult } from "@/features/search/ui/SearchResultItem"; -import type { Channel } from "@/shared/api/types"; +import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; export const MIN_SEARCH_QUERY_LENGTH = 2; +function formatUserResultName(user: UserSearchResult) { + return user.displayName?.trim() || user.nip05Handle?.trim() || user.pubkey; +} + +function dedupeSearchHits(hits: SearchHit[]) { + const seenEventIds = new Set(); + + return hits.filter((hit) => { + if (seenEventIds.has(hit.eventId)) { + return false; + } + + seenEventIds.add(hit.eventId); + return true; + }); +} + export function useSearchResults({ + channelLabels, channels, enabled, limit = 12, }: { + channelLabels?: Record; channels: Channel[]; enabled: boolean; limit?: number; @@ -19,6 +48,7 @@ export function useSearchResults({ const [query, setQuery] = React.useState(""); const [debouncedQuery, setDebouncedQuery] = React.useState(""); const [selectedIndex, setSelectedIndex] = React.useState(0); + const isArchivedDiscovery = useIsArchivedPredicate(); const channelLookup = React.useMemo( () => new Map(channels.map((channel) => [channel.id, channel])), @@ -30,7 +60,10 @@ export function useSearchResults({ limit, }); - const messageResults = searchQuery.data?.hits ?? []; + const messageResults = React.useMemo( + () => dedupeSearchHits(searchQuery.data?.hits ?? []), + [searchQuery.data?.hits], + ); const channelResults = React.useMemo(() => { if (debouncedQuery.length < MIN_SEARCH_QUERY_LENGTH) { return []; @@ -41,25 +74,179 @@ export function useSearchResults({ return channels .filter( (channel) => - channel.channelType !== "dm" && (channel.archivedAt ? channel.isMember : channel.visibility === "open" || channel.isMember) && - (channel.name.toLowerCase().includes(normalizedQuery) || - channel.description.toLowerCase().includes(normalizedQuery)), + [ + channel.name, + channel.description, + channelLabels?.[channel.id] ?? "", + ].some((value) => value.toLowerCase().includes(normalizedQuery)), ) .sort((a, b) => { - const aNameMatches = a.name.toLowerCase().includes(normalizedQuery); - const bNameMatches = b.name.toLowerCase().includes(normalizedQuery); + const aDisplayName = channelLabels?.[a.id]?.trim() || a.name; + const bDisplayName = channelLabels?.[b.id]?.trim() || b.name; + const aNameMatches = aDisplayName + .toLowerCase() + .includes(normalizedQuery); + const bNameMatches = bDisplayName + .toLowerCase() + .includes(normalizedQuery); if (aNameMatches !== bNameMatches) { return aNameMatches ? -1 : 1; } - return a.name.localeCompare(b.name); + return aDisplayName.localeCompare(bDisplayName); }) .slice(0, 5); - }, [channels, debouncedQuery]); + }, [channelLabels, channels, debouncedQuery]); + + const userSearchQuery = useUserSearchQuery(debouncedQuery, { + enabled: enabled && debouncedQuery.length >= MIN_SEARCH_QUERY_LENGTH, + limit, + }); + const managedAgentsQuery = useManagedAgentsQuery({ enabled }); + const relayAgentsQuery = useRelayAgentsQuery({ enabled }); + const managedAgentPubkeys = React.useMemo( + () => + new Set( + (managedAgentsQuery.data ?? []).map((agent) => + normalizePubkey(agent.pubkey), + ), + ), + [managedAgentsQuery.data], + ); + const relayAgentPubkeys = React.useMemo( + () => + new Set( + (relayAgentsQuery.data ?? []).map((agent) => + normalizePubkey(agent.pubkey), + ), + ), + [relayAgentsQuery.data], + ); + const eligibleAgentPubkeys = React.useMemo(() => { + const pubkeys = new Set(managedAgentPubkeys); + + for (const agent of relayAgentsQuery.data ?? []) { + if (agent.respondTo === "anyone") { + pubkeys.add(normalizePubkey(agent.pubkey)); + } + } + + return pubkeys; + }, [managedAgentPubkeys, relayAgentsQuery.data]); + const userResults = React.useMemo(() => { + if (debouncedQuery.length < MIN_SEARCH_QUERY_LENGTH) { + return []; + } + + const normalizedQuery = debouncedQuery.toLowerCase(); + const candidatesByPubkey = new Map(); + + const matchesQuery = (candidate: UserSearchResult) => + [ + candidate.displayName ?? "", + candidate.nip05Handle ?? "", + candidate.isAgent ? "agent" : "", + normalizePubkey(candidate.pubkey), + ].some((value) => value.toLowerCase().includes(normalizedQuery)); + + const addCandidate = (candidate: UserSearchResult) => { + const pubkey = normalizePubkey(candidate.pubkey); + + if (isArchivedDiscovery(pubkey)) { + return; + } + + const isKnownAgent = + candidate.isAgent || + managedAgentPubkeys.has(pubkey) || + relayAgentPubkeys.has(pubkey); + + if (isKnownAgent && !eligibleAgentPubkeys.has(pubkey)) { + return; + } + + const existing = candidatesByPubkey.get(pubkey); + if (!existing) { + candidatesByPubkey.set(pubkey, { + ...candidate, + pubkey, + isAgent: isKnownAgent, + }); + return; + } + + candidatesByPubkey.set(pubkey, { + pubkey, + avatarUrl: existing.avatarUrl ?? candidate.avatarUrl ?? null, + displayName: + candidate.isAgent && candidate.displayName?.trim() + ? candidate.displayName + : (existing.displayName ?? candidate.displayName), + nip05Handle: existing.nip05Handle ?? candidate.nip05Handle ?? null, + ownerPubkey: existing.ownerPubkey ?? candidate.ownerPubkey ?? null, + isAgent: existing.isAgent || isKnownAgent, + }); + }; + + for (const user of userSearchQuery.data ?? []) { + addCandidate(user); + } + + for (const agent of relayAgentsQuery.data ?? []) { + if (agent.respondTo !== "anyone") { + continue; + } + + const candidate = { + pubkey: agent.pubkey, + displayName: agent.name, + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + isAgent: true, + }; + + if (matchesQuery(candidate)) { + addCandidate(candidate); + } + } + + for (const agent of managedAgentsQuery.data ?? []) { + const candidate = { + pubkey: agent.pubkey, + displayName: agent.name, + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + isAgent: true, + }; + + if (matchesQuery(candidate)) { + addCandidate(candidate); + } + } + + return rankUserCandidatesBySearch({ + candidates: [...candidatesByPubkey.values()], + getLabel: formatUserResultName, + limit, + query: debouncedQuery, + }); + }, [ + debouncedQuery, + eligibleAgentPubkeys, + isArchivedDiscovery, + limit, + managedAgentPubkeys, + managedAgentsQuery.data, + relayAgentPubkeys, + relayAgentsQuery.data, + userSearchQuery.data, + ]); const results = React.useMemo( () => [ @@ -67,12 +254,16 @@ export function useSearchResults({ kind: "channel" as const, channel, })), + ...userResults.map((user) => ({ + kind: "user" as const, + user, + })), ...messageResults.map((hit) => ({ kind: "message" as const, hit, })), ], - [channelResults, messageResults], + [channelResults, messageResults, userResults], ); const resultProfilesQuery = useUsersBatchQuery( @@ -129,5 +320,7 @@ export function useSearchResults({ selectedResult: results[selectedIndex], setQuery, setSelectedIndex, + userResults, + userSearchQuery, }; } diff --git a/desktop/src/features/settings/UpdateChecker.tsx b/desktop/src/features/settings/UpdateChecker.tsx index 1c43c2097..2deba6ed4 100644 --- a/desktop/src/features/settings/UpdateChecker.tsx +++ b/desktop/src/features/settings/UpdateChecker.tsx @@ -4,20 +4,17 @@ import { SettingsOptionGroup, SettingsOptionRow, } from "./ui/SettingsOptionGroup"; +import { SettingsSectionHeader } from "./ui/SettingsSectionHeader"; export function UpdateChecker() { const { status, checkForUpdate, relaunch } = useUpdaterContext(); return ( -
-
-

- Software Updates -

-

- Keep Buzz up to date with the latest features and fixes. -

-
+
+ {status.state === "idle" && ( diff --git a/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx b/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx index de2527bfc..28611167c 100644 --- a/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx @@ -33,6 +33,7 @@ import type { UpdateChannelTemplateInput, } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; import { AlertDialog, AlertDialogAction, @@ -103,27 +104,26 @@ export function ChannelTemplatesSettingsCard() { return (
-
-
-

- Channel Templates -

-

+ Save reusable channel configurations and apply them when creating new channels. -

-
- -
+ + } + action={ + + } + /> {templatesQuery.isLoading ? (

diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index d78eab3f9..57e5ff8b8 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -18,6 +18,7 @@ import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { SettingsOptionGroup } from "./SettingsOptionGroup"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; function StatusIcon({ availability, @@ -268,32 +269,28 @@ export function DoctorSettingsPanel() { } return ( -

-
-
-

Doctor

-

- Verify the ACP runtime commands available to the desktop app. -

-
- - -
+
+ { + setInstallResults({}); + void runtimesQuery.refetch(); + }} + size="sm" + type="button" + variant="outline" + > + + Re-run + + } + />
diff --git a/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx b/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx index 96ac8dedb..4684829d3 100644 --- a/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx +++ b/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx @@ -1,6 +1,7 @@ import { desktopFeatures, useFeatureToggle } from "@/shared/features"; import type { FeatureDefinition } from "@/shared/features"; import { Switch } from "@/shared/ui/switch"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; function FeatureRow({ feature }: { feature: FeatureDefinition }) { const [enabled, toggle] = useFeatureToggle(feature.id); @@ -31,13 +32,15 @@ export function ExperimentalFeaturesCard() { return (
-
-

Experiments

-

- These features are functional but still being refined. Enable them to - try new capabilities early. -

-
+ + These features are functional but still being refined. Enable them + to try new capabilities early. + + } + />
{previewFeatures.map((f) => ( diff --git a/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx b/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx index 2e84f32a0..4cf1276c2 100644 --- a/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx +++ b/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx @@ -4,6 +4,7 @@ import { type KeyboardShortcut, } from "@/shared/lib/keyboard-shortcuts"; import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; function KeyCombo({ shortcut }: { shortcut: KeyboardShortcut }) { const keys = getPlatformKeys(shortcut); @@ -32,14 +33,10 @@ export function KeyboardShortcutsCard() { return (
-
-

- Keyboard Shortcuts -

-

- All available keyboard shortcuts. Shortcuts are read-only. -

-
+
{[...categories.entries()].map(([category, shortcuts]) => ( diff --git a/desktop/src/features/settings/ui/MobilePairingCard.tsx b/desktop/src/features/settings/ui/MobilePairingCard.tsx index 6ae0850e3..fab4aa158 100644 --- a/desktop/src/features/settings/ui/MobilePairingCard.tsx +++ b/desktop/src/features/settings/ui/MobilePairingCard.tsx @@ -27,6 +27,7 @@ import { DialogTitle, } from "@/shared/ui/dialog"; import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; type PairingStep = | "generating" @@ -321,14 +322,16 @@ export function MobilePairingCard({ return (
-
-

Mobile

-

- Connect the Buzz mobile app to this relay by scanning a QR code. The - connection is secured with end-to-end encryption and a verification - code. -

-
+ + Connect the Buzz mobile app to this relay by scanning a QR code. The + connection is secured with end-to-end encryption and a verification + code. + + } + /> diff --git a/desktop/src/features/settings/ui/NotificationSettingsCard.tsx b/desktop/src/features/settings/ui/NotificationSettingsCard.tsx index 72bb08326..f2bbff308 100644 --- a/desktop/src/features/settings/ui/NotificationSettingsCard.tsx +++ b/desktop/src/features/settings/ui/NotificationSettingsCard.tsx @@ -18,6 +18,7 @@ import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Switch } from "@/shared/ui/switch"; import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; import { SoundPicker } from "./SoundPicker"; export function NotificationSettingsCard({ @@ -60,12 +61,10 @@ export function NotificationSettingsCard({ return (
-
-

Notifications

-

- Desktop alerts are on by default. Fine-tune what gets through below. -

-
+ {notificationPermission === "unsupported" diff --git a/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx b/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx index c502efd53..52c48f384 100644 --- a/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx +++ b/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx @@ -1,6 +1,7 @@ import { usePreventSleepContext } from "@/features/agents/usePreventSleep"; import { Switch } from "@/shared/ui/switch"; import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; export function PreventSleepSettingsCard() { const { enabled, setEnabled, hasRunningAgents, expired, clearExpired } = @@ -8,12 +9,10 @@ export function PreventSleepSettingsCard() { return (
-
-

Agents

-

- Settings that affect how local managed agents run on this machine. -

-
+ diff --git a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx index c7c81476b..147cfd793 100644 --- a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx @@ -21,6 +21,7 @@ import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; import { Spinner } from "@/shared/ui/spinner"; import { Textarea } from "@/shared/ui/textarea"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; type ProfileSettingsCardProps = { currentPubkey?: string; @@ -458,12 +459,10 @@ export function ProfileSettingsCard({ return (
-
-

Profile

-

- Update how your name, avatar, and bio appear across Buzz. -

-
+
{profileQuery.error instanceof Error ? ( diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 78db770c3..c3055c30f 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -43,6 +43,7 @@ import { NotificationSettingsCard } from "./NotificationSettingsCard"; import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard"; import { ProfileSettingsCard } from "./ProfileSettingsCard"; import { UpdateChecker } from "../UpdateChecker"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; export type SettingsSection = | "profile" @@ -206,12 +207,10 @@ function ThemeSettingsCard() { return (
-
-

Appearance

-

- Choose a theme for Buzz. Light and dark mode is auto-detected. -

-
+
diff --git a/desktop/src/features/settings/ui/SettingsSectionHeader.tsx b/desktop/src/features/settings/ui/SettingsSectionHeader.tsx new file mode 100644 index 000000000..c22a526cd --- /dev/null +++ b/desktop/src/features/settings/ui/SettingsSectionHeader.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from "react"; + +export function SettingsSectionHeader({ + action, + description, + title, +}: { + action?: ReactNode; + description: ReactNode; + title: ReactNode; +}) { + const copy = ( + <> +

{title}

+

+ {description} +

+ + ); + + if (action) { + return ( +
+
{copy}
+
{action}
+
+ ); + } + + return
{copy}
; +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 9b925f07f..dc9909571 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -48,6 +48,7 @@ import { SECTION_ACTION_VISIBILITY_CLASS, SECTION_ICON_BUTTON_CLASS, } from "@/features/sidebar/ui/sidebarSectionStyles"; +import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen"; import { SidebarUpdateCard } from "@/features/settings/SidebarUpdateCard"; import { useUpdaterContext } from "@/features/settings/hooks/UpdaterProvider"; import { shouldShowSidebarUpdateCard } from "@/features/settings/sidebarUpdateCardVisibility"; @@ -140,6 +141,7 @@ type AppSidebarProps = { updates: Partial>, ) => void; onRemoveWorkspace: (id: string) => void; + onCreateAgent: () => void; onSelectAgents: () => void; onSelectProjects: () => void; onSelectPulse: () => void; @@ -209,6 +211,7 @@ export function AppSidebar({ onOpenDm, onUpdateWorkspace, onRemoveWorkspace, + onCreateAgent, onSelectAgents, onSelectProjects, onSelectPulse, @@ -310,6 +313,14 @@ export function AppSidebar({ const [createDialogKind, setCreateDialogKind] = React.useState(null); + const { openNextFrame: openModalNextFrame } = useDeferredModalOpen(); + const openCreateDialog = React.useCallback( + (kind: CreateChannelKind) => { + setCreateDialogKind(null); + openModalNextFrame(() => setCreateDialogKind(kind)); + }, + [openModalNextFrame], + ); React.useEffect(() => { if (!canShowSidebarUpdateCard) { @@ -324,9 +335,9 @@ export function AppSidebar({ // dialog's `onOpenChange` below. React.useEffect(() => { if (isCreateChannelOpenProp) { - setCreateDialogKind("stream"); + openCreateDialog("stream"); } - }, [isCreateChannelOpenProp]); + }, [isCreateChannelOpenProp, openCreateDialog]); const [collapsedGroups, setCollapsedGroups] = React.useState< Record >({ @@ -506,6 +517,15 @@ export function AppSidebar({ [createDialogKind, onCreateChannel, onCreateForum], ); + const handleOpenCreateChannel = React.useCallback(() => { + if (onCreateChannelOpenChange) { + onCreateChannelOpenChange(true); + return; + } + + openCreateDialog("stream"); + }, [onCreateChannelOpenChange, openCreateDialog]); + return ( onOpenDm({ pubkeys: [user.pubkey] })} + onCreateAgent={onCreateAgent} + onCreateChannel={handleOpenCreateChannel} + suggestionChannels={channels} /> {isLoading ? ( @@ -716,16 +741,13 @@ export function AppSidebar({ browseAriaLabel="Browse channels" createAriaLabel="Create a channel" draggable - groupClassName={ - channelSections.length > 0 ? undefined : "pt-1" - } hasUnread={unreadChannelIds.size > 0} isCollapsed={collapsedGroups.channels} isActiveChannel={selectedView === "channel"} items={sectionBuckets.unassigned} listTestId="stream-list" onBrowseClick={onBrowseChannels} - onCreateClick={() => setCreateDialogKind("stream")} + onCreateClick={() => openCreateDialog("stream")} onMarkAllRead={onMarkAllChannelsRead} onMarkChannelRead={onMarkChannelRead} onMarkChannelUnread={onMarkChannelUnread} @@ -756,7 +778,7 @@ export function AppSidebar({ isActiveChannel={selectedView === "channel"} items={forumChannels} listTestId="forum-list" - onCreateClick={() => setCreateDialogKind("forum")} + onCreateClick={() => openCreateDialog("forum")} onMarkAllRead={onMarkAllChannelsRead} onMarkChannelRead={onMarkChannelRead} onMarkChannelUnread={onMarkChannelUnread} @@ -802,7 +824,7 @@ export function AppSidebar({ presenceByChannelId={dmPresenceByChannelId} selectedChannelId={selectedChannelId} testId="dm-list" - title="Direct Messages" + title="Direct messages" unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} mutedChannelIds={mutedChannelIds} diff --git a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx index f0d583b7f..f4a0add50 100644 --- a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx +++ b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx @@ -11,7 +11,6 @@ import { GripVertical, Pencil, Plus, - Search, Star, StarOff, Trash2, @@ -48,6 +47,7 @@ import { import type { ChannelSection } from "@/features/sidebar/lib/useChannelSections"; import type { Channel } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; +import { HashSearch } from "@/shared/ui/icons"; // --------------------------------------------------------------------------- // Shared styles @@ -56,7 +56,7 @@ import { cn } from "@/shared/lib/cn"; const SECTION_LABEL_BUTTON_CLASS = "group/section-label flex w-fit max-w-[calc(100%-3rem)] cursor-pointer appearance-none items-center gap-1 text-left transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground"; const SECTION_LABEL_CHEVRON_CLASS = - "relative size-2.5 shrink-0 opacity-0 text-sidebar-foreground/45 transition-[color,opacity] group-hover/sidebar-section:opacity-100 group-hover/sidebar-section:text-sidebar-foreground group-hover/section-label:opacity-100 group-hover/section-label:text-sidebar-foreground group-focus-within/sidebar-section:opacity-100 group-focus-within/sidebar-section:text-sidebar-foreground group-focus-visible/section-label:opacity-100 group-focus-visible/section-label:text-sidebar-foreground"; + "relative size-2.5 shrink-0 text-current opacity-0 transition-[color,opacity] group-hover/sidebar-section:opacity-100 group-hover/section-label:opacity-100 group-focus-within/sidebar-section:opacity-100 group-focus-visible/section-label:opacity-100"; const SECTION_LABEL_CHEVRON_ICON_CLASS = "absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2"; @@ -261,18 +261,24 @@ function SectionHeaderActions({ {onBrowseClick ? ( ) : null} {onCreateClick ? (