diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d57e1cc01e3..6f4be02bbaf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1117,63 +1117,6 @@ jobs: ${{ steps.artifacts.outputs.exe }} ${{ steps.artifacts.outputs.sig }} - desktop-release-smoke: - name: Desktop release smoke - if: github.repository == 'block/buzz' - runs-on: ubuntu-latest - needs: setup - timeout-minutes: 20 - permissions: - contents: read - env: - PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - ref: ${{ needs.setup.outputs.source_sha }} - persist-credentials: false - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Install desktop dependencies - run: just desktop-install-ci - - name: Get Playwright version - id: pw-version - run: echo "version=$(cd desktop && node -e \"console.log(require('@playwright/test/package.json').version)\")" >> "$GITHUB_OUTPUT" - - name: Restore Playwright browser cache - id: playwright-cache - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} - key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - - name: Install Playwright Chromium - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: cd desktop && pnpm exec playwright install chromium - - name: Install Playwright system dependencies - run: cd desktop && pnpm exec playwright install-deps chromium - - name: Save Playwright browser cache - if: steps.playwright-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 - with: - path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} - key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - - name: Build test relay - run: cargo build --profile ci -p buzz-relay - - name: Run deterministic correctness smoke - env: - BUZZ_E2E_RELAY_BIN: ${{ github.workspace }}/target/ci/buzz-relay - BUZZ_RELEASE_SMOKE_ARTIFACT_DIR: ${{ github.workspace }}/release-smoke-artifacts - run: just desktop-release-smoke - - name: Upload release-smoke diagnostics - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: desktop-release-smoke - path: | - release-smoke-artifacts - desktop/test-results - desktop/playwright-release-smoke-report - if-no-files-found: warn - retention-days: 14 - assemble-manifest: name: Assemble multi-platform latest.json # Only the tag-bound setup path can reach this job. @@ -1189,17 +1132,15 @@ jobs: # # Reachable here without weakening upstream: accept either darwin-aarch64 lane # (their `if:` conditions are complementary, so exactly one ever runs), and - # accept `skipped` only for the signed x64 lane and `desktop-release-smoke`, - # neither of which a fork can run. On block/buzz every lane still has to - # succeed. + # accept `skipped` only for the signed x64 lane, which a fork cannot run. On + # block/buzz every lane still has to succeed. # - # `desktop-release-smoke` (upstream #5699) is the third instance of exactly the - # failure described above, and it arrived the same way: upstream added the - # condition, our side had rewritten the surrounding block, and git merged the - # new line in with no conflict at all — only the `needs:` list below conflicted. - # The job is pinned `if: github.repository == 'block/buzz'`, so it reports - # `skipped` here forever. Requiring its success would silently strand - # latest.json again while every artifact uploaded fine. + # A fourth lane, `desktop-release-smoke` (upstream #5699), was accepted here the + # same way until upstream deleted the job outright in #5914. Its condition and + # its `needs:` entry went with it: a `needs..result` naming a job that no + # longer exists is not a red check, it is a silently unreachable gate. Expect + # the pattern to recur — upstream adds a lane pinned to `block/buzz`, its new + # `if:` line merges in as clean context, and only the `needs:` list conflicts. if: | always() && needs.setup.result == 'success' && @@ -1207,8 +1148,6 @@ jobs: (needs.release-macos-x64.result == 'success' || needs.release-macos-x64.result == 'skipped') && needs.release-linux.result == 'success' && needs.release-windows.result == 'success' && - (needs.desktop-release-smoke.result == 'success' || - needs.desktop-release-smoke.result == 'skipped') && github.ref == format('refs/tags/desktop-v{0}', needs.setup.outputs.version) runs-on: ubuntu-latest # FORK-LOCAL PATCH (adrienlacombe/buzz): release-macos-unsigned added. It is @@ -1221,7 +1160,6 @@ jobs: release-macos-x64, release-linux, release-windows, - desktop-release-smoke, ] timeout-minutes: 10 permissions: diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index b8516ee3463..2c06a06c6a2 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,10 +1,10 @@ { "schema": 2, - "version": "0.5.11", - "base_sha": "4749bc7be3cdb78c2db4ce4864775ba7ab60b4cc", - "previous_tag": "desktop-v0.5.10", - "previous_base_sha": "f35930104bcbdb1332ff13735214ecb9fce1fc7b", - "previous_merge_sha": "4b3570671eb2786594267758af18784ac6e82972", - "tag": "desktop-v0.5.11", - "commit_count": 16 + "version": "0.5.14", + "base_sha": "1b3dbcaaea882eeea90359c1db02e306d2f4f50a", + "previous_tag": "desktop-v0.5.13", + "previous_base_sha": "09768100ec3420f0aa7cd278bd00fe0baab5de8d", + "previous_merge_sha": "51beba603886d34e751349d12b33c0c5aeb92c28", + "tag": "desktop-v0.5.14", + "commit_count": 1 } diff --git a/AGENTS.md b/AGENTS.md index 7e69699ec3a..72741f83c69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -180,7 +180,7 @@ place. | `.gitattributes` | `*.lock.yml linguist-generated` | Added by `gh aw init` | | `ci.yml` | mesh-llm rev read from `desktop/src-tauri/Cargo.lock` | The two locks pin mesh-llm independently (desktop is outside the root workspace) and can name different revs — at the time of the patch, root `tag=v0.73.1` (`43103c5c`) vs desktop `rev=f455d493`. The step fetches the *desktop* manifest, so the root rev names a checkout never fetched. Upstream is masked by a warm cache — the step is skipped on cache hit. **Since the 2026-07-31 sync both locks pin `tag=v0.74.0` (`e60b2fe4`), so the patch is a temporary no-op — do not delete it.** The locks stay independent; the next bump that moves one and not the other re-breaks the root-lock version | | `docker.yml` | `PUSH_GATEWAY_IMAGE` override; owner-correct attestation hint | Push-gateway image was hardcoded to `ghcr.io/block/buzz-push-gateway` in nine places, so `GHCR_IMAGE` could not retarget it | -| `release.yml` | `RELEASE_REPO` guard on `setup` + `release-linux`; `BASE` and `BUZZ_UPDATER_ENDPOINT` derive from `github.repository`; `assemble-manifest` asserts on job results instead of counting platforms, and accepts `skipped` from the two lanes no fork can run (`release-macos-x64`, `desktop-release-smoke`); `release-macos-unsigned` runs without `--no-sign`, sets `BUZZ_MACOS_ADHOC_SIGN=1`, asserts the bundle signature, and builds `buzz-backend-kubernetes` among its sidecars | Guards were pinned to `block/buzz`; the updater URLs were hardcoded to Block's releases, so a fork verified its artifacts against Block's rolling release and shipped builds polling Block for updates. The `-ge 3` platform count was unreachable with both macOS jobs skipped, so `latest.json` was never published. **Upstream keeps adding lanes to that gate, and each one arrives the same way**: it pins the new job to `block/buzz`, adds `needs..result == 'success'` to `assemble-manifest`, and the new condition merges into the fork's rewritten `if:` block as *clean context* — only the `needs:` list conflicts, so the diff points at the harmless half. `desktop-release-smoke` (#5699, 2026-08-14 sync) was the third instance. The gate then fails closed on a lane that structurally cannot run here, stranding `latest.json` while every artifact uploads fine. When a sync touches this job, read the `if:` block itself, not just the conflict. `--no-sign` suppressed updater signing too, so the `.app.tar.gz` shipped with no `.sig`; without ad-hoc signing the bundle had no signature at all and macOS called it damaged — see [Desktop auto-update](#desktop-auto-update-linux--windows--works). **`release-macos-unsigned` is a fork-added job, so upstream's sweeps across its own lanes never reach it.** When upstream added the `buzz-backend-kubernetes` sidecar to every non-Windows lane (#4289) this job kept the old list, and because `tauri.conf.json`'s `externalBin` is shared while `scripts/bundle-sidecars.sh` exits 1 on a missing binary, the fork's only `darwin-aarch64` lane would have failed — with no merge conflict anywhere. Re-check this job's sidecar list whenever upstream touches one of theirs. **Do not name the release-upload command anywhere in this file, even in a comment:** `scripts/test-release-ref-contract.sh` counts occurrences of that string and, since upstream #5398 moved rolling-manifest promotion out of this file, requires exactly **one** — it was two before the 2026-08-11 sync. `scripts/test-oss-desktop-promotion.sh` additionally asserts that the rolling-release upload does *not* appear here at all, so prose naming it fails two contracts, not one. See [Rolling-manifest promotion](#rolling-manifest-promotion-moved-upstream-and-the-fork-cannot-reach-it) | +| `release.yml` | `RELEASE_REPO` guard on `setup` + `release-linux`; `BASE` and `BUZZ_UPDATER_ENDPOINT` derive from `github.repository`; `assemble-manifest` asserts on job results instead of counting platforms, and accepts `skipped` from the one lane no fork can run (`release-macos-x64`); `release-macos-unsigned` runs without `--no-sign`, sets `BUZZ_MACOS_ADHOC_SIGN=1`, asserts the bundle signature, and builds `buzz-backend-kubernetes` among its sidecars | Guards were pinned to `block/buzz`; the updater URLs were hardcoded to Block's releases, so a fork verified its artifacts against Block's rolling release and shipped builds polling Block for updates. The `-ge 3` platform count was unreachable with both macOS jobs skipped, so `latest.json` was never published. **Upstream keeps adding lanes to that gate, and each one arrives the same way**: it pins the new job to `block/buzz`, adds `needs..result == 'success'` to `assemble-manifest`, and the new condition merges into the fork's rewritten `if:` block as *clean context* — only the `needs:` list conflicts, so the diff points at the harmless half. `desktop-release-smoke` (#5699, 2026-08-14 sync) was the third instance. The gate then fails closed on a lane that structurally cannot run here, stranding `latest.json` while every artifact uploads fine. When a sync touches this job, read the `if:` block itself, not just the conflict. **And the reverse also happens: upstream deleted `desktop-release-smoke` outright one day later (#5914, 2026-08-16 sync), which left the fork's freshly added condition and `needs:` entry naming a job that no longer exists.** Both were dropped in that sync. A dangling `needs..result` is not a red check — an unresolvable job reference makes the gate unreachable, so `latest.json` goes missing exactly as if the gate had failed closed. Whenever a sync touches this job, check every lane the `if:` block names still has a `jobs:` entry. `--no-sign` suppressed updater signing too, so the `.app.tar.gz` shipped with no `.sig`; without ad-hoc signing the bundle had no signature at all and macOS called it damaged — see [Desktop auto-update](#desktop-auto-update-linux--windows--works). **`release-macos-unsigned` is a fork-added job, so upstream's sweeps across its own lanes never reach it.** When upstream added the `buzz-backend-kubernetes` sidecar to every non-Windows lane (#4289) this job kept the old list, and because `tauri.conf.json`'s `externalBin` is shared while `scripts/bundle-sidecars.sh` exits 1 on a missing binary, the fork's only `darwin-aarch64` lane would have failed — with no merge conflict anywhere. Re-check this job's sidecar list whenever upstream touches one of theirs. **Do not name the release-upload command anywhere in this file, even in a comment:** `scripts/test-release-ref-contract.sh` counts occurrences of that string and, since upstream #5398 moved rolling-manifest promotion out of this file, requires exactly **one** — it was two before the 2026-08-11 sync. `scripts/test-oss-desktop-promotion.sh` additionally asserts that the rolling-release upload does *not* appear here at all, so prose naming it fails two contracts, not one. See [Rolling-manifest promotion](#rolling-manifest-promotion-moved-upstream-and-the-fork-cannot-reach-it) | | `release.yml` + `macos-canary.yml` | `BUZZ_DESKTOP_BUILD_AUTO_CONNECT_DEFAULT_RELAY: "1"` on the build step of the three fork-runnable lanes and the canary | Skips the "Join or create a community" picker and auto-creates the single allowlisted community. This is **upstream's own opt-in**, for builds whose default relay is reviewed and fixed — no source change was needed. It works because release builds already default to `wss://relay.bitcoinmarkets.app` (`relay.rs` → `relay/allowlist.rs`) and `shouldAutoConnectDefaultRelay` accepts any non-loopback `ws(s)` URL. `option_env!`, so it is **compile-time**: absent at build time it silently does nothing. Deliberately not set on the two `block/buzz` macOS lanes, and irrelevant in debug builds where the loopback default correctly keeps the picker. Assert it with the `#[ignore]`d `compiled_flag_matches_expected` test and `BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY` | | `desktop/scripts/build-release-config.mjs` | `BUZZ_MACOS_ADHOC_SIGN=1` emits `bundle.macOS.signingIdentity: "-"` | Ad-hoc bundle signing for the one macOS lane nothing else signs. Opt-in and off by default, so the two `block/buzz` lanes still reach `block/apple-codesign-action` unsigned — setting it unconditionally would sign a bundle that is about to be re-signed. It has to be config rather than a post-build `codesign`, because Tauri builds the DMG in the same invocation | | `linux-canary.yml`, `windows-canary.yml` | `RELEASE_REPO` guard | Were pinned to `block/buzz` | @@ -368,6 +368,36 @@ these OS links, and then the fork must emit the registered scheme exactly as `messageLink.ts` does. Check for both when a sync touches `entityLink.ts`, `entityLinks.tsx` or `deep_link.rs`. +#### Both flip conditions fired in the 2026-08-16 sync — **decision pending** + +Upstream's Projects v3 (#5792) landed both at once, so the reasoning above no longer +holds and the section is kept only as the record of why it once did: + +1. `deep_link.rs` gained `Some("repo" | "project" | "pr" | "issue")`, which calls + `parse_entity_deep_link`, activates the window and emits `deep-link-entity`. These + are now OS links by any definition. +2. `ShareLinkButton.tsx` and `CopyShareLinkMenuItem.tsx` are new, and both put an + entity link on the clipboard from a **"Copy link"** control. They are wired into + `RepositoryCards`, `ProjectCards`, `ProjectsPullRequestsList`, `ProjectsIssuesList`, + `ProjectIssuesPanel`, `ProjectPullRequestsPanel` and `ProjectDetailChrome`. + +`entityLink.ts:21` still has `const ENTITY_LINK_SCHEME = "buzz:"` and its four +builders still emit `buzz://…`, so in this fork **"Copy link" produces a link the OS +will not route back to this app** — only `bitcoinmarkets` is registered. Pasted into a +browser it opens upstream Buzz if installed, and nothing otherwise. In-app clicks are +unaffected: `entityLinks.tsx` still `preventDefault()`s and routes internally, and +`deep_link.rs` accepts both schemes inbound. + +The fix is the `messageLink.ts` idiom — a `FORK-LOCAL` scheme constant that the +builders emit and the parser accepts alongside the legacy literal — but it is a +deliberate behavioural change, not a merge resolution, so the sync that found it did +not make it. **It also has a real cost the earlier reasoning named:** these links +travel inside message content, so emitting `bitcoinmarkets://` stops upstream clients +rendering preview cards for links this fork publishes. Point 2 above is still true; +what changed is that point 1 is not, and a copied link that goes nowhere is the more +visible breakage. `crates/buzz-cli/src/links.rs` builds the same links and would need +the same treatment. + ### Splitting state from upstream Buzz This fork and upstream Buzz can be installed side by side, and until 2026-08-01 they @@ -692,7 +722,15 @@ All 12 alerts from the first CodeQL run were in files **byte-identical to upstream** — no fork-local code was implicated — and all are dismissed. Recorded here because dismissals key to an alert number: a sync that re-touches these files can raise the same finding under a new number, and without this table the -analysis gets redone from scratch. +analysis gets redone from scratch. Later rows are alerts a sync introduced; +they are triaged here but **not** dismissed, so they may still be open. + +**Two families are open on `main` and are not in this table**, because they +predate it and are not sync findings: 27 `rust/hard-coded-cryptographic-value` in +`crates/buzz-paymaster` (fork-local, test vectors — worth a proper triage pass +someday) and one `js/redos`. Check `?ref=refs/heads/main` before attributing an +alert to a sync — a red `CodeQL` on a sync PR reports only what that PR *added*, +but the alert list API returns the whole branch. | Rule | Where | Verdict | |------|-------|---------| @@ -703,6 +741,7 @@ analysis gets redone from scratch. | `js/xss-through-dom` ×2 | `desktop/src/features/agents/ui/AgentCreationPreview.tsx:752,889` | Sink is ``, a passive context — `javascript:` URLs do not execute there and SVG loaded via `` cannot run script. The value is the avatar URL the user typed into their own client. **False positive** | | `js/incomplete-multi-character-sanitization` | `desktop/src/features/projects/ui/ProjectReadmePanel.tsx:35` | `htmlInlineToMarkdown` is a markdown normalizer, not a sanitizer. **False positive** | | `js/double-escaping` | `desktop/src/features/projects/ui/ProjectReadmePanel.tsx:26` | **A real bug**, not exploitable. See below | +| `js/incomplete-url-substring-sanitization` ×6 | `desktop/src/features/messages/ui/useComposerLinkPreviews.test.mjs:245,374,541,545,562,563` | Arrived in the 2026-08-16 sync and turned `CodeQL` red on the sync PR. Every hit is `assert.ok(tag?.includes("https://relay.example.com/media/…"))` — a **test assertion** that a serialised tag carries an expected URL, not a sanitiser and not a security decision. The rule looks for `url.includes("host")` guarding a trust boundary; there is no untrusted input here. File is byte-identical to `upstream/main`. **False positive** | The one genuine defect is `decodeHtmlEntities`, which decodes `&` *before* `<`, so `&lt;` becomes a literal `<`. A README containing escaped HTML @@ -872,6 +911,27 @@ Known-still-hardcoded upstream, not yet patched here: `helm-chart.yml` has a `CHART_REPO` (`:20`), and `signed-macos-canary.yml:104` still carries the root-lockfile mesh-llm bug fixed in `ci.yml`. + + +--- + +## Product Contract + +Before planning or reviewing a non-trivial change: + +1. Read [VISION.md](VISION.md). +2. Read the `VISION_*.md` documents relevant to the affected product surface. +3. Read the applicable guidance in [TESTING.md](TESTING.md) and any + package-local `TESTING.md`. +4. Check that the proposed design advances, or at least does not contradict, + that product intent. Call out any intentional tension explicitly. + +Implementation describes the product today; the vision documents describe the +product it is becoming. A locally correct change can still be wrong if it works +against that direction. Scale validation to the change's risk and exercise the +real workflow for user-visible or integration behavior when practical; green CI +and runtime evidence answer different questions. + --- ## Ecosystem diff --git a/CHANGELOG.md b/CHANGELOG.md index aa19e933d16..717e732497c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,75 @@ # Changelog +## v0.5.14 + +### Desktop and shared changes + +- None + +### Other repository changes + +- ci(release): remove desktop smoke gate ([#5914](https://github.com/block/buzz/pull/5914)) ([`1b3dbcaaea882eeea90359c1db02e306d2f4f50a`](https://github.com/block/buzz/commit/1b3dbcaaea882eeea90359c1db02e306d2f4f50a)) + +[Compare desktop-v0.5.13...desktop-v0.5.14](https://github.com/block/buzz/compare/desktop-v0.5.13...desktop-v0.5.14) + +## v0.5.13 + +### Desktop and shared changes + +- fix(desktop): restore the agent trading-card mint button ([#5900](https://github.com/block/buzz/pull/5900)) ([`263c9bf76c18f0cde6cec9fb43d22f8895319380`](https://github.com/block/buzz/commit/263c9bf76c18f0cde6cec9fb43d22f8895319380)) +- Projects v3: unify sharing, discussions, and issue ownership ([#5792](https://github.com/block/buzz/pull/5792)) ([`122a8b8988869f0b1a7c056a76f7d16bfb0f6fdd`](https://github.com/block/buzz/commit/122a8b8988869f0b1a7c056a76f7d16bfb0f6fdd)) + +### Other repository changes + +- fix(ci): read Playwright version without nested shell quoting ([#5910](https://github.com/block/buzz/pull/5910)) ([`09768100ec3420f0aa7cd278bd00fe0baab5de8d`](https://github.com/block/buzz/commit/09768100ec3420f0aa7cd278bd00fe0baab5de8d)) +- fix(mobile): unwrap batched observer telemetry ([#5805](https://github.com/block/buzz/pull/5805)) ([`0bb7c60f824a05ac4d8c8569ee1e74d200069b45`](https://github.com/block/buzz/commit/0bb7c60f824a05ac4d8c8569ee1e74d200069b45)) + +[Compare desktop-v0.5.12...desktop-v0.5.13](https://github.com/block/buzz/compare/desktop-v0.5.12...desktop-v0.5.13) + +## v0.5.12 + +### Desktop and shared changes + +- perf(desktop): update active turns incrementally ([#5897](https://github.com/block/buzz/pull/5897)) ([`757779bb1ef22cc4a1c233344baa0946d907e5a6`](https://github.com/block/buzz/commit/757779bb1ef22cc4a1c233344baa0946d907e5a6)) +- fix(link-previews): send while previews finish in background ([#5697](https://github.com/block/buzz/pull/5697)) ([`f086eb6544fd9f450832ea22de74b5418d1f85a1`](https://github.com/block/buzz/commit/f086eb6544fd9f450832ea22de74b5418d1f85a1)) +- fix(desktop): cut steady-state relay traffic from polls and read-state echo ([#5879](https://github.com/block/buzz/pull/5879)) ([`01f76ec9719ebdacce3f6e67d545692a90e10b06`](https://github.com/block/buzz/commit/01f76ec9719ebdacce3f6e67d545692a90e10b06)) +- fix(desktop): support channel message path links ([#5889](https://github.com/block/buzz/pull/5889)) ([`207154706c87cbf207f2a2abbc096d17737b091a`](https://github.com/block/buzz/commit/207154706c87cbf207f2a2abbc096d17737b091a)) +- test(desktop): await channel E2E bridge readiness ([#5886](https://github.com/block/buzz/pull/5886)) ([`dbee2914ad806c7f038389eb95c7513f5df4e0d2`](https://github.com/block/buzz/commit/dbee2914ad806c7f038389eb95c7513f5df4e0d2)) +- fix(link-preview): refetch a link when it re-enters the composer ([#5510](https://github.com/block/buzz/pull/5510)) ([`fd0ab47a1b5526d7496b5a6d731f3c8d7e4dbe9f`](https://github.com/block/buzz/commit/fd0ab47a1b5526d7496b5a6d731f3c8d7e4dbe9f)) +- feat(desktop-messages): render compact Buzz permalink chips ([#5638](https://github.com/block/buzz/pull/5638)) ([`5acb930821ba56b5f4d1b487bffd237dd3ebe76a`](https://github.com/block/buzz/commit/5acb930821ba56b5f4d1b487bffd237dd3ebe76a)) +- Fix video comment effect wrapping ([#5748](https://github.com/block/buzz/pull/5748)) ([`17d2147ecadaef5891da598cf8f5257f7787992b`](https://github.com/block/buzz/commit/17d2147ecadaef5891da598cf8f5257f7787992b)) +- feat(desktop): one relative date ladder across chat and the Inbox ([#3769](https://github.com/block/buzz/pull/3769)) ([`caa64b5e8f584a740e331887a5dd1cda32bcb958`](https://github.com/block/buzz/commit/caa64b5e8f584a740e331887a5dd1cda32bcb958)) +- fix(desktop): amortize observer journal eviction with a low-water mark ([#5808](https://github.com/block/buzz/pull/5808)) ([`17977814d38a841ed475b318a5dfd4bc8405d049`](https://github.com/block/buzz/commit/17977814d38a841ed475b318a5dfd4bc8405d049)) +- Unify agent profile content ([#5788](https://github.com/block/buzz/pull/5788)) ([`34a7f2fb917cff8afd86bb59f6abcfa4cb8981d5`](https://github.com/block/buzz/commit/34a7f2fb917cff8afd86bb59f6abcfa4cb8981d5)) +- Standardize settings section layout ([#5855](https://github.com/block/buzz/pull/5855)) ([`43e53fc3491ecbd1def14ede3fb8c9e2d44e84d8`](https://github.com/block/buzz/commit/43e53fc3491ecbd1def14ede3fb8c9e2d44e84d8)) +- fix(desktop): share one timer across same-interval useNow consumers ([#5861](https://github.com/block/buzz/pull/5861)) ([`8b8445f5ef3338c58825194ebc008b98111a0962`](https://github.com/block/buzz/commit/8b8445f5ef3338c58825194ebc008b98111a0962)) +- Clarify immediate spoken huddle replies ([#5863](https://github.com/block/buzz/pull/5863)) ([`ea0960f8d0221de18d7d3504607594035519f33f`](https://github.com/block/buzz/commit/ea0960f8d0221de18d7d3504607594035519f33f)) +- Scope desktop presence subscriptions to active demand ([#5830](https://github.com/block/buzz/pull/5830)) ([`df9e773a13f17a270fd6531fc74948b8059d58c3`](https://github.com/block/buzz/commit/df9e773a13f17a270fd6531fc74948b8059d58c3)) +- fix(huddle): stop 20 Hz speaker-level churn from re-rendering the whole app ([#5825](https://github.com/block/buzz/pull/5825)) ([`57435628961d25bd24689cee82f1373e7a074040`](https://github.com/block/buzz/commit/57435628961d25bd24689cee82f1373e7a074040)) +- fix(desktop): match compact link preview thumbnail corners to card shell ([#5711](https://github.com/block/buzz/pull/5711)) ([`eedcd886a04833a78c18f49931abe68792518f97`](https://github.com/block/buzz/commit/eedcd886a04833a78c18f49931abe68792518f97)) +- feat(huddle): cut voice-turn time-to-first-audio from ~1.0 s to ~0.35 s (env-gated latency levers) ([#5671](https://github.com/block/buzz/pull/5671)) ([`068a83b09712703c71923fb22601dffd96554c91`](https://github.com/block/buzz/commit/068a83b09712703c71923fb22601dffd96554c91)) +- Speed up initial direct messages ([#5658](https://github.com/block/buzz/pull/5658)) ([`c8da06c5e9e92b2441927cdb4619318b4328c2bd`](https://github.com/block/buzz/commit/c8da06c5e9e92b2441927cdb4619318b4328c2bd)) +- Polish glass Huddle tray behavior ([#5590](https://github.com/block/buzz/pull/5590)) ([`0571f5455b1b2aeea7334082f0df9d1f19b22f7d`](https://github.com/block/buzz/commit/0571f5455b1b2aeea7334082f0df9d1f19b22f7d)) +- test: add deterministic desktop release smoke ([#5699](https://github.com/block/buzz/pull/5699)) ([`76f114a252866f17003520db0a11a8b6f5b3da0c`](https://github.com/block/buzz/commit/76f114a252866f17003520db0a11a8b6f5b3da0c)) +- feat(desktop): add Inbox message delete action ([#5779](https://github.com/block/buzz/pull/5779)) ([`514195b1d58d1a8679bfc8c63a2b410b6a227489`](https://github.com/block/buzz/commit/514195b1d58d1a8679bfc8c63a2b410b6a227489)) +- fix(desktop): enforce agent mention authorization at send boundaries ([#5681](https://github.com/block/buzz/pull/5681)) ([`bcf353c969b91991c22d0715aa2d7a618d630e1d`](https://github.com/block/buzz/commit/bcf353c969b91991c22d0715aa2d7a618d630e1d)) +- fix(desktop): route compact preview geometry fixture through media proxy ([#5799](https://github.com/block/buzz/pull/5799)) ([`b269e8df7e6ed3e1910b6f6eeef08fa4b89778bd`](https://github.com/block/buzz/commit/b269e8df7e6ed3e1910b6f6eeef08fa4b89778bd)) +- Make workflow run history authoritative in Desktop ([#5780](https://github.com/block/buzz/pull/5780)) ([`2693e0db1fc4980a551c2492031812dc4bad985f`](https://github.com/block/buzz/commit/2693e0db1fc4980a551c2492031812dc4bad985f)) +- fix(desktop): more compact "compact" link previews ([#5629](https://github.com/block/buzz/pull/5629)) ([`45f4b91a36145f2ce642548c34f699f1b529bcf5`](https://github.com/block/buzz/commit/45f4b91a36145f2ce642548c34f699f1b529bcf5)) +- Harden shared agent instruction review ([#4220](https://github.com/block/buzz/pull/4220)) ([`a96af89526f7181543e7651100a944aa8e21812b`](https://github.com/block/buzz/commit/a96af89526f7181543e7651100a944aa8e21812b)) + +### Other repository changes + +- feat(mobile-messages): render compact Buzz permalink chips ([#5639](https://github.com/block/buzz/pull/5639)) ([`5ddf23d700abdd96622de2d39750c56509a7561f`](https://github.com/block/buzz/commit/5ddf23d700abdd96622de2d39750c56509a7561f)) +- Teach agents to inherit Buzz product intent ([#5875](https://github.com/block/buzz/pull/5875)) ([`1d51081b8abf4d3f9ec7fc676207f967a843e860`](https://github.com/block/buzz/commit/1d51081b8abf4d3f9ec7fc676207f967a843e860)) +- Polish mobile profiles, DMs, and sheets ([#5401](https://github.com/block/buzz/pull/5401)) ([`b30f1f61299f6f559777f797be27f193a6a4f0b3`](https://github.com/block/buzz/commit/b30f1f61299f6f559777f797be27f193a6a4f0b3)) +- Fix channel list scroll interruption ([#5815](https://github.com/block/buzz/pull/5815)) ([`0f61f24ad659abf44a7a4fcde6a0a2cbcf78f13b`](https://github.com/block/buzz/commit/0f61f24ad659abf44a7a4fcde6a0a2cbcf78f13b)) +- fix(channels): return complete member rosters ([#5765](https://github.com/block/buzz/pull/5765)) ([`e0940927ff381f6a353c637732c7a81886f9639d`](https://github.com/block/buzz/commit/e0940927ff381f6a353c637732c7a81886f9639d)) +- Fix mobile composer input regressions ([#5594](https://github.com/block/buzz/pull/5594)) ([`98d3d77b426f1107c98b7826d0224624ea774385`](https://github.com/block/buzz/commit/98d3d77b426f1107c98b7826d0224624ea774385)) +- Add mobile community invites ([#5641](https://github.com/block/buzz/pull/5641)) ([`8abc2baf0b71844fc4ff7222aab5027c862b7d1f`](https://github.com/block/buzz/commit/8abc2baf0b71844fc4ff7222aab5027c862b7d1f)) + +[Compare desktop-v0.5.11...desktop-v0.5.12](https://github.com/block/buzz/compare/desktop-v0.5.11...desktop-v0.5.12) + ## v0.5.11 ### Desktop and shared changes diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 1d85221f113..83b9357171a 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -23,7 +23,7 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ | `buzz feed` | `get` | | `buzz social` | `publish`, `notes` | | `buzz repos` | `create`, `get`, `list` | -| `buzz issues` | `create`, `get`, `list`, `status` | +| `buzz issues` | `create`, `get`, `list`, `status`, `assign` | | `buzz pr` | `open`, `update`, `get`, `list`, `status` | | `buzz upload` | `file` | @@ -31,7 +31,9 @@ Run `buzz --help` or `buzz --help` for full usage. For multiline message When opening a pull request in response to channel work, always pass `--channel ` using the UUID from `[Context]`. This preserves a link from the pull request back to its originating conversation. -`buzz pr open`, `buzz issues create`, and `buzz repos create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, or repo in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references. +`buzz pr open`, `buzz issues create`, `buzz repos create`, and `buzz projects create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, repo, or project in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references. + +To assign an issue to someone, run `buzz issues assign --issue --repo-owner --repo-id --assignee --label ` after creating it. Remove an assignment with the matching `buzz issues unassign` arguments. Writing assignee names in the issue body or adding recipients with `issues create --to` is notification/presentation only — Buzz Desktop's Assignees rail and the "Assigned to me" filter read the signed assignment operations. Only operations signed by the issue author or repo owner are trusted for other people; anyone may assign or unassign themselves. ## Conversational Agent Creation @@ -47,7 +49,7 @@ For explicit changes to an existing personal agent, use `buzz agents draft-updat ### Mentions -- Use the person's **exact full display name** after `@` (e.g., `@Will Pfleger`, not `@Will`). Partial names fail silently. +- For a notifying `@mention`, use the person's **exact display name as shown in Buzz** (e.g., `@Will Pfleger`, not `@Will`, when the displayed name is `Will Pfleger`). Do not expand a short display name, infer a surname, or spend tool calls looking for a “fuller” name merely to address someone. Partial names fail silently. - Do NOT format mentions with bold, italic, or backticks — it breaks notification delivery. - When you know intended recipient pubkeys, send readable `@Name` text and pass the identities separately in the same command: `buzz messages send ... --content "@Name ..." --mention `. Repeat `--mention` for multiple recipients. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add their own recipients. Include a pubkey for every presentation-only name that should notify. The success JSON's `mention_pubkeys` comes from the signed event and is the delivery evidence; no follow-up verification command is needed. - Without `--mention`, the CLI resolves `@Name` against current channel members. It stops before sending on an unresolved/ambiguous name or a mentioned pubkey that is not a member. For a non-member, add them explicitly with `buzz channels add-member` only when authorized, then retry. Sending never changes membership automatically. @@ -81,7 +83,7 @@ All replies and delegations — including task assignments to other agents — g - For work that requires follow-up tools, create an open todo **before** sending the pickup acknowledgment. Keep it open until the deliverable is verified and you have sent a completion or blocker message; never end a turn with open todo state unless you have posted that completion or blocker message. - Use GitHub-flavored Markdown. Fenced code blocks with language tags for syntax highlighting. - No push notifications — poll with `buzz messages get --channel --since `. -- Address people by the name in their own message header. +- Address people using the name shown in their own message header. Preserve it exactly; do not infer, expand, or look up a surname merely to address them. - Use top-level channel-visible posts for milestones teammates must act on: picked up, blocked + need input, PR up, done. - Praise in public; correct in the work, not the person. @@ -115,6 +117,7 @@ These paths are relative to your working directory — keep exploration there. N Your `core` memory is auto-injected into your context every turn — it holds identity, durable rules, and goals across sessions. - **Keep `core` small.** A line earns a permanent slot only if it matters across most sessions or prevents a sharp repeat mistake. Treat the 65,535-byte hard limit as a wall to stay far from, not a budget to fill — aim to keep `core` under ~10 KB (roughly your healthy baseline). +- **Turn mistakes into durable lessons.** When a mistake exposes a repeatable mechanism, record the invariant in the same session. Keep only the load-bearing rule in `core`; put detailed evidence and procedures in cold memory. If the lesson improves a shared workflow, update the team's shared guidance so others do not have to re-earn it. - **Durable detail goes to a cold `mem/` slug, not `core`.** Long-lived findings that don't need to be in front of you every turn belong in a `mem/` slug you read on demand — not appended to `core`. - **Evict completed work.** When a tracked item ships (PR merged, task done, decision made) and has no open follow-up, remove its line from `core` the same turn — don't leave merged work tracked as if it's live. The detail already lives in its cold `mem/` slug if you need it later. - **Treat `core` as load-bearing.** Follow it unless newer explicit user instructions override it. @@ -130,13 +133,15 @@ These are guidelines, not a fixed procedure — apply judgment to the task in fr - **Plan briefly, then build.** Be opinionated about the safest concrete approach. Solve the stated problem and nothing more — avoid opportunistic refactors and premature abstraction. - **Match what's there.** Follow the surrounding code's conventions and module boundaries. Read neighboring code first. - **Attribute results to the exact state that produced them.** Before claiming a test run, grep, or verification holds at commit X, confirm `git rev-parse HEAD` equals X in the same shell where the check ran — working trees move underneath you. Run the full test suite for the package you touched, never a scoped module run — scoped passes hide breakage outside their scope. Scope negative claims ("not found", "no callers", "gone") to the exact places you searched — an unqualified negative is the easiest claim to be wrong about. -- **Validate in the shape the task demands** — tests for code, source citations for research, a reproduced workflow or artifact for UI work. If the same failure hits twice, change angle rather than retrying. +- **Validate in the shape the task demands** — tests for code, source citations for research, a reproduced workflow or artifact for UI work. CI and live workflow evidence answer different questions: for user-visible or integration behavior, exercise the real workflow when practical and scale the depth to the risk. If the same failure hits twice, change angle rather than retrying. - **Get a second opinion on risky changes.** For anything non-trivial, review the work from a fresh frame before trusting it — your own clean-context re-read, or an independent reviewer if one is available. Don't tell the reviewer what you expect them to find. - **Self-review before calling it done.** Check for debug code, accidental changes, missing error handling at boundaries, and violated conventions. - **Scale effort to risk.** A typo or config tweak just gets done. A multi-file change touching persistence, auth, or anything user-visible earns the full discipline above. ## Working in the Repo +- After selecting a repository or worktree, read its root `AGENTS.md` and any path-local `AGENTS.md` files that apply before planning or editing. The workspace-level file is team context; it does not replace repository-owned instructions. +- Treat repository-owned product, architecture, and vision documents as design constraints, not optional background. Read the relevant documents before making non-trivial plans, and surface any intentional conflict with them. - Make file changes in a worktree, not on the default branch. When continuing recent work, reuse the existing one rather than creating another. - Before committing, read the repo-local git `user.name` / `user.email`; if email is empty, stop and ask. Include the trailers the repo requires. diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 27b9000b7bb..7fd40b83db1 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1071,6 +1071,7 @@ fn handle_relay_observer_control_event( pool: &mut AgentPool, observer: Option<&observer::ObserverHandle>, owner_pubkey_hex: &str, + event_publisher: RelayEventPublisher, ) { // Defense-in-depth: verify signature even though the relay already checked. if let Err(e) = buzz_core::verify_event(&event) { @@ -1116,12 +1117,162 @@ fn handle_relay_observer_control_event( Some("switch_model") => { handle_switch_model_control(&payload, pool, observer); } + Some("publish_project_owner_announcements") => { + handle_publish_project_owner_announcements_control( + &payload, + keys, + observer, + event_publisher, + ); + } _ => { tracing::debug!(payload = %payload, "ignoring unknown observer control frame"); } } } +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProjectOwnerAnnouncementControl { + request_id: String, + announcements: Vec, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProjectOwnerAnnouncementTemplate { + kind: u16, + content: String, + created_at: Option, + tags: Vec>, +} + +fn handle_publish_project_owner_announcements_control( + payload: &serde_json::Value, + keys: &nostr::Keys, + observer: Option<&observer::ObserverHandle>, + publisher: RelayEventPublisher, +) { + let Ok(control) = serde_json::from_value::(payload.clone()) + else { + tracing::warn!("project announcement control frame has an invalid payload"); + return; + }; + if Uuid::parse_str(&control.request_id).is_err() + || control.announcements.is_empty() + || control.announcements.len() > 2 + { + tracing::warn!("project announcement control frame has invalid request metadata"); + return; + } + + let keys = keys.clone(); + let observer = observer.cloned(); + tokio::spawn(async move { + let events = match build_project_owner_announcement_events(control.announcements, &keys) { + Ok(events) => events, + Err(error) => { + emit_project_owner_control_result( + observer.as_ref(), + &control.request_id, + "error", + &[], + Some(error.to_string()), + ); + return; + } + }; + let mut published_events = Vec::with_capacity(events.len()); + for event in events { + if let Err(error) = publisher.publish_event(event.clone()).await { + emit_project_owner_control_result( + observer.as_ref(), + &control.request_id, + "error", + &published_events, + Some(format!("publish project announcement: {error}")), + ); + return; + } + published_events.push(event); + } + emit_project_owner_control_result( + observer.as_ref(), + &control.request_id, + "ok", + &published_events, + None, + ); + }); +} + +fn build_project_owner_announcement_events( + announcements: Vec, + keys: &nostr::Keys, +) -> Result> { + let now = nostr::Timestamp::now().as_secs(); + announcements + .into_iter() + .map(|template| { + if !matches!(template.kind, 30_617 | 30_621) { + anyhow::bail!("unsupported project announcement kind"); + } + if !template.tags.iter().any(|tag| { + tag.first().is_some_and(|value| value == "d") + && tag.get(1).is_some_and(|value| !value.trim().is_empty()) + }) { + anyhow::bail!("project announcement is missing its address"); + } + let tags = template + .tags + .into_iter() + .map(|tag| { + nostr::Tag::parse(tag) + .map_err(|error| anyhow::anyhow!("invalid project tag: {error}")) + }) + .collect::>>()?; + let created_at = template.created_at.unwrap_or(now); + if created_at > now.saturating_add(300) { + anyhow::bail!("project announcement timestamp is too far in the future"); + } + nostr::EventBuilder::new(nostr::Kind::Custom(template.kind), template.content) + .tags(tags) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(keys) + .map_err(|error| anyhow::anyhow!("sign project announcement: {error}")) + }) + .collect() +} + +fn emit_project_owner_control_result( + observer: Option<&observer::ObserverHandle>, + request_id: &str, + status: &str, + events: &[nostr::Event], + error: Option, +) { + let Some(observer) = observer else { + return; + }; + observer.emit( + "control_result", + None, + &observer::ObserverContext { + channel_id: None, + session_id: None, + turn_id: None, + started_at: None, + }, + serde_json::json!({ + "type": "publish_project_owner_announcements", + "requestId": request_id, + "status": status, + "events": events, + "error": error, + }), + ); +} + /// Handle a `cancel_turn` control frame: signal the in-flight task to cancel. fn handle_cancel_turn_control( payload: &serde_json::Value, @@ -2423,7 +2574,14 @@ async fn tokio_main() -> Result<()> { match control_event { Some(event) => { if let Some(ref owner_hex) = owner_cache.pubkey { - handle_relay_observer_control_event(&config.keys, event, &mut pool, observer.as_ref(), owner_hex); + handle_relay_observer_control_event( + &config.keys, + event, + &mut pool, + observer.as_ref(), + owner_hex, + relay.event_publisher(), + ); } else { tracing::warn!("observer control frame received but no owner resolved — dropping"); } @@ -4274,9 +4432,25 @@ mod agent_draft_prompt_tests { assert!(prompt.contains("buzz messages send ... --content -")); } + #[test] + fn shared_base_prompt_teaches_repo_context_and_learning_loop() { + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("read its root `AGENTS.md`")); + assert!(prompt.contains("path-local `AGENTS.md`")); + assert!( + prompt.contains("product, architecture, and vision documents as design constraints") + ); + assert!(prompt.contains("CI and live workflow evidence answer different questions")); + assert!(prompt.contains("record the invariant in the same session")); + assert!(prompt.contains("update the team's shared guidance")); + } + #[test] fn shared_base_prompt_teaches_single_command_mentions_and_preflight() { let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("use the person's **exact display name as shown in Buzz**")); + assert!(prompt.contains("Do not expand a short display name, infer a surname")); + assert!(prompt.contains("Preserve it exactly; do not infer, expand, or look up a surname")); assert!(prompt.contains("--mention ")); assert!(prompt.contains("every presentation-only name that should notify")); assert!( @@ -5046,6 +5220,59 @@ mod owner_control_command_tests { ControlSignal::Rotate )); } + + #[test] + fn project_owner_control_signs_only_addressable_project_events() { + let keys = Keys::generate(); + let events = build_project_owner_announcement_events( + vec![ + ProjectOwnerAnnouncementTemplate { + kind: 30_621, + content: String::new(), + created_at: Some(1), + tags: vec![vec!["d".to_string(), "project".to_string()]], + }, + ProjectOwnerAnnouncementTemplate { + kind: 30_617, + content: String::new(), + created_at: Some(1), + tags: vec![vec!["d".to_string(), "repository".to_string()]], + }, + ], + &keys, + ) + .expect("valid project events"); + + assert_eq!(events.len(), 2); + assert!(events.iter().all(|event| event.pubkey == keys.public_key())); + assert!(events.iter().all(|event| event.verify().is_ok())); + } + + #[test] + fn project_owner_control_rejects_arbitrary_or_unaddressed_events() { + let keys = Keys::generate(); + let arbitrary = build_project_owner_announcement_events( + vec![ProjectOwnerAnnouncementTemplate { + kind: 1, + content: String::new(), + created_at: None, + tags: vec![vec!["d".to_string(), "project".to_string()]], + }], + &keys, + ); + assert!(arbitrary.is_err()); + + let unaddressed = build_project_owner_announcement_events( + vec![ProjectOwnerAnnouncementTemplate { + kind: 30_621, + content: String::new(), + created_at: None, + tags: vec![], + }], + &keys, + ); + assert!(unaddressed.is_err()); + } } #[cfg(test)] diff --git a/crates/buzz-cli/src/commands/issues.rs b/crates/buzz-cli/src/commands/issues.rs index 91c64a3915c..15284a0d7bd 100644 --- a/crates/buzz-cli/src/commands/issues.rs +++ b/crates/buzz-cli/src/commands/issues.rs @@ -1,8 +1,232 @@ +use std::collections::{HashMap, HashSet}; + use crate::client::BuzzClient; use crate::commands::with_git_provenance; use crate::error::CliError; use crate::validate::{read_or_stdin, sdk_err, validate_hex64, validate_repo_id}; use buzz_sdk::{GitIssueMeta, GitRepoCoord, GitStatusMeta}; +use nostr::Timestamp; +use serde::Deserialize; + +const ISSUE_ASSIGNMENT_LABEL: &str = "assignment"; +const ISSUE_UNASSIGNMENT_LABEL: &str = "unassignment"; + +fn assignment_note_label(assignees: &[String], label: Option<&str>) -> Result { + if let Some(label) = label { + let label = label.trim(); + if label.is_empty() || label.chars().count() > 128 { + return Err(CliError::Usage( + "--label must be between 1 and 128 characters".into(), + )); + } + return Ok(label.to_string()); + } + let prefixes = assignees + .iter() + .map(|assignee| format!("{}…", assignee.chars().take(8).collect::())) + .collect::>(); + for included in (0..=prefixes.len()).rev() { + let omitted = prefixes.len() - included; + let mut generated = prefixes[..included].join(", "); + if omitted > 0 { + let suffix = format!("{omitted} other{}", if omitted == 1 { "" } else { "s" }); + if !generated.is_empty() { + generated.push_str(", and "); + } + generated.push_str(&suffix); + } + if !generated.is_empty() && generated.chars().count() <= 128 { + return Ok(generated); + } + } + Err(CliError::Usage( + "Unable to generate an assignee label between 1 and 128 characters".into(), + )) +} + +#[derive(Clone, Copy)] +enum IssueAssignmentOperation { + Assign, + Unassign, +} + +#[derive(Debug)] +struct AssignmentEvent { + id: String, + pubkey: String, + created_at: u64, + tags: Vec>, +} + +#[derive(Debug, Deserialize)] +struct AssignmentQueryEvent { + id: String, + kind: u16, + pubkey: String, + created_at: u64, + tags: Vec>, +} + +impl From<&AssignmentQueryEvent> for AssignmentEvent { + fn from(event: &AssignmentQueryEvent) -> Self { + Self { + id: event.id.clone(), + pubkey: event.pubkey.clone(), + created_at: event.created_at, + tags: event.tags.clone(), + } + } +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct AssignmentState { + assignees: HashSet, + heads: HashMap, +} + +#[derive(Debug)] +struct ParsedAssignmentOperation { + id: String, + is_assignment: bool, + pubkeys: Vec, + prior: Option, +} + +fn tag_values<'a>(event: &'a AssignmentEvent, name: &str) -> Vec<&'a str> { + event + .tags + .iter() + .filter_map(|tag| { + if tag.first().map(String::as_str) != Some(name) { + return None; + } + tag.get(1) + .map(String::as_str) + .filter(|value| !value.is_empty()) + }) + .collect() +} + +fn has_root_tag(event: &AssignmentEvent, issue_id: &str) -> bool { + event.tags.iter().any(|tag| { + matches!( + tag.as_slice(), + [name, value, ..] if name == "e" && value == issue_id + ) + }) +} + +fn is_hex64(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn apply_assignment_operation(state: &mut AssignmentState, operation: ParsedAssignmentOperation) { + if let Some(prior) = operation.prior.as_ref() { + let Some(target) = operation.pubkeys.first() else { + return; + }; + if state.heads.get(target) != Some(prior) { + return; + } + } + for pubkey in operation.pubkeys { + if operation.is_assignment { + state.assignees.insert(pubkey.clone()); + } else { + state.assignees.remove(&pubkey); + } + state.heads.insert(pubkey, operation.id.clone()); + } +} + +fn reduce_assignment_operations( + issue_id: &str, + issue_author: &str, + repo_owner: &str, + events: &[AssignmentEvent], +) -> AssignmentState { + let issue_author = issue_author.to_ascii_lowercase(); + let repo_owner = repo_owner.to_ascii_lowercase(); + let mut events = events.iter().collect::>(); + events.sort_by(|left, right| { + left.created_at + .cmp(&right.created_at) + .then_with(|| left.id.cmp(&right.id)) + }); + + let mut uncaused_self_operations = Vec::new(); + let mut authoritative_operations = Vec::new(); + let mut causal_self_operations = Vec::new(); + for event in events { + if !has_root_tag(event, issue_id) { + continue; + } + let labels = tag_values(event, "t"); + let is_assignment = labels.contains(&ISSUE_ASSIGNMENT_LABEL); + let is_unassignment = labels.contains(&ISSUE_UNASSIGNMENT_LABEL); + if is_assignment == is_unassignment { + continue; + } + let signer = event.pubkey.to_ascii_lowercase(); + let pubkeys = tag_values(event, "p") + .into_iter() + .map(str::to_ascii_lowercase) + .collect::>(); + let is_authoritative = signer == issue_author || signer == repo_owner; + let is_self_operation = pubkeys.len() == 1 && pubkeys[0] == signer; + if !is_authoritative && !is_self_operation { + continue; + } + + let mut operation = ParsedAssignmentOperation { + id: event.id.to_ascii_lowercase(), + is_assignment, + pubkeys, + prior: None, + }; + if is_authoritative { + authoritative_operations.push(operation); + continue; + } + + let prior_tags = event + .tags + .iter() + .filter(|tag| tag.first().map(String::as_str) == Some("prior")) + .collect::>(); + if prior_tags.is_empty() { + uncaused_self_operations.push(operation); + } else if prior_tags.len() == 1 && prior_tags[0].get(1).is_some_and(|prior| is_hex64(prior)) + { + operation.prior = prior_tags[0].get(1).map(|prior| prior.to_ascii_lowercase()); + causal_self_operations.push(operation); + } + } + + let mut state = AssignmentState::default(); + for operation in uncaused_self_operations + .into_iter() + .chain(authoritative_operations) + .chain(causal_self_operations) + { + apply_assignment_operation(&mut state, operation); + } + state +} + +struct IssueAssignmentContext { + created_at: u64, + prior: Option, +} + +impl IssueAssignmentOperation { + fn content(self, label: &str) -> String { + match self { + Self::Assign => format!("Assigned this issue to {label}"), + Self::Unassign => format!("Unassigned {label} from this issue"), + } + } +} pub async fn cmd_create_issue( client: &BuzzClient, @@ -40,6 +264,185 @@ pub async fn cmd_create_issue( Ok(()) } +/// Publish an issue assignment: a kind:1 comment on the issue whose `p` +/// tags are the assignees, labeled `t: assignment` (same event shape the +/// Desktop app writes). Clients trust it when signed by the issue author +/// or repo owner, or when it is a self-assignment. +pub async fn cmd_assign_issue( + client: &BuzzClient, + issue: &str, + repo_owner: &str, + repo_id: &str, + assignees: &[String], + label: Option<&str>, +) -> Result<(), CliError> { + publish_issue_assignment_operation( + client, + issue, + repo_owner, + repo_id, + assignees, + label, + IssueAssignmentOperation::Assign, + ) + .await +} + +/// Publish an issue unassignment with the same trust rules as assignment. +pub async fn cmd_unassign_issue( + client: &BuzzClient, + issue: &str, + repo_owner: &str, + repo_id: &str, + assignees: &[String], + label: Option<&str>, +) -> Result<(), CliError> { + publish_issue_assignment_operation( + client, + issue, + repo_owner, + repo_id, + assignees, + label, + IssueAssignmentOperation::Unassign, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn publish_issue_assignment_operation( + client: &BuzzClient, + issue: &str, + repo_owner: &str, + repo_id: &str, + assignees: &[String], + label: Option<&str>, + operation: IssueAssignmentOperation, +) -> Result<(), CliError> { + validate_hex64(issue)?; + validate_hex64(repo_owner)?; + validate_repo_id(repo_id)?; + for assignee in assignees { + validate_hex64(assignee)?; + } + + let label = assignment_note_label(assignees, label)?; + let content = operation.content(&label); + let repo = GitRepoCoord { + owner: repo_owner.to_string(), + id: repo_id.to_string(), + }; + let signer = client.keys().public_key().to_hex(); + let is_self_service = assignees.len() == 1 + && assignees[0].eq_ignore_ascii_case(&signer) + && !signer.eq_ignore_ascii_case(repo_owner); + let context = issue_assignment_context(client, issue, &repo, &signer, is_self_service).await?; + let builder = match (operation, is_self_service) { + (IssueAssignmentOperation::Assign, true) => { + buzz_sdk::build_git_issue_assignment_with_prior( + &repo, + issue, + assignees, + &content, + context.prior.as_deref(), + ) + } + (IssueAssignmentOperation::Unassign, true) => { + buzz_sdk::build_git_issue_unassignment_with_prior( + &repo, + issue, + assignees, + &content, + context.prior.as_deref(), + ) + } + (IssueAssignmentOperation::Assign, false) => { + buzz_sdk::build_git_issue_assignment(&repo, issue, assignees, &content) + } + (IssueAssignmentOperation::Unassign, false) => { + buzz_sdk::build_git_issue_unassignment(&repo, issue, assignees, &content) + } + } + .map(|builder| builder.custom_created_at(Timestamp::from_secs(context.created_at))); + let event = client.sign_event(builder.map_err(sdk_err)?)?; + let resp = client.submit_event(event).await?; + println!("{resp}"); + Ok(()) +} + +async fn issue_assignment_context( + client: &BuzzClient, + issue: &str, + repo: &GitRepoCoord, + signer: &str, + include_prior: bool, +) -> Result { + let root_filter = serde_json::json!({ + "kinds": [1621], + "ids": [issue], + "limit": 1 + }); + let assignment_filter = serde_json::json!({ + "kinds": [1], + "#e": [issue], + "#t": [ISSUE_ASSIGNMENT_LABEL, ISSUE_UNASSIGNMENT_LABEL], + "limit": 500 + }); + let signer_comment_filter = serde_json::json!({ + "kinds": [1], + "#e": [issue], + "authors": [signer], + "limit": 1 + }); + let response = client + .query_multi(&[root_filter, assignment_filter, signer_comment_filter]) + .await?; + // CLI read responses intentionally omit signatures, so deserialize only + // the event fields needed for assignment reduction. + let events = serde_json::from_str::>(&response) + .map_err(|error| CliError::Other(format!("parse issue assignment context: {error}")))?; + let root = events + .iter() + .find(|event| event.kind == 1621 && event.id == issue) + .ok_or_else(|| CliError::Other("issue root was not returned by the relay".into()))?; + let expected_repo = format!("30617:{}:{}", repo.owner.to_ascii_lowercase(), repo.id); + let root_matches_repo = root.tags.iter().any(|tag| { + tag.as_slice().first().map(String::as_str) == Some("a") + && tag.as_slice().get(1) == Some(&expected_repo) + }); + if !root_matches_repo { + return Err(CliError::Other( + "issue root does not match the requested repository".into(), + )); + } + + let comments = events + .iter() + .filter(|event| event.kind == 1) + .map(AssignmentEvent::from) + .collect::>(); + let latest = comments + .iter() + .filter(|event| event.pubkey.eq_ignore_ascii_case(signer)) + .map(|event| event.created_at) + .max() + .unwrap_or(0); + let created_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|error| CliError::Other(format!("read system clock: {error}")))? + .as_secs() + .max(latest.saturating_add(1)); + let prior = include_prior + .then(|| { + reduce_assignment_operations(issue, &root.pubkey, &repo.owner, &comments) + .heads + .get(&signer.to_ascii_lowercase()) + .cloned() + }) + .flatten(); + Ok(IssueAssignmentContext { created_at, prior }) +} + pub async fn cmd_get_issue(client: &BuzzClient, event: &str) -> Result<(), CliError> { validate_hex64(event)?; let filter = serde_json::json!({ @@ -202,5 +605,197 @@ pub async fn dispatch(cmd: crate::IssuesCmd, client: &BuzzClient) -> Result<(), ) .await } + IssuesCmd::Assign { + issue, + repo_owner, + repo_id, + assignee, + label, + } => { + cmd_assign_issue( + client, + &issue, + &repo_owner, + &repo_id, + &assignee, + label.as_deref(), + ) + .await + } + IssuesCmd::Unassign { + issue, + repo_owner, + repo_id, + assignee, + label, + } => { + cmd_unassign_issue( + client, + &issue, + &repo_owner, + &repo_id, + &assignee, + label.as_deref(), + ) + .await + } + } +} + +#[cfg(test)] +mod tests { + use super::{ + assignment_note_label, reduce_assignment_operations, AssignmentEvent, AssignmentQueryEvent, + ISSUE_ASSIGNMENT_LABEL, ISSUE_UNASSIGNMENT_LABEL, + }; + + const ISSUE: &str = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + const AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const OWNER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const VOLUNTEER: &str = "5555555555555555555555555555555555555555555555555555555555555555"; + + fn assignment_event( + pubkey: &str, + id: &str, + assignment: bool, + created_at: u64, + prior: Option<&str>, + ) -> AssignmentEvent { + let mut tags = vec![ + vec!["e".into(), ISSUE.into(), "".into(), "root".into()], + vec!["p".into(), VOLUNTEER.into()], + vec![ + "t".into(), + if assignment { + ISSUE_ASSIGNMENT_LABEL.into() + } else { + ISSUE_UNASSIGNMENT_LABEL.into() + }, + ], + ]; + if let Some(prior) = prior { + tags.push(vec!["prior".into(), prior.into()]); + } + AssignmentEvent { + id: id.into(), + pubkey: pubkey.into(), + created_at, + tags, + } + } + + #[test] + fn assignment_note_label_enforces_desktop_length_limit() { + let assignees = vec!["a".repeat(64)]; + assert_eq!( + assignment_note_label(&assignees, Some(" Thomas ")).unwrap(), + "Thomas" + ); + assert!(assignment_note_label(&assignees, Some("")).is_err()); + assert!(assignment_note_label(&assignees, Some(&"x".repeat(129))).is_err()); + assert_eq!( + assignment_note_label(&assignees, None).unwrap(), + "aaaaaaaa…" + ); + let many_assignees = (0..50) + .map(|index| format!("{index:064x}")) + .collect::>(); + let generated = assignment_note_label(&many_assignees, None).unwrap(); + assert!(generated.chars().count() <= 128); + assert!(generated.contains("others")); + } + + #[test] + fn assignment_query_event_accepts_sig_stripped_cli_reads() { + let event = serde_json::from_value::(serde_json::json!({ + "id": "1".repeat(64), + "kind": 1, + "pubkey": VOLUNTEER, + "created_at": 200, + "tags": [["e", ISSUE, "", "root"]] + })) + .unwrap(); + + assert_eq!(event.kind, 1); + assert_eq!(event.pubkey, VOLUNTEER); + } + + #[test] + fn uncaused_future_self_operations_lose_to_authority() { + let owner_unassign = "1".repeat(64); + let state = reduce_assignment_operations( + ISSUE, + AUTHOR, + OWNER, + &[ + assignment_event(VOLUNTEER, &"2".repeat(64), true, 1_000, None), + assignment_event(OWNER, &owner_unassign, false, 200, None), + ], + ); + assert!(!state.assignees.contains(VOLUNTEER)); + assert_eq!(state.heads.get(VOLUNTEER), Some(&owner_unassign)); + + let owner_assign = "3".repeat(64); + let state = reduce_assignment_operations( + ISSUE, + AUTHOR, + OWNER, + &[ + assignment_event(VOLUNTEER, &"4".repeat(64), false, 1_000, None), + assignment_event(OWNER, &owner_assign, true, 200, None), + ], + ); + assert!(state.assignees.contains(VOLUNTEER)); + assert_eq!(state.heads.get(VOLUNTEER), Some(&owner_assign)); + } + + #[test] + fn causal_self_operations_can_follow_authority() { + let owner_assign = "5".repeat(64); + let self_unassign = "6".repeat(64); + let state = reduce_assignment_operations( + ISSUE, + AUTHOR, + OWNER, + &[ + assignment_event(OWNER, &owner_assign, true, 200, None), + assignment_event(VOLUNTEER, &self_unassign, false, 300, Some(&owner_assign)), + ], + ); + assert!(!state.assignees.contains(VOLUNTEER)); + assert_eq!(state.heads.get(VOLUNTEER), Some(&self_unassign)); + + let owner_unassign = "7".repeat(64); + let self_assign = "8".repeat(64); + let state = reduce_assignment_operations( + ISSUE, + AUTHOR, + OWNER, + &[ + assignment_event(OWNER, &owner_unassign, false, 200, None), + assignment_event(VOLUNTEER, &self_assign, true, 300, Some(&owner_unassign)), + ], + ); + assert!(state.assignees.contains(VOLUNTEER)); + assert_eq!(state.heads.get(VOLUNTEER), Some(&self_assign)); + } + + #[test] + fn stale_causal_self_operation_is_ignored() { + let initial_assign = "9".repeat(64); + let owner_unassign = "a".repeat(64); + let state = reduce_assignment_operations( + ISSUE, + AUTHOR, + OWNER, + &[ + assignment_event(OWNER, &initial_assign, true, 100, None), + assignment_event(OWNER, &owner_unassign, false, 200, None), + assignment_event(VOLUNTEER, &"c".repeat(64), true, 300, Some(&initial_assign)), + ], + ); + + assert!(!state.assignees.contains(VOLUNTEER)); + assert_eq!(state.heads.get(VOLUNTEER), Some(&owner_unassign)); } } diff --git a/crates/buzz-cli/src/commands/projects.rs b/crates/buzz-cli/src/commands/projects.rs index e6798dbfc44..32056bc6991 100644 --- a/crates/buzz-cli/src/commands/projects.rs +++ b/crates/buzz-cli/src/commands/projects.rs @@ -108,13 +108,29 @@ fn make_tag(parts: &[&str]) -> Result { // ── Submit helper ───────────────────────────────────────────────────────────── -async fn submit_project(client: &BuzzClient, builder: EventBuilder) -> Result<(), CliError> { +/// Submit a project event and print the relay's write response. +/// +/// `link_slug` carries the project's d-tag on creates whose slug fits the +/// `buzz://` link charset; the response then also carries a `link` field, +/// which renders as a rich preview card in Buzz Desktop when included in a +/// chat message — agents announce projects with it (see base_prompt.md). +async fn submit_project( + client: &BuzzClient, + builder: EventBuilder, + link_slug: Option<&str>, +) -> Result<(), CliError> { let event = client.sign_event(builder)?; + let owner = event.pubkey.to_hex(); let raw = client.submit_event(event).await?; - println!( - "{}", - parse_write_response(&raw, "project changed concurrently; retry")? - ); + let response = parse_write_response(&raw, "project changed concurrently; retry")?; + match link_slug { + Some(slug) => crate::client::print_create_response( + &response, + "link", + &crate::links::project_link(&owner, slug), + ), + None => println!("{response}"), + } Ok(()) } @@ -207,7 +223,15 @@ pub async fn cmd_create( // ── Build via Layer B (enforces all writer policy) ──────────────────── let builder = build_project(slug, name, description, &members, channel, visibility) .map_err(|e| CliError::Usage(e.to_string()))?; - submit_project(client, builder).await + + // Slugs wider than the link charset stay linkless rather than emitting a + // `link` no client can parse. + submit_project( + client, + builder, + crate::links::is_linkable_dtag(slug).then_some(slug), + ) + .await } /// `buzz projects get` @@ -320,7 +344,7 @@ pub async fn cmd_add_repo( } let builder = rebuild_project(&head.content, tags, next_ts)?; - submit_project(client, builder).await + submit_project(client, builder, None).await } /// `buzz projects remove-repo` @@ -383,7 +407,7 @@ pub async fn cmd_remove_repo( // Single rebuild validates the full envelope and strips any remaining auth. let builder = rebuild_project(&head.content, tags, next_ts)?; - submit_project(client, builder).await + submit_project(client, builder, None).await } /// `buzz projects update` @@ -484,7 +508,7 @@ pub async fn cmd_update( let builder = build_project_with_tags(&head.content, tags) .map_err(|e| CliError::Other(format!("envelope validation failed: {e}")))? .custom_created_at(next_ts); - submit_project(client, builder).await + submit_project(client, builder, None).await } /// `buzz projects delete` diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 73e89f2c231..16d74a4e659 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1744,6 +1744,47 @@ pub enum IssuesCmd { #[arg(long = "to")] to: Vec, }, + /// Assign an issue to one or more people or agents. Only assignments + /// signed by the issue author or repo owner are trusted by clients; + /// anyone may assign themselves (sole assignee = your own pubkey). + Assign { + /// Issue event id (64-char hex) + #[arg(long)] + issue: String, + /// Repo owner pubkey (64-char hex) + #[arg(long)] + repo_owner: String, + /// Repo identifier (d-tag) + #[arg(long)] + repo_id: String, + /// Assignee pubkey (64-char hex) — can be specified multiple times + #[arg(long = "assignee", required = true)] + assignee: Vec, + /// Human-readable assignee name(s) for the note body, e.g. "Thomas". + /// Defaults to the truncated assignee pubkeys. + #[arg(long)] + label: Option, + }, + /// Remove one or more assignees from an issue. Issue authors and repo + /// owners may remove anyone; other users may remove only themselves. + Unassign { + /// Issue event id (64-char hex) + #[arg(long)] + issue: String, + /// Repo owner pubkey (64-char hex) + #[arg(long)] + repo_owner: String, + /// Repo identifier (d-tag) + #[arg(long)] + repo_id: String, + /// Assignee pubkey to remove — can be specified multiple times + #[arg(long = "assignee", required = true)] + assignee: Vec, + /// Human-readable assignee name(s) for the note body. + /// Defaults to the truncated assignee pubkeys. + #[arg(long)] + label: Option, + }, } #[derive(Subcommand)] @@ -2354,7 +2395,7 @@ mod tests { ); assert_eq!( names(&cmd, "issues"), - vec!["create", "get", "list", "status"] + vec!["assign", "create", "get", "list", "status", "unassign"] ); assert_eq!(names(&cmd, "media"), vec!["get"]); assert_eq!(names(&cmd, "upload"), vec!["file"]); @@ -2383,7 +2424,7 @@ mod tests { ("dms", 4), ("emoji", 5), ("feed", 1), - ("issues", 4), + ("issues", 6), ("media", 1), ("messages", 8), ("pack", 2), diff --git a/crates/buzz-cli/src/links.rs b/crates/buzz-cli/src/links.rs index 043bdc48b05..7d512710d43 100644 --- a/crates/buzz-cli/src/links.rs +++ b/crates/buzz-cli/src/links.rs @@ -8,12 +8,41 @@ //! //! Callers are expected to validate inputs first (`validate_hex64`, //! `validate_repo_id`); the identifier charsets need no URL encoding. +//! +//! Coordinate links additionally accept an optional `&tab=` parameter +//! (`files|commits|issues|prs|contributors|channels`) selecting a workspace tab on +//! the receiving side. The CLI builders emit the canonical no-tab form +//! (overview); the parameter exists for the desktop's tab-aware copy-link +//! button. + +/// Whether a d-tag can be expressed in a `buzz://` link. +/// +/// Project slugs accept up to 1024 bytes of arbitrary UTF-8, but the link +/// format is restricted to `[a-zA-Z0-9._-]{1,64}` (no leading dot, no `..`) +/// so links need no escaping and stay safe to paste. Callers must check +/// before building a link and omit the field when it returns false, rather +/// than emitting a link no client can parse. Mirrors `isValidDtag` in +/// `desktop/src/shared/lib/entityLink.ts`. +pub fn is_linkable_dtag(dtag: &str) -> bool { + !dtag.is_empty() + && dtag.len() <= 64 + && !dtag.starts_with('.') + && !dtag.contains("..") + && dtag + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') +} /// Build a `buzz://repo` link for a repository announcement (kind 30617). pub fn repo_link(owner: &str, repo_id: &str) -> String { format!("buzz://repo?owner={owner}&d={repo_id}") } +/// Build a `buzz://project` link for a project announcement (kind 30621). +pub fn project_link(owner: &str, project_id: &str) -> String { + format!("buzz://project?owner={owner}&d={project_id}") +} + /// Build a `buzz://pr` link for a pull request event (kind 1618). pub fn pull_request_link(event_id: &str, owner: &str, repo_id: &str) -> String { format!("buzz://pr?id={event_id}&owner={owner}&d={repo_id}") @@ -27,25 +56,49 @@ pub fn issue_link(event_id: &str, owner: &str, repo_id: &str) -> String { #[cfg(test)] mod tests { use super::*; + use serde_json::Value; - const OWNER: &str = "71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224"; - const EVENT_ID: &str = "c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1"; + fn golden() -> Value { + serde_json::from_str(include_str!("../../../test-fixtures/entity-links.json")) + .expect("valid entity-links golden fixture") + } - // Golden strings shared with desktop/src/shared/lib/entityLink.test.mjs - // ("builders emit the canonical cross-language link format"). #[test] fn golden_format_matches_desktop() { + let golden = golden(); + let owner = golden["owner"].as_str().unwrap(); + let event_id = golden["eventId"].as_str().unwrap(); + let dtag = golden["dtag"].as_str().unwrap(); assert_eq!( - pull_request_link(EVENT_ID, OWNER, "buzz-world"), - format!("buzz://pr?id={EVENT_ID}&owner={OWNER}&d=buzz-world") + pull_request_link(event_id, owner, dtag), + golden["links"]["pullRequest"].as_str().unwrap() ); assert_eq!( - issue_link(EVENT_ID, OWNER, "buzz-world"), - format!("buzz://issue?id={EVENT_ID}&owner={OWNER}&d=buzz-world") + issue_link(event_id, owner, dtag), + golden["links"]["issue"].as_str().unwrap() ); assert_eq!( - repo_link(OWNER, "buzz-world"), - format!("buzz://repo?owner={OWNER}&d=buzz-world") + repo_link(owner, dtag), + golden["links"]["repository"].as_str().unwrap() ); + assert_eq!( + project_link(owner, dtag), + golden["links"]["project"].as_str().unwrap() + ); + } + + #[test] + fn linkable_dtag_matches_the_desktop_charset() { + let golden = golden(); + for ok in golden["validDtags"].as_array().unwrap() { + let ok = ok.as_str().unwrap(); + assert!(is_linkable_dtag(ok), "{ok:?} should be linkable"); + } + assert!(is_linkable_dtag(&"a".repeat(64))); + for bad in golden["invalidDtags"].as_array().unwrap() { + let bad = bad.as_str().unwrap(); + assert!(!is_linkable_dtag(bad), "{bad:?} should not be linkable"); + } + assert!(!is_linkable_dtag(&"a".repeat(65))); } } diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 948fa775f51..30311ddcf46 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -1121,6 +1121,131 @@ pub fn build_git_issue( Ok(EventBuilder::new(Kind::Custom(KIND_GIT_ISSUE as u16), content).tags(tags)) } +/// Build an issue assignment note (kind:1) — a labeled comment whose `p` +/// tags are the assignees, mirroring the Desktop app's assignment events. +/// +/// Tag layout: `["e", , "", "root"]`, `["a", ]`, one `["p", ..]` +/// per assignee, and `["t", "assignment"]`. +/// +/// Clients only trust assignments signed by the issue author or the repo +/// owner (who may assign anyone), or a self-assignment whose sole assignee +/// is the signer. Assignments from other signers are ignored on read. +pub fn build_git_issue_assignment( + repo: &GitRepoCoord, + issue_id: &str, + assignees: &[String], + content: &str, +) -> Result { + build_git_issue_assignment_with_prior(repo, issue_id, assignees, content, None) +} + +/// Build an issue assignment note with an optional causal assignment-operation +/// event ID in a `["prior", ]` tag. +/// +/// `prior`, when present, must be a 64-character hexadecimal event ID. +pub fn build_git_issue_assignment_with_prior( + repo: &GitRepoCoord, + issue_id: &str, + assignees: &[String], + content: &str, + prior: Option<&str>, +) -> Result { + build_git_issue_assignee_operation( + repo, + issue_id, + assignees, + content, + GitIssueAssigneeOperation::Assign, + prior, + ) +} + +/// Build an issue unassignment note (kind:1) whose `p` tags name the people +/// being removed and whose operation label is `t: unassignment`. +/// +/// Clients trust unassignments signed by the issue author or repository owner, +/// or a self-unassignment whose sole `p` tag is the signer. +pub fn build_git_issue_unassignment( + repo: &GitRepoCoord, + issue_id: &str, + assignees: &[String], + content: &str, +) -> Result { + build_git_issue_unassignment_with_prior(repo, issue_id, assignees, content, None) +} + +/// Build an issue unassignment note with an optional causal +/// assignment-operation event ID in a `["prior", ]` tag. +/// +/// `prior`, when present, must be a 64-character hexadecimal event ID. +pub fn build_git_issue_unassignment_with_prior( + repo: &GitRepoCoord, + issue_id: &str, + assignees: &[String], + content: &str, + prior: Option<&str>, +) -> Result { + build_git_issue_assignee_operation( + repo, + issue_id, + assignees, + content, + GitIssueAssigneeOperation::Unassign, + prior, + ) +} + +#[derive(Clone, Copy)] +enum GitIssueAssigneeOperation { + Assign, + Unassign, +} + +impl GitIssueAssigneeOperation { + fn label(self) -> &'static str { + match self { + Self::Assign => "assignment", + Self::Unassign => "unassignment", + } + } +} + +fn build_git_issue_assignee_operation( + repo: &GitRepoCoord, + issue_id: &str, + assignees: &[String], + content: &str, + operation: GitIssueAssigneeOperation, + prior: Option<&str>, +) -> Result { + check_content(content, 64 * 1024)?; + let issue = check_hex_exact(issue_id, 64, "issue")?; + let a_value = repo.to_a_tag_value()?; + if assignees.is_empty() || assignees.len() > 50 { + return Err(SdkError::InvalidInput( + "between 1 and 50 assignees are required".into(), + )); + } + let mut normalized = assignees + .iter() + .map(|assignee| check_pubkey_hex(assignee, "assignee")) + .collect::, _>>()?; + normalized.sort(); + normalized.dedup(); + + let mut tags = vec![tag(&["e", &issue, "", "root"])?, tag(&["a", &a_value])?]; + for assignee in &normalized { + tags.push(tag(&["p", assignee])?); + } + tags.push(tag(&["t", operation.label()])?); + if let Some(prior) = prior { + let prior = check_hex_exact(prior, 64, "prior assignment operation")?; + tags.push(tag(&["prior", &prior])?); + } + + Ok(EventBuilder::new(Kind::Custom(1), content).tags(tags)) +} + /// Status to apply to a patch or issue root (kind:1630/1631/1632/1633, NIP-34). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GitStatus { @@ -3481,6 +3606,149 @@ mod tests { assert!(matches!(err, SdkError::InvalidInput(_))); } + #[test] + fn git_issue_assignment_happy_path() { + let owner = "a".repeat(64); + let repo = GitRepoCoord { + owner: owner.clone(), + id: "repo".to_string(), + }; + let issue = "b".repeat(64); + // Duplicates (case-insensitive) collapse to a single p tag. + let assignees = vec!["C".repeat(64), "c".repeat(64), "d".repeat(64)]; + let ev = sign( + build_git_issue_assignment(&repo, &issue, &assignees, "Assigned this issue to Thomas") + .unwrap(), + ); + assert_eq!(ev.kind.as_u16(), 1); + assert_eq!(ev.content, "Assigned this issue to Thomas"); + assert!(has_tag(&ev, "e", &issue)); + assert!(has_tag(&ev, "a", &format!("30617:{owner}:repo"))); + assert!(has_tag(&ev, "p", &"c".repeat(64))); + assert!(has_tag(&ev, "p", &"d".repeat(64))); + assert!(has_tag(&ev, "t", "assignment")); + let p_count = ev + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some("p")) + .count(); + assert_eq!(p_count, 2); + } + + #[test] + fn git_issue_assignment_rejects_bad_input() { + let repo = GitRepoCoord { + owner: "a".repeat(64), + id: "repo".to_string(), + }; + let issue = "b".repeat(64); + // No assignees. + let err = build_git_issue_assignment(&repo, &issue, &[], "x").unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + // Malformed assignee pubkey. + let err = + build_git_issue_assignment(&repo, &issue, &["nope".to_string()], "x").unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + // Malformed issue id. + let err = build_git_issue_assignment(&repo, "short", &["c".repeat(64)], "x").unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn git_issue_assignment_with_prior_emits_valid_causal_tag() { + let repo = GitRepoCoord { + owner: "a".repeat(64), + id: "repo".to_string(), + }; + let issue = "b".repeat(64); + let assignee = "c".repeat(64); + let prior = "d".repeat(64); + let ev = sign( + build_git_issue_assignment_with_prior( + &repo, + &issue, + &[assignee], + "Assigned this issue", + Some(&prior), + ) + .unwrap(), + ); + + assert!(has_tag(&ev, "prior", &prior)); + let unassignment = sign( + build_git_issue_unassignment_with_prior( + &repo, + &issue, + &["c".repeat(64)], + "Unassigned this issue", + Some(&prior), + ) + .unwrap(), + ); + assert!(has_tag(&unassignment, "prior", &prior)); + assert!(build_git_issue_assignment_with_prior( + &repo, + &issue, + &["c".repeat(64)], + "Assigned this issue", + Some("invalid"), + ) + .is_err()); + } + + #[test] + fn git_issue_unassignment_happy_path() { + let owner = "a".repeat(64); + let repo = GitRepoCoord { + owner: owner.clone(), + id: "repo".to_string(), + }; + let issue = "b".repeat(64); + let assignee = "c".repeat(64); + let ev = sign( + build_git_issue_unassignment( + &repo, + &issue, + std::slice::from_ref(&assignee), + "Unassigned Thomas from this issue", + ) + .unwrap(), + ); + assert_eq!(ev.kind.as_u16(), 1); + assert_eq!(ev.content, "Unassigned Thomas from this issue"); + assert!(has_tag(&ev, "e", &issue)); + assert!(has_tag(&ev, "a", &format!("30617:{owner}:repo"))); + assert!(has_tag(&ev, "p", &assignee)); + assert!(has_tag(&ev, "t", "unassignment")); + assert!(!has_tag(&ev, "t", "assignment")); + } + + #[test] + fn legacy_issue_assignment_builders_omit_prior() { + let repo = GitRepoCoord { + owner: "a".repeat(64), + id: "repo".to_string(), + }; + let issue = "b".repeat(64); + let assignees = vec!["c".repeat(64)]; + let assignment = sign( + build_git_issue_assignment(&repo, &issue, &assignees, "Assigned this issue").unwrap(), + ); + let unassignment = sign( + build_git_issue_unassignment(&repo, &issue, &assignees, "Unassigned this issue") + .unwrap(), + ); + + assert!(!assignment + .tags + .iter() + .any(|tag| { tag.as_slice().first().map(String::as_str) == Some("prior") })); + assert!(!unassignment + .tags + .iter() + .any(|tag| { tag.as_slice().first().map(String::as_str) == Some("prior") })); + } + #[test] fn git_status_open_happy_path() { let root = event_id().to_hex(); diff --git a/desktop/package.json b/desktop/package.json index b6581d48057..39e93d8a98d 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.11", + "version": "0.5.14", "type": "module", "scripts": { "dev": "vite", @@ -67,6 +67,7 @@ "emoji-mart": "^5.6.0", "jdenticon": "^3.3.0", "lucide-react": "^1.0.0", + "mdast-util-from-markdown": "^2.0.3", "motion": "^12.38.0", "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index e930f0ef612..7d06c4da91b 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ name: "smoke", testMatch: [ "**/smoke.spec.ts", + "**/sidebar-offcanvas-rail.spec.ts", "**/search-scope-screenshots.spec.ts", "**/onboarding-docked-cta-screenshots.spec.ts", "**/identity-key-help.spec.ts", @@ -109,6 +110,7 @@ export default defineConfig({ "**/send-channel-binding.spec.ts", "**/project-commit-detail.spec.ts", "**/project-inbox.spec.ts", + "**/projects-v3-screenshots.spec.ts", "**/project-issue-comments.spec.ts", "**/project-pr-review.spec.ts", "**/persona-model-combobox-screenshots.spec.ts", @@ -130,6 +132,7 @@ export default defineConfig({ "**/profile-nsec-reveal.spec.ts", "**/profile-backup-settings.spec.ts", "**/signout-confirmation.spec.ts", + "**/settings-section-layout.spec.ts", "**/agent-provider-dropdowns.spec.ts", "**/agent-lifecycle-feedback.spec.ts", "**/agent-access-warning.spec.ts", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 6c206264679..89443be046a 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1084,7 +1084,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.11" +version = "0.5.14" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 54676458737..527690df14f 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -7,7 +7,7 @@ members = ["crates/buzz-terminal"] [package] name = "buzz-desktop" -version = "0.5.11" +version = "0.5.14" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 453bb81fb0c..26136719706 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -117,19 +117,14 @@ pub(super) fn tombstone_managed_agent_pending( } } -/// Build and sign the NIP-IA `kind:9035` archive request enqueued when an -/// agent is deleted. Pure given the keys — unit-testable without an -/// `AppHandle`. Reuses the same wire builder as the GUI's Archive action -/// (`events::build_archive_identity_request`); the machine-readable reason is -/// `retired` (NIP-IA suggested code for a deliberately decommissioned key). -/// -/// The owner auth tag is minted locally from the same keys used to sign the -/// request, avoiding a network fetch while the managed-agent store lock is -/// held. The relay still independently verifies it against the agent's live -/// kind:0. +/// Build an owner-authenticated NIP-IA `kind:9035` archive request for a deleted agent. +/// Definition-linked agents carry the persona id in `content`, where it survives the +/// kind:30177 tombstone as owner-signed historical alias data. The request uses the +/// same builder as the GUI Archive action and the NIP-IA `retired` reason. pub(super) fn build_agent_archive_request( keys: &nostr::Keys, agent_pubkey: &str, + persona_id: Option<&str>, ) -> Result { let auth_tag = if keys .public_key() @@ -149,9 +144,13 @@ pub(super) fn build_agent_archive_request( .map_err(|_| "owner auth tag must have four elements".to_string())?, ) }; + let content = persona_id + .filter(|id| !id.trim().is_empty()) + .map(|id| serde_json::json!({ "persona_id": id }).to_string()) + .unwrap_or_default(); crate::events::build_archive_identity_request( agent_pubkey, - "", + &content, Some("retired"), None, auth_tag.as_ref(), @@ -160,22 +159,15 @@ pub(super) fn build_agent_archive_request( .map_err(|e| format!("failed to sign archive request: {e}")) } -/// Enqueue a NIP-IA `kind:9035` archive request for a deleted agent, retained -/// next to its kind:5 tombstone with `pending_sync = 1`. -/// -/// The tombstone removes the agent's 30177 record cross-device, but the -/// agent's `kind:0` and channel membership keep populating member pickers and -/// autocomplete on the relay until the identity is archived. Retaining the -/// request here gives archival the same offline durability as the tombstone; -/// the flush loop is the sole publisher and re-signs the request with a fresh -/// `created_at` at publish time, because the relay enforces a ±120s freshness -/// window on 9035s. -/// -/// Same contract as `tombstone_managed_agent_pending`: called inside the -/// `managed_agents_store_lock`-held delete body, never across an `.await`, -/// best-effort — a failure is logged and swallowed so it never blocks the -/// disk-authoritative delete. -pub(super) fn archive_managed_agent_pending(app: &AppHandle, state: &AppState, agent_pubkey: &str) { +/// Durably enqueue the archive request next to the kind:5 tombstone. The flush +/// loop re-signs it with a relay-fresh timestamp. Best-effort and lock-scoped, +/// matching `tombstone_managed_agent_pending`. +pub(super) fn archive_managed_agent_pending( + app: &AppHandle, + state: &AppState, + agent_pubkey: &str, + persona_id: Option<&str>, +) { use crate::managed_agents::retention::{open_retention_db, retain_event, RetainedEvent}; use buzz_core_pkg::kind::KIND_IA_ARCHIVE_REQUEST; use nostr::JsonUtil; @@ -183,7 +175,7 @@ pub(super) fn archive_managed_agent_pending(app: &AppHandle, state: &AppState, a let result = (|| -> Result<(), String> { let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; let owner_pubkey = scope.owner_keys.public_key().to_hex(); - let event = build_agent_archive_request(&scope.owner_keys, agent_pubkey)?; + let event = build_agent_archive_request(&scope.owner_keys, agent_pubkey, persona_id)?; let conn = open_retention_db(&scope.db_path)?; retain_event( &conn, @@ -1321,6 +1313,10 @@ pub async fn delete_managed_agent( } } + let persona_id = records + .iter() + .find(|record| record.pubkey == pubkey) + .and_then(|record| record.persona_id.clone()); if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { stop_managed_agent_process(&app, record, &mut runtimes)?; } @@ -1341,7 +1337,7 @@ pub async fn delete_managed_agent( // NIP-IA: archive the deleted agent's identity on the relay so it // stops appearing in member pickers and autocomplete. Same // best-effort, inside-the-lock contract as the tombstone above. - archive_managed_agent_pending(&app, &state, &pubkey); + archive_managed_agent_pending(&app, &state, &pubkey, persona_id.as_deref()); } try_regenerate_nest(&app); Ok(()) diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 54a03e2babe..f550a72e0c3 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -98,8 +98,12 @@ fn build_agent_archive_request_attaches_owner_auth_and_retired_reason() { let owner = nostr::Keys::generate(); let agent = nostr::Keys::generate(); - let event = build_agent_archive_request(&owner, &agent.public_key().to_hex()) - .expect("build archive request"); + let event = build_agent_archive_request( + &owner, + &agent.public_key().to_hex(), + Some("persona-reviewer"), + ) + .expect("build archive request"); let json: serde_json::Value = serde_json::from_str(&event.as_json()).unwrap(); let tags = json["tags"].as_array().unwrap(); @@ -107,6 +111,7 @@ fn build_agent_archive_request_attaches_owner_auth_and_retired_reason() { assert_eq!(event.pubkey, owner.public_key()); assert!(event.verify_id()); assert!(event.verify_signature()); + assert_eq!(event.content, r#"{"persona_id":"persona-reviewer"}"#); assert!(tags.iter().any(|tag| { tag.as_array().is_some_and(|parts| { parts.first().and_then(serde_json::Value::as_str) == Some("p") diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index 732c750a135..b781f8d9e68 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -10,6 +10,8 @@ use reqwest::{ use serde::Serialize; use url::Url; +#[path = "link_preview_image_retry.rs"] +mod image_retry; #[path = "link_preview_rate_limit.rs"] mod rate_limit; #[path = "link_preview_youtube.rs"] @@ -108,10 +110,13 @@ async fn fetch_link_preview_metadata_inner( Some(image_url) => Some( tokio::time::timeout( PREVIEW_FETCH_TIMEOUT, - fetch_sanitized_image(image_url, false), + fetch_sanitized_image_with_retry(image_url, false), ) .await - .unwrap_or(Err(ImageFetchError::Transient { retry_after: None })), + .unwrap_or(Err(ImageFetchError::Transient { + retry_after: None, + retry_inline: false, + })), ), None => None, } @@ -149,7 +154,7 @@ fn apply_image_result( metadata.image_domain = Some(domain); metadata.image_fetch_state = LinkPreviewImageFetchState::Image; } - Some(Err(ImageFetchError::Transient { retry_after })) => { + Some(Err(ImageFetchError::Transient { retry_after, .. })) => { metadata.image_fetch_state = LinkPreviewImageFetchState::TransientFailure; metadata.image_retry_after_ms = retry_after.and_then(|duration| u64::try_from(duration.as_millis()).ok()); @@ -318,10 +323,23 @@ fn extract_image_url(html: &str, page_url: &Url) -> Option { #[derive(Debug, PartialEq)] enum ImageFetchError { - Transient { retry_after: Option }, + Transient { + retry_after: Option, + retry_inline: bool, + }, Rejected, } +async fn fetch_sanitized_image_with_retry( + url: Url, + preserve_transparency: bool, +) -> Result<(String, String), ImageFetchError> { + image_retry::retry_transient_image_fetch(|| { + fetch_sanitized_image(url.clone(), preserve_transparency) + }) + .await +} + async fn fetch_sanitized_image( mut url: Url, preserve_transparency: bool, @@ -333,11 +351,15 @@ async fn fetch_sanitized_image( if let Some(retry_after) = image_host_cooldown_remaining(&url) { return Err(ImageFetchError::Transient { retry_after: Some(retry_after), + retry_inline: false, }); } let response = send_pinned_request(&url, "image/jpeg,image/png,image/webp") .await - .map_err(|_| ImageFetchError::Transient { retry_after: None })?; + .map_err(|_| ImageFetchError::Transient { + retry_after: None, + retry_inline: true, + })?; if response.status().is_redirection() { if redirect_count == MAX_REDIRECTS { return Err(ImageFetchError::Rejected); @@ -364,7 +386,10 @@ async fn fetch_sanitized_image( if let Some(retry_after) = retry_after { set_image_host_cooldown(&url, retry_after); } - return Err(ImageFetchError::Transient { retry_after }); + return Err(ImageFetchError::Transient { + retry_after, + retry_inline: status != reqwest::StatusCode::TOO_MANY_REQUESTS, + }); } return Err(ImageFetchError::Rejected); } @@ -710,6 +735,7 @@ mod tests { &mut metadata, Some(Err(ImageFetchError::Transient { retry_after: Some(std::time::Duration::from_secs(15)), + retry_inline: false, })), ); assert_eq!( diff --git a/desktop/src-tauri/src/commands/link_preview_image_retry.rs b/desktop/src-tauri/src/commands/link_preview_image_retry.rs new file mode 100644 index 00000000000..f397c516367 --- /dev/null +++ b/desktop/src-tauri/src/commands/link_preview_image_retry.rs @@ -0,0 +1,75 @@ +use super::ImageFetchError; + +pub(super) async fn retry_transient_image_fetch( + mut fetch: F, +) -> Result<(String, String), ImageFetchError> +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + let first = fetch().await; + if matches!( + first, + Err(ImageFetchError::Transient { + retry_inline: true, + .. + }) + ) { + return fetch().await; + } + first +} + +#[cfg(test)] +mod tests { + use super::retry_transient_image_fetch; + use crate::commands::link_preview::ImageFetchError; + use std::{cell::Cell, time::Duration}; + + #[tokio::test] + async fn retries_one_transient_failure_inline() { + let attempts = Cell::new(0); + let result = retry_transient_image_fetch(|| { + let attempt = attempts.get() + 1; + attempts.set(attempt); + async move { + if attempt == 1 { + Err(ImageFetchError::Transient { + retry_after: None, + retry_inline: true, + }) + } else { + Ok(("image".to_string(), "example.com".to_string())) + } + } + }) + .await; + + assert!(result.is_ok()); + assert_eq!(attempts.get(), 2); + } + + #[tokio::test] + async fn does_not_retry_rate_limits_inline() { + let attempts = Cell::new(0); + let result = retry_transient_image_fetch(|| { + attempts.set(attempts.get() + 1); + async { + Err(ImageFetchError::Transient { + retry_after: Some(Duration::from_secs(60)), + retry_inline: false, + }) + } + }) + .await; + + assert_eq!( + result, + Err(ImageFetchError::Transient { + retry_after: Some(Duration::from_secs(60)), + retry_inline: false, + }) + ); + assert_eq!(attempts.get(), 1); + } +} diff --git a/desktop/src-tauri/src/commands/link_preview_youtube.rs b/desktop/src-tauri/src/commands/link_preview_youtube.rs index 722c481b5e9..a0a5a753dcd 100644 --- a/desktop/src-tauri/src/commands/link_preview_youtube.rs +++ b/desktop/src-tauri/src/commands/link_preview_youtube.rs @@ -60,7 +60,10 @@ pub(super) async fn fetch_oembed_metadata( fetch_sanitized_image(thumbnail_url, false), ) .await - .unwrap_or(Err(ImageFetchError::Transient { retry_after: None })), + .unwrap_or(Err(ImageFetchError::Transient { + retry_after: None, + retry_inline: false, + })), ), None => None, }; diff --git a/desktop/src-tauri/src/commands/media_raw.rs b/desktop/src-tauri/src/commands/media_raw.rs index a74ccd4dfe3..97081571623 100644 --- a/desktop/src-tauri/src/commands/media_raw.rs +++ b/desktop/src-tauri/src/commands/media_raw.rs @@ -27,7 +27,18 @@ pub async fn upload_media_bytes( app: tauri::AppHandle, state: State<'_, AppState>, ) -> Result { - upload_media_bytes_inner(data, filename, progress_id, app, state, None).await + let cancellation = begin_media_upload(progress_id.as_deref()); + let result = upload_media_bytes_inner( + data, + filename, + progress_id.clone(), + app, + state, + cancellation.as_ref(), + ) + .await; + finish_media_upload(progress_id.as_deref()); + result } fn decode_raw_upload_header(value: &str) -> Result { @@ -56,6 +67,12 @@ pub fn cancel_media_upload(progress_id: String) { cancel_registered_media_upload(&progress_id); } +/// Release the renderer's ownership after its upload promise settles. +#[tauri::command] +pub fn release_media_upload(progress_id: String) { + finish_media_upload(Some(&progress_id)); +} + /// Upload raw IPC bytes without expanding a large browser File into JSON. #[tauri::command] pub async fn upload_media_bytes_raw( diff --git a/desktop/src-tauri/src/commands/media_upload_progress.rs b/desktop/src-tauri/src/commands/media_upload_progress.rs index 850afe1b123..5ed3f786521 100644 --- a/desktop/src-tauri/src/commands/media_upload_progress.rs +++ b/desktop/src-tauri/src/commands/media_upload_progress.rs @@ -8,23 +8,45 @@ use tokio_util::sync::CancellationToken; use crate::{app_state::AppState, relay::classify_request_error}; -static MEDIA_UPLOAD_CANCELLATIONS: LazyLock>> = - LazyLock::new(|| Mutex::new(HashMap::new())); +#[derive(Default)] +struct MediaUploadCancellations { + tokens: HashMap, +} + +impl MediaUploadCancellations { + fn begin(&mut self, progress_id: &str) -> CancellationToken { + if let Some(cancel) = self.tokens.get(progress_id).cloned() { + return cancel; + } + let cancel = CancellationToken::new(); + self.tokens.insert(progress_id.to_string(), cancel.clone()); + cancel + } + + fn cancel(&mut self, progress_id: &str) { + let cancel = self.tokens.entry(progress_id.to_string()).or_default(); + cancel.cancel(); + } + + fn finish(&mut self, progress_id: &str) { + self.tokens.remove(progress_id); + } +} + +static MEDIA_UPLOAD_CANCELLATIONS: LazyLock> = + LazyLock::new(|| Mutex::new(MediaUploadCancellations::default())); pub(super) fn begin_media_upload(progress_id: Option<&str>) -> Option { let progress_id = progress_id?; - let cancel = CancellationToken::new(); - if let Ok(mut uploads) = MEDIA_UPLOAD_CANCELLATIONS.lock() { - uploads.insert(progress_id.to_string(), cancel.clone()); - } - Some(cancel) + MEDIA_UPLOAD_CANCELLATIONS + .lock() + .ok() + .map(|mut uploads| uploads.begin(progress_id)) } pub(super) fn cancel_media_upload(progress_id: &str) { - if let Ok(uploads) = MEDIA_UPLOAD_CANCELLATIONS.lock() { - if let Some(cancel) = uploads.get(progress_id) { - cancel.cancel(); - } + if let Ok(mut uploads) = MEDIA_UPLOAD_CANCELLATIONS.lock() { + uploads.cancel(progress_id); } } @@ -33,7 +55,7 @@ pub(super) fn finish_media_upload(progress_id: Option<&str>) { return; }; if let Ok(mut uploads) = MEDIA_UPLOAD_CANCELLATIONS.lock() { - uploads.remove(progress_id); + uploads.finish(progress_id); } } @@ -124,3 +146,85 @@ pub(super) fn emit_media_upload_phase( serde_json::json!({ "id": id, "phase": phase }), ); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cancellation_before_begin_is_retained() { + let progress_id = format!("cancel-before-begin-{}", uuid::Uuid::new_v4()); + + cancel_media_upload(&progress_id); + let cancellation = begin_media_upload(Some(&progress_id)).expect("cancellation token"); + + assert!(cancellation.is_cancelled()); + finish_media_upload(Some(&progress_id)); + } + + #[test] + fn cancellation_after_begin_reaches_registered_token() { + let progress_id = format!("cancel-after-begin-{}", uuid::Uuid::new_v4()); + let cancellation = begin_media_upload(Some(&progress_id)).expect("cancellation token"); + + cancel_media_upload(&progress_id); + + assert!(cancellation.is_cancelled()); + finish_media_upload(Some(&progress_id)); + } + + #[test] + fn late_cancellation_after_native_finish_is_removed_on_release() { + let mut uploads = MediaUploadCancellations::default(); + let id = "late-cancel"; + + uploads.begin(id); + uploads.finish(id); + uploads.cancel(id); + assert!(uploads.tokens.contains_key(id)); + + uploads.finish(id); + assert!(!uploads.tokens.contains_key(id)); + } + + #[test] + fn repeated_concurrent_cycles_leave_no_registry_entries() { + let mut uploads = MediaUploadCancellations::default(); + let ids = (0..256) + .map(|index| format!("cycle-{index}")) + .collect::>(); + + for id in &ids { + uploads.begin(id); + } + for id in &ids { + uploads.cancel(id); + } + for id in &ids { + uploads.finish(id); + } + + assert!(uploads.tokens.is_empty()); + } + + #[test] + fn dispatched_cancellations_are_not_evicted_before_begin() { + let mut uploads = MediaUploadCancellations::default(); + let ids = (0..129) + .map(|index| format!("dispatched-{index}")) + .collect::>(); + + for id in &ids { + uploads.cancel(id); + } + + let oldest = uploads.begin(&ids[0]); + assert!(oldest.is_cancelled()); + assert_eq!(uploads.tokens.len(), ids.len()); + + for id in &ids { + uploads.finish(id); + } + assert!(uploads.tokens.is_empty()); + } +} diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 4f839638b93..168be1ecf60 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -215,7 +215,7 @@ pub async fn search_messages( until: Option, state: State<'_, AppState>, ) -> Result { - let cap = limit.unwrap_or(20).min(100); + let cap = search_messages_limit(limit); let filter = build_search_messages_filter( &q, cap, @@ -229,6 +229,10 @@ pub async fn search_messages( Ok(nostr_convert::search_response_from_events(&events)) } +fn search_messages_limit(limit: Option) -> u32 { + limit.unwrap_or(20).min(500) +} + /// Fetch the full reply subtree under a thread root, server-side. /// /// Unlike the channel timeline (which the desktop assembles from its local diff --git a/desktop/src-tauri/src/commands/messages_tests.rs b/desktop/src-tauri/src/commands/messages_tests.rs index a907a3dff1d..c0ad03d936b 100644 --- a/desktop/src-tauri/src/commands/messages_tests.rs +++ b/desktop/src-tauri/src/commands/messages_tests.rs @@ -1,5 +1,12 @@ use super::*; +#[test] +fn search_messages_limit_allows_discussion_discovery_page() { + assert_eq!(search_messages_limit(None), 20); + assert_eq!(search_messages_limit(Some(500)), 500); + assert_eq!(search_messages_limit(Some(1_000)), 500); +} + #[test] fn marker_author_scope_validates_scope_and_required_pubkey() { assert_eq!( diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 52473716465..761bee9cd32 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -52,6 +52,7 @@ mod project_git_diff; mod project_git_exec; mod project_git_merge_error; mod project_git_push; +mod project_git_recipient_notes; mod project_git_workflow; mod project_repo_paths; mod project_terminal; @@ -106,6 +107,7 @@ pub use profile::*; pub use project_git::*; pub use project_git_branches::*; pub use project_git_diff::*; +pub use project_git_recipient_notes::*; pub use project_git_workflow::*; pub use project_terminal::*; pub use qr_download::*; diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 0cd7ad03247..3be24d04131 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -237,7 +237,7 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { // Remove nsec from keyring after the record is gone. delete_agent_key(pk); super::agents::tombstone_managed_agent_pending(&app, &state, pk); - super::agents::archive_managed_agent_pending(&app, &state, pk); + super::agents::archive_managed_agent_pending(&app, &state, pk, Some(&id)); } tombstone_persona_pending(&app, &state, &d_tag); diff --git a/desktop/src-tauri/src/commands/project_git_recipient_notes.rs b/desktop/src-tauri/src/commands/project_git_recipient_notes.rs new file mode 100644 index 00000000000..4695749fed7 --- /dev/null +++ b/desktop/src-tauri/src/commands/project_git_recipient_notes.rs @@ -0,0 +1,430 @@ +//! Labeled recipient notes for the Projects workflow: kind:1 comments whose +//! `p` tags name recipients on a root event. Pull-request review requests +//! (`t: review-request`) and issue assignments (`t: assignment`) share this +//! shape so clients can parse them with one code path. + +use super::project_git_workflow::{ + normalize_event_id, project_owner_identity, validate_repo_address, +}; +use crate::app_state::AppState; +use crate::relay::submit_signed_event_with_keys; +use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; +use serde::Deserialize; +use tauri::{AppHandle, State}; + +/// Repository-scoped metadata for an agent-signed review request. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectPullRequestReviewRequestInput { + target_owner: String, + repo_address: String, + pull_request_id: String, + reviewers: Vec, + reviewer_label: String, +} + +/// Repository-scoped metadata for an agent-signed issue assignee operation. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectIssueAssigneeOperationInput { + target_owner: String, + repo_address: String, + issue_id: String, + assignees: Vec, + assignee_label: String, + created_at: u64, +} + +#[derive(Clone, Copy)] +enum IssueAssigneeOperation { + Assign, + Unassign, +} + +impl IssueAssigneeOperation { + fn label(self) -> &'static str { + match self { + Self::Assign => "assignment", + Self::Unassign => "unassignment", + } + } + + fn content(self, assignee_label: &str) -> String { + match self { + Self::Assign => format!("Assigned this issue to {assignee_label}"), + Self::Unassign => format!("Unassigned {assignee_label} from this issue"), + } + } +} + +/// Parameters for [`build_labeled_recipient_note_event`]. +struct LabeledRecipientNote<'a> { + repo_address: &'a str, + root_id: &'a str, + root_id_error: &'a str, + recipients: &'a [String], + recipient_noun: &'a str, + label: &'a str, + content: String, + created_at: Option, +} + +/// Shared builder for labeled kind:1 notes tagging recipients (`p`) on a +/// root event — the convention used by both PR review requests +/// (`t: review-request`) and issue assignments (`t: assignment`). +fn build_labeled_recipient_note_event( + keys: &Keys, + note: LabeledRecipientNote<'_>, +) -> Result { + let LabeledRecipientNote { + repo_address, + root_id, + root_id_error, + recipients, + recipient_noun, + label, + content, + created_at, + } = note; + let owner = keys.public_key().to_hex(); + validate_repo_address(repo_address, &owner)?; + let root_id = normalize_event_id(root_id).ok_or_else(|| root_id_error.to_string())?; + if recipients.is_empty() || recipients.len() > 50 { + return Err(format!("Select between 1 and 50 {recipient_noun}s.")); + } + let mut recipients = recipients + .iter() + .map(|recipient| { + normalize_event_id(recipient).ok_or_else(|| format!("Invalid {recipient_noun} pubkey.")) + }) + .collect::, _>>()?; + recipients.sort(); + recipients.dedup(); + + let mut raw_tags = vec![ + vec!["e".to_string(), root_id, String::new(), "root".to_string()], + vec!["a".to_string(), repo_address.to_string()], + ]; + raw_tags.extend( + recipients + .into_iter() + .map(|recipient| vec!["p".to_string(), recipient]), + ); + raw_tags.push(vec!["t".to_string(), label.to_string()]); + let tags = raw_tags + .into_iter() + .map(Tag::parse) + .collect::, _>>() + .map_err(|error| format!("build {label} tags: {error}"))?; + let mut builder = EventBuilder::new(Kind::TextNote, content).tags(tags); + if let Some(created_at) = created_at { + builder = builder.custom_created_at(Timestamp::from_secs(created_at)); + } + builder + .sign_with_keys(keys) + .map(|event| event.as_json()) + .map_err(|error| format!("sign {label} note: {error}")) +} + +fn build_review_request_event( + keys: &Keys, + repo_address: &str, + pull_request_id: &str, + reviewers: &[String], + reviewer_label: &str, +) -> Result { + let reviewer_label = reviewer_label.trim(); + if reviewer_label.is_empty() || reviewer_label.chars().count() > 128 { + return Err("Reviewer label must be between 1 and 128 characters.".to_string()); + } + build_labeled_recipient_note_event( + keys, + LabeledRecipientNote { + repo_address, + root_id: pull_request_id, + root_id_error: "Invalid pull request event ID.", + recipients: reviewers, + recipient_noun: "reviewer", + label: "review-request", + content: format!("Requested a review from {reviewer_label}"), + created_at: None, + }, + ) +} + +#[cfg(test)] +fn build_issue_assignment_event( + keys: &Keys, + repo_address: &str, + issue_id: &str, + assignees: &[String], + assignee_label: &str, + created_at: Option, +) -> Result { + build_issue_assignee_operation_event( + keys, + repo_address, + issue_id, + assignees, + assignee_label, + created_at, + IssueAssigneeOperation::Assign, + ) +} + +#[cfg(test)] +fn build_issue_unassignment_event( + keys: &Keys, + repo_address: &str, + issue_id: &str, + assignees: &[String], + assignee_label: &str, + created_at: Option, +) -> Result { + build_issue_assignee_operation_event( + keys, + repo_address, + issue_id, + assignees, + assignee_label, + created_at, + IssueAssigneeOperation::Unassign, + ) +} + +#[allow(clippy::too_many_arguments)] +fn build_issue_assignee_operation_event( + keys: &Keys, + repo_address: &str, + issue_id: &str, + assignees: &[String], + assignee_label: &str, + created_at: Option, + operation: IssueAssigneeOperation, +) -> Result { + let assignee_label = assignee_label.trim(); + if assignee_label.is_empty() || assignee_label.chars().count() > 128 { + return Err("Assignee label must be between 1 and 128 characters.".to_string()); + } + build_labeled_recipient_note_event( + keys, + LabeledRecipientNote { + repo_address, + root_id: issue_id, + root_id_error: "Invalid issue event ID.", + recipients: assignees, + recipient_noun: "assignee", + label: operation.label(), + content: operation.content(assignee_label), + created_at, + }, + ) +} + +#[tauri::command] +pub async fn sign_project_pull_request_review_request( + input: ProjectPullRequestReviewRequestInput, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let target_owner = input.target_owner.trim().to_ascii_lowercase(); + if normalize_event_id(&target_owner).is_none() { + return Err("Invalid target repository owner.".to_string()); + } + let identity = project_owner_identity(&app, &state, &target_owner)?; + let event = Event::from_json(build_review_request_event( + &identity.keys, + &input.repo_address, + &input.pull_request_id, + &input.reviewers, + &input.reviewer_label, + )?) + .map_err(|error| format!("parse signed review request: {error}"))?; + submit_signed_event_with_keys(&event, &state, &identity.keys, identity.auth_tag.as_deref()) + .await?; + Ok(()) +} + +#[tauri::command] +pub async fn sign_project_issue_assignment( + input: ProjectIssueAssigneeOperationInput, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + sign_project_issue_assignee_operation(input, IssueAssigneeOperation::Assign, app, state).await +} + +#[tauri::command] +pub async fn sign_project_issue_unassignment( + input: ProjectIssueAssigneeOperationInput, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + sign_project_issue_assignee_operation(input, IssueAssigneeOperation::Unassign, app, state).await +} + +async fn sign_project_issue_assignee_operation( + input: ProjectIssueAssigneeOperationInput, + operation: IssueAssigneeOperation, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let target_owner = input.target_owner.trim().to_ascii_lowercase(); + if normalize_event_id(&target_owner).is_none() { + return Err("Invalid target repository owner.".to_string()); + } + let identity = project_owner_identity(&app, &state, &target_owner)?; + let event = Event::from_json(build_issue_assignee_operation_event( + &identity.keys, + &input.repo_address, + &input.issue_id, + &input.assignees, + &input.assignee_label, + Some(input.created_at), + operation, + )?) + .map_err(|error| format!("parse signed issue {}: {error}", operation.label()))?; + submit_signed_event_with_keys(&event, &state, &identity.keys, identity.auth_tag.as_deref()) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + build_issue_assignment_event, build_issue_unassignment_event, build_review_request_event, + }; + use nostr::{Event, JsonUtil, Keys}; + + #[test] + fn issue_assignment_is_signed_by_repository_owner() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + let assignee = "b".repeat(64); + let repo_address = format!("30617:{owner}:buzz"); + let event = Event::from_json( + build_issue_assignment_event( + &keys, + &repo_address, + &"d".repeat(64), + std::slice::from_ref(&assignee), + "Bob", + None, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(event.pubkey, keys.public_key()); + assert_eq!(event.kind, nostr::Kind::TextNote); + assert_eq!(event.content, "Assigned this issue to Bob"); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["p", assignee.as_str()])); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["t", "assignment"])); + assert!(event.verify().is_ok()); + } + + #[test] + fn issue_assignment_rejects_invalid_metadata() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + let repo_address = format!("30617:{owner}:buzz"); + + assert!(build_issue_assignment_event( + &keys, + &repo_address, + &"d".repeat(64), + &[], + "Bob", + None, + ) + .is_err()); + assert!(build_issue_assignment_event( + &keys, + &repo_address, + &"d".repeat(64), + &["b".repeat(64)], + " ", + None, + ) + .is_err()); + assert!(build_issue_assignment_event( + &keys, + &repo_address, + "not-an-event-id", + &["b".repeat(64)], + "Bob", + None, + ) + .is_err()); + } + + #[test] + fn issue_unassignment_is_signed_by_repository_owner() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + let assignee = "b".repeat(64); + let repo_address = format!("30617:{owner}:buzz"); + let event = Event::from_json( + build_issue_unassignment_event( + &keys, + &repo_address, + &"d".repeat(64), + std::slice::from_ref(&assignee), + "Bob", + Some(123), + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(event.content, "Unassigned Bob from this issue"); + assert_eq!(event.created_at.as_secs(), 123); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["p", assignee.as_str()])); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["t", "unassignment"])); + assert!(event.verify().is_ok()); + } + + #[test] + fn review_request_is_signed_by_repository_owner() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + let reviewer = "b".repeat(64); + let repo_address = format!("30617:{owner}:buzz"); + let event = Event::from_json( + build_review_request_event( + &keys, + &repo_address, + &"d".repeat(64), + std::slice::from_ref(&reviewer), + "Bob", + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(event.pubkey, keys.public_key()); + assert_eq!(event.kind, nostr::Kind::TextNote); + assert_eq!(event.content, "Requested a review from Bob"); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["p", reviewer.as_str()])); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["t", "review-request"])); + assert!(event.verify().is_ok()); + } +} diff --git a/desktop/src-tauri/src/commands/project_git_workflow.rs b/desktop/src-tauri/src/commands/project_git_workflow.rs index 9e06852762b..2784068c7ca 100644 --- a/desktop/src-tauri/src/commands/project_git_workflow.rs +++ b/desktop/src-tauri/src/commands/project_git_workflow.rs @@ -59,17 +59,6 @@ pub struct ProjectPullRequestMergeInput { expected_commit: String, } -/// Repository-scoped metadata for an agent-signed review request. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProjectPullRequestReviewRequestInput { - target_owner: String, - repo_address: String, - pull_request_id: String, - reviewers: Vec, - reviewer_label: String, -} - /// Repository-scoped metadata for an agent-signed lifecycle status. #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -90,21 +79,41 @@ pub struct ProjectPullRequestMergedStatusInput { status_event: String, } +/// A project or repository announcement signed by its direct or managed owner. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectOwnerAnnouncementInput { + target_owner: String, + kind: u16, + content: String, + created_at: Option, + tags: Vec>, +} + +/// Signed announcement plus any relay publication failure for recovery. +#[derive(Serialize)] +pub struct ProjectOwnerAnnouncementResult { + /// Serialized signed Nostr event. + event: String, + /// Relay error when signing succeeded but publication did not. + publication_error: Option, +} + fn normalize_commit(value: &str) -> Option { clean_commit(Some(value.trim().to_ascii_lowercase())) } -fn normalize_event_id(value: &str) -> Option { +pub(crate) fn normalize_event_id(value: &str) -> Option { let value = value.trim().to_ascii_lowercase(); (value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit())).then_some(value) } -struct ProjectOwnerIdentity { - keys: Keys, - auth_tag: Option, +pub(crate) struct ProjectOwnerIdentity { + pub(crate) keys: Keys, + pub(crate) auth_tag: Option, } -fn project_owner_identity( +pub(crate) fn project_owner_identity( app: &AppHandle, state: &AppState, target_owner: &str, @@ -126,7 +135,7 @@ fn project_owner_identity( .iter() .find(|record| record.pubkey.eq_ignore_ascii_case(target_owner)) .ok_or_else(|| { - "Only the repository owner or the owner of its managed agent can merge pull requests." + "Only the owner identity or the owner of its managed agent can perform this action." .to_string() })?; if let Some(error) = spawn_key_refusal(record) { @@ -143,7 +152,7 @@ fn project_owner_identity( }) } -fn validate_repo_address(repo_address: &str, owner: &str) -> Result<(), String> { +pub(crate) fn validate_repo_address(repo_address: &str, owner: &str) -> Result<(), String> { let prefix = format!("30617:{owner}:"); if repo_address.strip_prefix(&prefix).is_none_or(str::is_empty) { return Err("Repository address does not match the repository owner.".to_string()); @@ -151,6 +160,67 @@ fn validate_repo_address(repo_address: &str, owner: &str) -> Result<(), String> Ok(()) } +fn validate_project_owner_announcement( + input: &ProjectOwnerAnnouncementInput, +) -> Result<(), String> { + if !matches!(input.kind, 30_617 | 30_621) { + return Err("Only project and repository announcements can be signed here.".to_string()); + } + let has_valid_d_tag = input.tags.iter().any(|tag| { + tag.first().is_some_and(|value| value == "d") + && tag.get(1).is_some_and(|value| !value.trim().is_empty()) + }); + if !has_valid_d_tag { + return Err("Project and repository announcements require a non-empty d tag.".to_string()); + } + if let Some(created_at) = input.created_at { + // Mirror the ACP publish path (`build_project_owner_announcement_events`): + // these are addressable events where the latest created_at wins, so a + // far-future timestamp would wedge the head until that time. Reject + // anything more than 5 minutes ahead. + if created_at > Timestamp::now().as_secs().saturating_add(300) { + return Err("Announcement timestamp is too far in the future.".to_string()); + } + } + Ok(()) +} + +/// Sign and publish an addressable project event as a direct or managed owner. +#[tauri::command] +pub async fn publish_project_owner_announcement( + input: ProjectOwnerAnnouncementInput, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + validate_project_owner_announcement(&input)?; + let target_owner = input.target_owner.trim().to_ascii_lowercase(); + if normalize_event_id(&target_owner).is_none() { + return Err("Invalid project owner.".to_string()); + } + let identity = project_owner_identity(&app, &state, &target_owner)?; + let nostr_tags = input + .tags + .into_iter() + .map(|tag| Tag::parse(tag).map_err(|error| format!("invalid tag: {error}"))) + .collect::, _>>()?; + let mut builder = EventBuilder::new(Kind::Custom(input.kind), input.content).tags(nostr_tags); + if let Some(created_at) = input.created_at { + builder = builder.custom_created_at(Timestamp::from(created_at)); + } + let event = builder + .sign_with_keys(&identity.keys) + .map_err(|error| format!("sign failed: {error}"))?; + let publication_error = + submit_signed_event_with_keys(&event, &state, &identity.keys, identity.auth_tag.as_deref()) + .await + .err(); + + Ok(ProjectOwnerAnnouncementResult { + event: event.as_json(), + publication_error, + }) +} + fn validate_merge_status_metadata( repo_address: &str, owner: &str, @@ -243,63 +313,6 @@ fn build_pull_request_status_event( .map_err(|error| format!("sign pull request status: {error}")) } -fn build_review_request_event( - keys: &Keys, - repo_address: &str, - pull_request_id: &str, - reviewers: &[String], - reviewer_label: &str, -) -> Result { - let owner = keys.public_key().to_hex(); - validate_repo_address(repo_address, &owner)?; - let pull_request_id = normalize_event_id(pull_request_id) - .ok_or_else(|| "Invalid pull request event ID.".to_string())?; - if reviewers.is_empty() || reviewers.len() > 50 { - return Err("Select between 1 and 50 reviewers.".to_string()); - } - let mut reviewers = reviewers - .iter() - .map(|reviewer| { - normalize_event_id(reviewer).ok_or_else(|| "Invalid reviewer pubkey.".to_string()) - }) - .collect::, _>>()?; - reviewers.sort(); - reviewers.dedup(); - let reviewer_label = reviewer_label.trim(); - if reviewer_label.is_empty() || reviewer_label.chars().count() > 128 { - return Err("Reviewer label must be between 1 and 128 characters.".to_string()); - } - - let mut raw_tags = vec![ - vec![ - "e".to_string(), - pull_request_id, - String::new(), - "root".to_string(), - ], - vec!["a".to_string(), repo_address.to_string()], - ]; - raw_tags.extend( - reviewers - .into_iter() - .map(|reviewer| vec!["p".to_string(), reviewer]), - ); - raw_tags.push(vec!["t".to_string(), "review-request".to_string()]); - let tags = raw_tags - .into_iter() - .map(Tag::parse) - .collect::, _>>() - .map_err(|error| format!("build review request tags: {error}"))?; - EventBuilder::new( - Kind::TextNote, - format!("Requested a review from {reviewer_label}"), - ) - .tags(tags) - .sign_with_keys(keys) - .map(|event| event.as_json()) - .map_err(|error| format!("sign pull request review request: {error}")) -} - fn same_repository(left: &str, right: &str) -> bool { left.trim() .trim_end_matches('/') @@ -452,30 +465,6 @@ pub async fn sign_project_pull_request_status( Ok(()) } -#[tauri::command] -pub async fn sign_project_pull_request_review_request( - input: ProjectPullRequestReviewRequestInput, - app: AppHandle, - state: State<'_, AppState>, -) -> Result<(), String> { - let target_owner = input.target_owner.trim().to_ascii_lowercase(); - if normalize_event_id(&target_owner).is_none() { - return Err("Invalid target repository owner.".to_string()); - } - let identity = project_owner_identity(&app, &state, &target_owner)?; - let event = Event::from_json(build_review_request_event( - &identity.keys, - &input.repo_address, - &input.pull_request_id, - &input.reviewers, - &input.reviewer_label, - )?) - .map_err(|error| format!("parse signed review request: {error}"))?; - submit_signed_event_with_keys(&event, &state, &identity.keys, identity.auth_tag.as_deref()) - .await?; - Ok(()) -} - #[tauri::command] pub async fn publish_project_pull_request_merged_status( input: ProjectPullRequestMergedStatusInput, @@ -683,8 +672,8 @@ pub async fn merge_project_pull_request( mod tests { use super::{ align_unborn_head_branch, build_merged_status_event, build_pull_request_status_event, - build_review_request_event, normalize_commit, same_repository, - validate_merge_status_metadata, + normalize_commit, same_repository, validate_merge_status_metadata, + validate_project_owner_announcement, ProjectOwnerAnnouncementInput, }; use crate::commands::project_git_exec::{build_test_git_auth_config, run_git}; use nostr::{Event, JsonUtil, Keys, Timestamp}; @@ -717,6 +706,62 @@ mod tests { assert_eq!(normalize_commit(&"z".repeat(40)), None); } + #[test] + fn project_owner_announcement_is_limited_to_addressable_project_kinds() { + let valid = ProjectOwnerAnnouncementInput { + target_owner: "a".repeat(64), + kind: 30_621, + content: String::new(), + created_at: Some(1), + tags: vec![vec!["d".to_string(), "project".to_string()]], + }; + assert!(validate_project_owner_announcement(&valid).is_ok()); + + let invalid_kind = ProjectOwnerAnnouncementInput { kind: 1, ..valid }; + assert_eq!( + validate_project_owner_announcement(&invalid_kind), + Err("Only project and repository announcements can be signed here.".to_string()) + ); + } + + #[test] + fn project_owner_announcement_requires_an_address() { + let input = ProjectOwnerAnnouncementInput { + target_owner: "a".repeat(64), + kind: 30_617, + content: String::new(), + created_at: None, + tags: vec![vec!["name".to_string(), "buzz".to_string()]], + }; + assert_eq!( + validate_project_owner_announcement(&input), + Err("Project and repository announcements require a non-empty d tag.".to_string()) + ); + } + + #[test] + fn project_owner_announcement_rejects_far_future_timestamps() { + // Mirrors the ACP path's +300s cap: an addressable head stamped far in + // the future could not be superseded until that time. + let base = ProjectOwnerAnnouncementInput { + target_owner: "a".repeat(64), + kind: 30_621, + content: String::new(), + created_at: Some(Timestamp::now().as_secs() + 200), + tags: vec![vec!["d".to_string(), "project".to_string()]], + }; + assert!(validate_project_owner_announcement(&base).is_ok()); + + let far_future = ProjectOwnerAnnouncementInput { + created_at: Some(Timestamp::now().as_secs() + 301), + ..base + }; + assert_eq!( + validate_project_owner_announcement(&far_future), + Err("Announcement timestamp is too far in the future.".to_string()) + ); + } + #[test] fn repository_comparison_normalizes_git_suffix_and_trailing_slash() { assert!(same_repository( @@ -850,36 +895,4 @@ mod tests { ) .is_err()); } - - #[test] - fn review_request_is_signed_by_repository_owner() { - let keys = Keys::generate(); - let owner = keys.public_key().to_hex(); - let reviewer = "b".repeat(64); - let repo_address = format!("30617:{owner}:buzz"); - let event = Event::from_json( - build_review_request_event( - &keys, - &repo_address, - &"d".repeat(64), - std::slice::from_ref(&reviewer), - "Bob", - ) - .unwrap(), - ) - .unwrap(); - - assert_eq!(event.pubkey, keys.public_key()); - assert_eq!(event.kind, nostr::Kind::TextNote); - assert_eq!(event.content, "Requested a review from Bob"); - assert!(event - .tags - .iter() - .any(|tag| tag.as_slice() == ["p", reviewer.as_str()])); - assert!(event - .tags - .iter() - .any(|tag| tag.as_slice() == ["t", "review-request"])); - assert!(event.verify().is_ok()); - } } diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index 7872530baa0..39a7ecff74c 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -20,6 +20,79 @@ pub(crate) struct PendingCommunityDeepLink { #[derive(Default)] pub(crate) struct PendingCommunityDeepLinks(Mutex>); +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PendingNavigationDeepLink { + id: String, + kind: String, + channel_id: String, + message_id: Option, + thread_root_id: Option, +} + +#[derive(Default)] +pub(crate) struct PendingNavigationDeepLinks(Mutex>); + +impl PendingNavigationDeepLinks { + fn lock(&self) -> std::sync::MutexGuard<'_, VecDeque> { + self.0.lock().unwrap_or_else(|poisoned| { + eprintln!("buzz-desktop: recovering poisoned pending navigation deep-link queue"); + poisoned.into_inner() + }) + } + + fn enqueue(&self, pending: PendingNavigationDeepLink) { + let mut queue = self.lock(); + if queue.iter().any(|item| { + item.kind == pending.kind + && item.channel_id == pending.channel_id + && item.message_id == pending.message_id + && item.thread_root_id == pending.thread_root_id + }) { + return; + } + queue.push_back(pending); + } + + fn clear(&self) { + self.lock().clear(); + } + + fn first(&self) -> Option { + self.lock().front().cloned() + } + + fn acknowledge(&self, id: &str) -> bool { + let mut queue = self.lock(); + if queue.front().is_some_and(|item| item.id == id) { + queue.pop_front(); + true + } else { + false + } + } +} + +#[tauri::command] +pub(crate) fn clear_pending_navigation_deep_links(pending: State<'_, PendingNavigationDeepLinks>) { + pending.clear(); +} + +#[tauri::command] +pub(crate) fn take_pending_navigation_deep_link( + pending: State<'_, PendingNavigationDeepLinks>, +) -> Option { + pending.first() +} + +#[tauri::command] +pub(crate) fn acknowledge_pending_navigation_deep_link( + id: String, + pending: State<'_, PendingNavigationDeepLinks>, +) -> bool { + pending.acknowledge(&id) +} + impl PendingCommunityDeepLinks { fn enqueue(&self, pending: PendingCommunityDeepLink) { let mut queue = self.0.lock().expect("pending deep-link queue poisoned"); @@ -54,6 +127,49 @@ impl PendingCommunityDeepLinks { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PendingEntityDeepLink { + id: String, + href: String, +} + +#[derive(Default)] +pub(crate) struct PendingEntityDeepLinks(Mutex>); + +impl PendingEntityDeepLinks { + fn enqueue(&self, href: String) -> PendingEntityDeepLink { + let mut queue = self.0.lock().expect("pending deep-link queue poisoned"); + if let Some(existing) = queue.iter().find(|item| item.href == href) { + return existing.clone(); + } + let pending = PendingEntityDeepLink { + id: uuid::Uuid::new_v4().to_string(), + href, + }; + queue.push_back(pending.clone()); + pending + } + + fn first(&self) -> Option { + self.0 + .lock() + .expect("pending deep-link queue poisoned") + .front() + .cloned() + } + + fn acknowledge(&self, id: &str) -> bool { + let mut queue = self.0.lock().expect("pending deep-link queue poisoned"); + if queue.front().is_some_and(|item| item.id == id) { + queue.pop_front(); + true + } else { + false + } + } +} + #[tauri::command] pub(crate) fn take_pending_community_deep_link( pending: State<'_, PendingCommunityDeepLinks>, @@ -69,6 +185,21 @@ pub(crate) fn acknowledge_pending_community_deep_link( pending.acknowledge(&id) } +#[tauri::command] +pub(crate) fn take_pending_entity_deep_link( + pending: State<'_, PendingEntityDeepLinks>, +) -> Option { + pending.first() +} + +#[tauri::command] +pub(crate) fn acknowledge_pending_entity_deep_link( + id: String, + pending: State<'_, PendingEntityDeepLinks>, +) -> bool { + pending.acknowledge(&id) +} + fn queue_community_deep_link( app: &tauri::AppHandle, kind: &str, @@ -88,6 +219,24 @@ fn queue_community_deep_link( }); } +fn queue_navigation_deep_link(app: &tauri::AppHandle, kind: &str, payload: &serde_json::Value) { + let Some(channel_id) = payload["channelId"].as_str() else { + return; + }; + app.state::() + .enqueue(PendingNavigationDeepLink { + id: uuid::Uuid::new_v4().to_string(), + kind: kind.to_owned(), + channel_id: channel_id.to_owned(), + message_id: payload["messageId"].as_str().map(str::to_owned), + thread_root_id: payload["threadRootId"].as_str().map(str::to_owned), + }); +} + +fn queue_entity_deep_link(app: &tauri::AppHandle, href: String) -> PendingEntityDeepLink { + app.state::().enqueue(href) +} + fn activate_main_window(app: &tauri::AppHandle) { let Some(window) = app.get_webview_window("main") else { return; @@ -104,6 +253,58 @@ fn activate_main_window(app: &tauri::AppHandle) { } } +fn parse_channel_deep_link(url: &Url) -> Option { + if url.query().is_some() + || url.fragment().is_some() + || !url.username().is_empty() + || url.password().is_some() + { + return None; + } + let mut segments = url.path_segments()?; + let channel_id = segments.next()?; + let message_id = segments.next(); + if segments.next().is_some() { + return None; + } + let channel_id = uuid::Uuid::parse_str(channel_id).ok()?.to_string(); + if message_id.is_some_and(|value| { + value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) { + return None; + } + Some(match message_id { + Some(message_id) => serde_json::json!({ + "channelId": channel_id, + "messageId": message_id.to_ascii_lowercase(), + }), + None => serde_json::json!({ "channelId": channel_id }), + }) +} + +#[cfg(desktop)] +pub(crate) fn install_deep_link_handlers(app: &mut tauri::App) { + use tauri_plugin_deep_link::DeepLinkExt; + + let dl_handle = app.handle().clone(); + app.deep_link().on_open_url(move |event| { + for url in event.urls() { + handle_deep_link_url(&dl_handle, url.as_str()); + } + }); + + #[cfg(any(target_os = "windows", target_os = "linux"))] + match app.deep_link().get_current() { + Ok(Some(urls)) => { + for url in urls { + handle_deep_link_url(app.handle(), url.as_str()); + } + } + Ok(None) => {} + Err(error) => eprintln!("buzz-desktop: failed to read launch deep link: {error}"), + } +} + /// Parse the query string of a `buzz://message?…` URL into the JSON /// payload emitted on `deep-link-message`. Returns `None` when a required /// param (`channel`, `id`) is missing or empty — mirroring the validation @@ -163,6 +364,90 @@ fn parse_join_deep_link(url: &Url) -> Option { })) } +/// Hosts of the `buzz://` git-entity links built by +/// `desktop/src/shared/lib/entityLink.ts` and `crates/buzz-cli/src/links.rs`. +const ENTITY_LINK_HOSTS: [&str; 4] = ["repo", "project", "pr", "issue"]; + +fn is_hex64(value: &str) -> bool { + value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit()) +} + +/// Mirrors `isValidDtag` in `entityLink.ts` — the link format addresses a +/// narrower d-tag charset than Nostr allows. +fn is_linkable_dtag(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + && !value.starts_with('.') + && !value.contains("..") +} + +/// Validate a `buzz://repo|project|pr|issue?…` link and return it verbatim +/// for the frontend, which re-parses it with `parseEntityLink` before +/// navigating. Validating here too keeps a malformed link from raising and +/// focusing the window for a navigation that would then be declined. +/// +/// Workspace tabs addressable by `buzz://repo|project` links — mirrors +/// `ENTITY_LINK_TABS` in `entityLink.ts`. +const ENTITY_LINK_TABS: [&str; 6] = [ + "files", + "commits", + "issues", + "prs", + "contributors", + "channels", +]; + +/// The canonical-form rules match `parseEntityLink`: no path segments, no +/// fragment, and no parameters beyond `owner`/`d` (plus `id` for event +/// links and the optional `tab` for coordinate links), so a future +/// extension of the format is declined by old builds rather than silently +/// misread. +fn parse_entity_deep_link(url: &Url) -> Option<()> { + let host = url.host_str()?; + if !ENTITY_LINK_HOSTS.contains(&host) { + return None; + } + if !matches!(url.path(), "" | "/") || url.fragment().is_some() { + return None; + } + + let needs_event_id = host == "pr" || host == "issue"; + let allows_tab = host == "repo" || host == "project"; + let (mut owner, mut dtag, mut id, mut tab) = (None, None, None, None); + for (key, value) in url.query_pairs() { + let slot = match key.as_ref() { + "owner" => &mut owner, + "d" => &mut dtag, + "id" if needs_event_id => &mut id, + "tab" if allows_tab => &mut tab, + _ => return None, + }; + if slot.is_some() { + return None; + } + *slot = Some(value.into_owned()); + } + + if !owner.is_some_and(|owner| is_hex64(&owner)) { + return None; + } + if !dtag.is_some_and(|dtag| is_linkable_dtag(&dtag)) { + return None; + } + if needs_event_id && !id.is_some_and(|id| is_hex64(&id)) { + return None; + } + if let Some(tab) = tab { + if !ENTITY_LINK_TABS.contains(&tab.as_str()) { + return None; + } + } + Some(()) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] struct AddCommunityDeepLinkPayload { @@ -321,6 +606,8 @@ pub(crate) fn is_supported_deep_link(arg: &str) -> bool { /// - `bitcoinmarkets://connect?relay=` — emits `deep-link-connect` /// to the frontend. Legacy `buzz://` URLs are accepted too; see the scheme /// check below. +/// - `bitcoinmarkets://repo|project|pr|issue?…` — emits `deep-link-entity` to +/// the frontend pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { let url = match Url::parse(url_str) { Ok(u) => u, @@ -383,6 +670,20 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { ); let _ = app.emit("deep-link-add-community", payload); } + Some("channel") => { + let Some(payload) = parse_channel_deep_link(&url) else { + eprintln!("buzz-desktop: channel deep link missing/invalid channel: {url_str}"); + return; + }; + activate_main_window(app); + if payload["messageId"].is_string() { + queue_navigation_deep_link(app, "message", &payload); + let _ = app.emit("deep-link-message", payload); + } else { + queue_navigation_deep_link(app, "channel", &payload); + let _ = app.emit("deep-link-channel", payload); + } + } Some("message") => { // `buzz://message?channel=&id=[&thread=]` // @@ -397,8 +698,23 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { return; }; activate_main_window(app); + queue_navigation_deep_link(app, "message", &payload); let _ = app.emit("deep-link-message", payload); } + Some("repo" | "project" | "pr" | "issue") => { + // `buzz://repo|project?owner=&d=` and + // `buzz://pr|issue?id=&owner=&d=` — the + // share links copied from the Projects UI. The frontend owns + // routing (`useEntityDeepLinks`), so the validated URL is + // forwarded unchanged. + if parse_entity_deep_link(&url).is_none() { + eprintln!("buzz-desktop: malformed entity deep link: {url_str}"); + return; + } + activate_main_window(app); + let pending = queue_entity_deep_link(app, url_str.to_owned()); + let _ = app.emit("deep-link-entity", pending); + } Some("nostr-bind") => match parse_nostr_bind_deep_link(&url) { Ok(payload) => { activate_main_window(app); @@ -418,327 +734,5 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { } #[cfg(test)] -mod tests { - use url::Url; - - use super::{ - parse_add_community_deep_link, parse_join_deep_link, parse_message_deep_link, - parse_nostr_bind_deep_link, PendingCommunityDeepLink, PendingCommunityDeepLinks, - }; - - fn pending(id: &str, relay_url: &str, code: Option<&str>) -> PendingCommunityDeepLink { - PendingCommunityDeepLink { - id: id.to_owned(), - kind: if code.is_some() { "join" } else { "connect" }.to_owned(), - relay_url: relay_url.to_owned(), - code: code.map(str::to_owned), - policy_receipt: None, - name: None, - } - } - - #[test] - fn pending_join_serializes_policy_receipt_for_cold_launch_recovery() { - let mut link = pending("join", "wss://relay.example", Some("invite")); - link.policy_receipt = Some("relay-signed-receipt".to_owned()); - - let payload = serde_json::to_value(link).unwrap(); - assert_eq!(payload["policyReceipt"], "relay-signed-receipt"); - } - - #[test] - fn pending_community_links_are_fifo_and_acknowledged_in_order() { - let queue = PendingCommunityDeepLinks::default(); - queue.enqueue(pending("first", "wss://one.example", Some("one"))); - queue.enqueue(pending("second", "wss://two.example", Some("two"))); - assert_eq!(queue.first().unwrap().id, "first"); - assert!(!queue.acknowledge("second")); - assert!(queue.acknowledge("first")); - assert_eq!(queue.first().unwrap().id, "second"); - } - - #[test] - fn pending_community_links_dedupe_exact_intents() { - let queue = PendingCommunityDeepLinks::default(); - queue.enqueue(pending("first", "wss://one.example", Some("one"))); - queue.enqueue(pending("duplicate", "wss://one.example", Some("one"))); - assert!(queue.acknowledge("first")); - assert!(queue.first().is_none()); - } - - fn valid_nostr_bind_url() -> Url { - Url::parse( - "buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard", - ) - .unwrap() - } - - #[test] - fn parse_add_community_deep_link_extracts_relay_and_name() { - let url = Url::parse( - "buzz://add-community?relay=wss%3A%2F%2Facme.communities.buzz.xyz&name=Acme%20Team&ignored=value", - ) - .unwrap(); - let payload = parse_add_community_deep_link(&url).unwrap(); - assert_eq!(payload.relay_url, "wss://acme.communities.buzz.xyz"); - assert_eq!(payload.name.as_deref(), Some("Acme Team")); - } - - #[test] - fn parse_add_community_deep_link_accepts_an_omitted_or_empty_name() { - for raw in [ - "buzz://add-community?relay=wss%3A%2F%2Facme.example", - "buzz://add-community?relay=wss%3A%2F%2Facme.example&name=", - ] { - assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()) - .unwrap() - .name - .is_none()); - } - } - - #[test] - fn parse_add_community_deep_link_rejects_invalid_relays() { - for raw in [ - "buzz://add-community", - "buzz://add-community?relay=", - "buzz://add-community?relay=not-a-url", - "buzz://add-community?relay=https%3A%2F%2Facme.example", - "buzz://add-community?relay=wss%3A%2F%2F", - ] { - assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()).is_none()); - } - } - - #[test] - fn parse_message_deep_link_extracts_required_params() { - let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); - let payload = parse_message_deep_link(&url).expect("required params present"); - assert_eq!(payload["channelId"], "abc"); - assert_eq!(payload["messageId"], "xyz"); - assert!(payload["threadRootId"].is_null()); - } - - #[test] - fn parse_message_deep_link_accepts_buzz_scheme() { - let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); - let payload = parse_message_deep_link(&url).expect("required params present"); - assert_eq!(payload["channelId"], "abc"); - assert_eq!(payload["messageId"], "xyz"); - } - - #[test] - fn parse_message_deep_link_includes_thread_root() { - let url = Url::parse("buzz://message?channel=abc&id=xyz&thread=root1").unwrap(); - let payload = parse_message_deep_link(&url).expect("required params present"); - assert_eq!(payload["threadRootId"], "root1"); - } - - #[test] - fn parse_message_deep_link_rejects_missing_id() { - let url = Url::parse("buzz://message?channel=abc").unwrap(); - assert!(parse_message_deep_link(&url).is_none()); - } - - #[test] - fn parse_message_deep_link_rejects_empty_channel() { - // Regression: `channel=&id=foo` previously produced channelId: "". - let url = Url::parse("buzz://message?channel=&id=foo").unwrap(); - assert!(parse_message_deep_link(&url).is_none()); - } - - #[test] - fn parse_message_deep_link_rejects_empty_id() { - let url = Url::parse("buzz://message?channel=abc&id=").unwrap(); - assert!(parse_message_deep_link(&url).is_none()); - } - - #[test] - fn parse_message_deep_link_treats_empty_thread_as_absent() { - let url = Url::parse("buzz://message?channel=abc&id=xyz&thread=").unwrap(); - let payload = parse_message_deep_link(&url).expect("required params present"); - assert!(payload["threadRootId"].is_null()); - } - - #[test] - fn parse_join_deep_link_extracts_relay_and_code() { - let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def").unwrap(); - let payload = parse_join_deep_link(&url).expect("required params present"); - assert_eq!(payload["relayUrl"], "wss://relay.example"); - assert_eq!(payload["code"], "abc.def"); - assert!(payload["policyReceipt"].is_null()); - } - - #[test] - fn parse_join_deep_link_extracts_policy_receipt() { - let url = Url::parse( - "buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def&policy_receipt=receipt.value", - ) - .unwrap(); - let payload = parse_join_deep_link(&url).expect("required params present"); - assert_eq!(payload["policyReceipt"], "receipt.value"); - } - - #[test] - fn parse_join_deep_link_rejects_missing_code() { - let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example").unwrap(); - assert!(parse_join_deep_link(&url).is_none()); - } - - #[test] - fn parse_join_deep_link_rejects_empty_code() { - let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=").unwrap(); - assert!(parse_join_deep_link(&url).is_none()); - } - - #[test] - fn parse_join_deep_link_rejects_missing_relay() { - let url = Url::parse("buzz://join?code=abc.def").unwrap(); - assert!(parse_join_deep_link(&url).is_none()); - } - - #[test] - fn parse_join_deep_link_rejects_non_websocket_relay() { - let url = Url::parse("buzz://join?relay=https%3A%2F%2Frelay.example&code=abc.def").unwrap(); - assert!(parse_join_deep_link(&url).is_none()); - } - - #[test] - fn parse_nostr_bind_deep_link_accepts_valid_url() { - let payload = parse_nostr_bind_deep_link(&valid_nostr_bind_url()).unwrap(); - assert_eq!(payload.challenge_id, "550e8400-e29b-41d4-a716-446655440000"); - assert_eq!(payload.nonce, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567"); - assert_eq!(payload.verification_code, "123456"); - assert_eq!(payload.audience, "buzz:nostr-identity"); - assert_eq!(payload.action, "bind_nostr_identity"); - assert_eq!(payload.protocol, "buzz-nostr-identity"); - assert_eq!(payload.version, "1"); - assert_eq!(payload.origin, "https://example.com"); - assert_eq!(payload.expires_at, "2999-01-01T00:00:00Z"); - assert_eq!(payload.return_mode, "clipboard"); - assert_eq!(payload.callback_url, None); - } - - #[test] - fn parse_nostr_bind_deep_link_accepts_same_origin_callback_url() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fexample.com%2Fbuzz%3FmockSession%3D1").unwrap(); - let payload = parse_nostr_bind_deep_link(&url).unwrap(); - assert_eq!( - payload.callback_url.as_deref(), - Some("https://example.com/buzz?mockSession=1") - ); - } - - #[test] - fn parse_nostr_bind_deep_link_accepts_browser_fragment_return() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1&callback_url=https%3A%2F%2Fexample.com%2Fbuzz").unwrap(); - let payload = parse_nostr_bind_deep_link(&url).unwrap(); - - assert_eq!(payload.return_mode, "browser_fragment_v1"); - assert_eq!( - payload.callback_url.as_deref(), - Some("https://example.com/buzz") - ); - } - - #[test] - fn parse_nostr_bind_deep_link_requires_callback_for_browser_fragment_return() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1").unwrap(); - - assert_eq!( - parse_nostr_bind_deep_link(&url).unwrap_err(), - "browser_fragment_v1 requires callback_url" - ); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_cross_origin_callback_url() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fevil.example%2Fbuzz").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_http_callback_url() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=http%3A%2F%2Fexample.com%2Fbuzz").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_missing_challenge_id() { - let url = Url::parse("buzz://nostr-bind?nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_empty_nonce() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_missing_verification_code() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_short_verification_code() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_long_verification_code() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=1234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_non_digit_verification_code() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345a&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_wrong_action() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=wrong&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_wrong_audience() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=other&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_non_https_origin() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=http%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_origin_with_path() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com%2Fbind&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_origin_with_credentials() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fuser%40example.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_unsupported_return_mode() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=callback").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_accepts_expired_link_for_user_facing_error() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2000-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - let payload = parse_nostr_bind_deep_link(&url).unwrap(); - assert_eq!(payload.expires_at, "2000-01-01T00:00:00Z"); - } -} +#[path = "deep_link_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/deep_link_tests.rs b/desktop/src-tauri/src/deep_link_tests.rs new file mode 100644 index 00000000000..eaddbb7a4c3 --- /dev/null +++ b/desktop/src-tauri/src/deep_link_tests.rs @@ -0,0 +1,569 @@ +use url::Url; + +use super::{ + parse_add_community_deep_link, parse_channel_deep_link, parse_entity_deep_link, + parse_join_deep_link, parse_message_deep_link, parse_nostr_bind_deep_link, + PendingCommunityDeepLink, PendingCommunityDeepLinks, PendingEntityDeepLinks, + PendingNavigationDeepLink, PendingNavigationDeepLinks, ENTITY_LINK_TABS, +}; + +fn entity_link_golden() -> serde_json::Value { + serde_json::from_str(include_str!("../../../test-fixtures/entity-links.json")) + .expect("valid entity-links golden fixture") +} + +#[test] +fn parse_entity_deep_link_accepts_every_share_link_shape() { + let golden = entity_link_golden(); + let owner = golden["owner"].as_str().unwrap(); + let dtag = golden["dtag"].as_str().unwrap(); + for raw in golden["links"] + .as_object() + .unwrap() + .values() + .map(|value| value.as_str().unwrap().to_owned()) + .chain(golden["tabs"].as_array().unwrap().iter().map(|tab| { + format!( + "buzz://repo?owner={owner}&d={dtag}&tab={}", + tab.as_str().unwrap() + ) + })) + { + assert!( + parse_entity_deep_link(&Url::parse(&raw).unwrap()).is_some(), + "{raw}" + ); + } + let expected_tabs = golden["tabs"] + .as_array() + .unwrap() + .iter() + .map(|tab| tab.as_str().unwrap()) + .collect::>(); + assert_eq!(ENTITY_LINK_TABS.as_slice(), expected_tabs); +} + +#[test] +fn parse_entity_deep_link_rejects_malformed_and_non_canonical_links() { + let golden = entity_link_golden(); + let owner = golden["owner"].as_str().unwrap(); + let event_id = golden["eventId"].as_str().unwrap(); + for raw in [ + // Missing or malformed identifiers. + format!("buzz://repo?owner={owner}"), + "buzz://repo?owner=nope&d=buzz-world".to_owned(), + format!("buzz://repo?owner={owner}&d=.hidden"), + format!("buzz://repo?owner={owner}&d=has%20space"), + format!("buzz://pr?owner={owner}&d=buzz-world"), + format!("buzz://pr?id=short&owner={owner}&d=buzz-world"), + // Coordinate links take no event id. + format!("buzz://repo?id={event_id}&owner={owner}&d=buzz-world"), + // Non-canonical: unknown param, duplicate param, path, fragment. + format!("buzz://repo?owner={owner}&d=buzz-world&relay=wss%3A%2F%2Fx.example"), + format!("buzz://repo?owner={owner}&owner={owner}&d=buzz-world"), + // Unknown tab value, duplicate tab, and tab on an event link. + format!("buzz://repo?owner={owner}&d=buzz-world&tab=overview"), + format!("buzz://repo?owner={owner}&d=buzz-world&tab=prs&tab=prs"), + format!("buzz://pr?id={event_id}&owner={owner}&d=buzz-world&tab=prs"), + format!("buzz://repo/extra?owner={owner}&d=buzz-world"), + format!("buzz://repo?owner={owner}&d=buzz-world#top"), + // Not an entity host. + format!("buzz://message?owner={owner}&d=buzz-world"), + ] { + assert!( + parse_entity_deep_link(&Url::parse(&raw).unwrap()).is_none(), + "{raw}" + ); + } +} + +fn pending(id: &str, relay_url: &str, code: Option<&str>) -> PendingCommunityDeepLink { + PendingCommunityDeepLink { + id: id.to_owned(), + kind: if code.is_some() { "join" } else { "connect" }.to_owned(), + relay_url: relay_url.to_owned(), + code: code.map(str::to_owned), + policy_receipt: None, + name: None, + } +} + +fn pending_navigation( + id: &str, + kind: &str, + channel_id: &str, + message_id: Option<&str>, + thread_root_id: Option<&str>, +) -> PendingNavigationDeepLink { + PendingNavigationDeepLink { + id: id.to_owned(), + kind: kind.to_owned(), + channel_id: channel_id.to_owned(), + message_id: message_id.map(str::to_owned), + thread_root_id: thread_root_id.map(str::to_owned), + } +} + +#[test] +fn pending_navigation_links_are_fifo_acknowledged_and_deduplicated() { + let queue = PendingNavigationDeepLinks::default(); + queue.enqueue(pending_navigation( + "first", + "channel", + "channel-1", + None, + None, + )); + queue.enqueue(pending_navigation( + "duplicate", + "channel", + "channel-1", + None, + None, + )); + queue.enqueue(pending_navigation( + "second", + "message", + "channel-1", + Some("message-1"), + Some("root-1"), + )); + + assert_eq!(queue.first().unwrap().id, "first"); + assert!(!queue.acknowledge("second")); + assert!(queue.acknowledge("first")); + assert_eq!(queue.first().unwrap().id, "second"); + assert!(queue.acknowledge("second")); + assert!(queue.first().is_none()); +} + +#[test] +fn pending_navigation_links_can_be_cleared() { + let queue = PendingNavigationDeepLinks::default(); + queue.enqueue(pending_navigation( + "first", + "channel", + "channel-1", + None, + None, + )); + queue.enqueue(pending_navigation( + "second", + "message", + "channel-1", + Some("message-1"), + None, + )); + + queue.clear(); + assert!(queue.first().is_none()); +} + +#[test] +fn pending_navigation_queue_recovers_after_mutex_poisoning() { + let queue = std::sync::Arc::new(PendingNavigationDeepLinks::default()); + let poisoner = std::sync::Arc::clone(&queue); + assert!(std::thread::spawn(move || { + let _guard = poisoner.0.lock().unwrap(); + panic!("poison queue for recovery regression"); + }) + .join() + .is_err()); + + queue.enqueue(pending_navigation( + "after-poison", + "channel", + "channel-1", + None, + None, + )); + assert_eq!(queue.first().unwrap().id, "after-poison"); + assert!(queue.acknowledge("after-poison")); + assert!(queue.first().is_none()); +} + +#[test] +fn pending_join_serializes_policy_receipt_for_cold_launch_recovery() { + let mut link = pending("join", "wss://relay.example", Some("invite")); + link.policy_receipt = Some("relay-signed-receipt".to_owned()); + + let payload = serde_json::to_value(link).unwrap(); + assert_eq!(payload["policyReceipt"], "relay-signed-receipt"); +} + +#[test] +fn pending_community_links_are_fifo_and_acknowledged_in_order() { + let queue = PendingCommunityDeepLinks::default(); + queue.enqueue(pending("first", "wss://one.example", Some("one"))); + queue.enqueue(pending("second", "wss://two.example", Some("two"))); + assert_eq!(queue.first().unwrap().id, "first"); + assert!(!queue.acknowledge("second")); + assert!(queue.acknowledge("first")); + assert_eq!(queue.first().unwrap().id, "second"); +} + +#[test] +fn pending_community_links_dedupe_exact_intents() { + let queue = PendingCommunityDeepLinks::default(); + queue.enqueue(pending("first", "wss://one.example", Some("one"))); + queue.enqueue(pending("duplicate", "wss://one.example", Some("one"))); + assert!(queue.acknowledge("first")); + assert!(queue.first().is_none()); +} + +#[test] +fn pending_entity_links_survive_until_acknowledged_in_order() { + let queue = PendingEntityDeepLinks::default(); + let first = queue.enqueue("buzz://project?owner=aa&d=first".to_owned()); + let second = queue.enqueue("buzz://project?owner=aa&d=second".to_owned()); + + assert_eq!(queue.first(), Some(first.clone())); + assert!(!queue.acknowledge(&second.id)); + assert!(queue.acknowledge(&first.id)); + assert_eq!(queue.first(), Some(second)); +} + +#[test] +fn pending_entity_links_dedupe_launch_and_open_callbacks() { + let queue = PendingEntityDeepLinks::default(); + let href = "buzz://project?owner=aa&d=buzz".to_owned(); + let first = queue.enqueue(href.clone()); + let duplicate = queue.enqueue(href); + + assert_eq!(duplicate.id, first.id); + assert!(queue.acknowledge(&first.id)); + assert!(queue.first().is_none()); +} + +fn valid_nostr_bind_url() -> Url { + Url::parse( + "buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard", + ) + .unwrap() +} + +#[test] +fn parse_add_community_deep_link_extracts_relay_and_name() { + let url = Url::parse( + "buzz://add-community?relay=wss%3A%2F%2Facme.communities.buzz.xyz&name=Acme%20Team&ignored=value", + ) + .unwrap(); + let payload = parse_add_community_deep_link(&url).unwrap(); + assert_eq!(payload.relay_url, "wss://acme.communities.buzz.xyz"); + assert_eq!(payload.name.as_deref(), Some("Acme Team")); +} + +#[test] +fn parse_add_community_deep_link_accepts_an_omitted_or_empty_name() { + for raw in [ + "buzz://add-community?relay=wss%3A%2F%2Facme.example", + "buzz://add-community?relay=wss%3A%2F%2Facme.example&name=", + ] { + assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()) + .unwrap() + .name + .is_none()); + } +} + +#[test] +fn parse_add_community_deep_link_rejects_invalid_relays() { + for raw in [ + "buzz://add-community", + "buzz://add-community?relay=", + "buzz://add-community?relay=not-a-url", + "buzz://add-community?relay=https%3A%2F%2Facme.example", + "buzz://add-community?relay=wss%3A%2F%2F", + ] { + assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()).is_none()); + } +} + +#[test] +fn parse_channel_deep_link_accepts_one_path_segment() { + let url = Url::parse("buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32").unwrap(); + let payload = parse_channel_deep_link(&url).unwrap(); + assert_eq!(payload["channelId"], "580ca78b-9dae-46f3-8854-bd671853ba32"); +} + +#[test] +fn parse_channel_deep_link_accepts_message_path() { + let message_id = "8455293f0123456789abcdef0123456789abcdef0123456789abcdef01234567"; + let url = Url::parse(&format!( + "buzz://channel/a372f080-5961-4535-b1a3-edffface377d/{message_id}" + )) + .unwrap(); + let payload = parse_channel_deep_link(&url).unwrap(); + assert_eq!(payload["channelId"], "a372f080-5961-4535-b1a3-edffface377d"); + assert_eq!(payload["messageId"], message_id); +} + +#[test] +fn parse_channel_deep_link_accepts_v7_and_normalizes_uppercase() { + for (raw, expected) in [ + ( + "buzz://channel/018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9", + "018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9", + ), + ( + "buzz://channel/580CA78B-9DAE-46F3-8854-BD671853BA32", + "580ca78b-9dae-46f3-8854-bd671853ba32", + ), + ] { + let payload = parse_channel_deep_link(&Url::parse(raw).unwrap()).unwrap(); + assert_eq!(payload["channelId"], expected); + } +} + +#[test] +fn parse_channel_deep_link_rejects_malformed_forms() { + for raw in [ + "buzz://channel", + "buzz://channel/", + "buzz://channel/one/two", + "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32/not-hex", + "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/extra", + "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32/", + "buzz://channel/one?extra=true", + "buzz://channel/one#fragment", + "buzz://:pass@channel/580ca78b-9dae-46f3-8854-bd671853ba32/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "buzz://channel/not-a-uuid", + "buzz://channel/%2F", + "buzz://channel/%00", + ] { + assert!(parse_channel_deep_link(&Url::parse(raw).unwrap()).is_none()); + } +} + +#[test] +fn parse_message_deep_link_extracts_required_params() { + let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); + let payload = parse_message_deep_link(&url).expect("required params present"); + assert_eq!(payload["channelId"], "abc"); + assert_eq!(payload["messageId"], "xyz"); + assert!(payload["threadRootId"].is_null()); +} + +#[test] +fn parse_message_deep_link_accepts_buzz_scheme() { + let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); + let payload = parse_message_deep_link(&url).expect("required params present"); + assert_eq!(payload["channelId"], "abc"); + assert_eq!(payload["messageId"], "xyz"); +} + +#[test] +fn parse_message_deep_link_includes_thread_root() { + let url = Url::parse("buzz://message?channel=abc&id=xyz&thread=root1").unwrap(); + let payload = parse_message_deep_link(&url).expect("required params present"); + assert_eq!(payload["threadRootId"], "root1"); +} + +#[test] +fn parse_message_deep_link_rejects_missing_id() { + let url = Url::parse("buzz://message?channel=abc").unwrap(); + assert!(parse_message_deep_link(&url).is_none()); +} + +#[test] +fn parse_message_deep_link_rejects_empty_channel() { + // Regression: `channel=&id=foo` previously produced channelId: "". + let url = Url::parse("buzz://message?channel=&id=foo").unwrap(); + assert!(parse_message_deep_link(&url).is_none()); +} + +#[test] +fn parse_message_deep_link_rejects_empty_id() { + let url = Url::parse("buzz://message?channel=abc&id=").unwrap(); + assert!(parse_message_deep_link(&url).is_none()); +} + +#[test] +fn parse_message_deep_link_treats_empty_thread_as_absent() { + let url = Url::parse("buzz://message?channel=abc&id=xyz&thread=").unwrap(); + let payload = parse_message_deep_link(&url).expect("required params present"); + assert!(payload["threadRootId"].is_null()); +} + +#[test] +fn parse_join_deep_link_extracts_relay_and_code() { + let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def").unwrap(); + let payload = parse_join_deep_link(&url).expect("required params present"); + assert_eq!(payload["relayUrl"], "wss://relay.example"); + assert_eq!(payload["code"], "abc.def"); + assert!(payload["policyReceipt"].is_null()); +} + +#[test] +fn parse_join_deep_link_extracts_policy_receipt() { + let url = Url::parse( + "buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def&policy_receipt=receipt.value", + ) + .unwrap(); + let payload = parse_join_deep_link(&url).expect("required params present"); + assert_eq!(payload["policyReceipt"], "receipt.value"); +} + +#[test] +fn parse_join_deep_link_rejects_missing_code() { + let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); +} + +#[test] +fn parse_join_deep_link_rejects_empty_code() { + let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); +} + +#[test] +fn parse_join_deep_link_rejects_missing_relay() { + let url = Url::parse("buzz://join?code=abc.def").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); +} + +#[test] +fn parse_join_deep_link_rejects_non_websocket_relay() { + let url = Url::parse("buzz://join?relay=https%3A%2F%2Frelay.example&code=abc.def").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); +} + +#[test] +fn parse_nostr_bind_deep_link_accepts_valid_url() { + let payload = parse_nostr_bind_deep_link(&valid_nostr_bind_url()).unwrap(); + assert_eq!(payload.challenge_id, "550e8400-e29b-41d4-a716-446655440000"); + assert_eq!(payload.nonce, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567"); + assert_eq!(payload.verification_code, "123456"); + assert_eq!(payload.audience, "buzz:nostr-identity"); + assert_eq!(payload.action, "bind_nostr_identity"); + assert_eq!(payload.protocol, "buzz-nostr-identity"); + assert_eq!(payload.version, "1"); + assert_eq!(payload.origin, "https://example.com"); + assert_eq!(payload.expires_at, "2999-01-01T00:00:00Z"); + assert_eq!(payload.return_mode, "clipboard"); + assert_eq!(payload.callback_url, None); +} + +#[test] +fn parse_nostr_bind_deep_link_accepts_same_origin_callback_url() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fexample.com%2Fbuzz%3FmockSession%3D1").unwrap(); + let payload = parse_nostr_bind_deep_link(&url).unwrap(); + assert_eq!( + payload.callback_url.as_deref(), + Some("https://example.com/buzz?mockSession=1") + ); +} + +#[test] +fn parse_nostr_bind_deep_link_accepts_browser_fragment_return() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1&callback_url=https%3A%2F%2Fexample.com%2Fbuzz").unwrap(); + let payload = parse_nostr_bind_deep_link(&url).unwrap(); + + assert_eq!(payload.return_mode, "browser_fragment_v1"); + assert_eq!( + payload.callback_url.as_deref(), + Some("https://example.com/buzz") + ); +} + +#[test] +fn parse_nostr_bind_deep_link_requires_callback_for_browser_fragment_return() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1").unwrap(); + + assert_eq!( + parse_nostr_bind_deep_link(&url).unwrap_err(), + "browser_fragment_v1 requires callback_url" + ); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_cross_origin_callback_url() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fevil.example%2Fbuzz").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_http_callback_url() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=http%3A%2F%2Fexample.com%2Fbuzz").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_missing_challenge_id() { + let url = Url::parse("buzz://nostr-bind?nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_empty_nonce() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_missing_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_short_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_long_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=1234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_non_digit_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345a&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_wrong_action() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=wrong&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_wrong_audience() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=other&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_non_https_origin() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=http%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_origin_with_path() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com%2Fbind&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_origin_with_credentials() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fuser%40example.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_unsupported_return_mode() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=callback").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_accepts_expired_link_for_user_facing_error() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2000-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + let payload = parse_nostr_bind_deep_link(&url).unwrap(); + assert_eq!(payload.expires_at, "2000-01-01T00:00:00Z"); +} diff --git a/desktop/src-tauri/src/huddle/agents.rs b/desktop/src-tauri/src/huddle/agents.rs index a64f540f5eb..2b5b601de49 100644 --- a/desktop/src-tauri/src/huddle/agents.rs +++ b/desktop/src-tauri/src/huddle/agents.rs @@ -33,16 +33,15 @@ use super::{pipeline::start_auto_enabled_transcription, HuddlePhase}; /// huddle start. Agents load this event into the channel session system prompt. /// /// Keep this deliberately short: the invariant that matters is that a directly -/// addressed user interrupts every other activity and receives an immediate -/// spoken response. +/// addressed user receives an immediate spoken response before any other work. pub fn voice_mode_guidelines(parent_channel_id: &str) -> String { format!( "\ You are in a live voice huddle attached to channel {parent_channel_id}. -Your messages are read aloud in the order sent. -Reply immediately whenever a user addresses you, no matter what else is happening. -Send your first sentence as soon as it is formed, then send each following sentence separately. -Speak plainly and briefly without markdown; post code or long detail to the attached channel instead. +Only messages sent with `buzz messages send` to this huddle channel are spoken aloud, in the order sent; everything else you produce is silent. +When a user addresses you, your FIRST tool call must send a brief spoken reply to this channel, before any file read, search, or other tool call. The usual rule against bare acknowledgments does not apply here; the pickup is the feedback that you heard them. +Then work, sending each useful sentence as its own message the moment it is ready—a few sentences per answer, not a monologue. +Speak plainly without markdown; post code or long detail to the attached channel instead. If you are not addressed, stay silent." ) } @@ -301,10 +300,13 @@ mod tests { use super::{contains_member, voice_mode_guidelines}; #[test] - fn voice_mode_guidelines_are_short_and_pin_immediate_reply() { + fn voice_mode_guidelines_pin_spoken_reply_as_first_tool_call() { let guidelines = voice_mode_guidelines("parent-channel"); assert_eq!(guidelines.lines().count(), 6); - assert!(guidelines.contains("Reply immediately whenever a user addresses you")); + assert!(guidelines.contains("Only messages sent with `buzz messages send`")); + assert!(guidelines.contains("your FIRST tool call must send a brief spoken reply")); + assert!(guidelines.contains("before any file read, search, or other tool call")); + assert!(guidelines.contains("rule against bare acknowledgments does not apply here")); assert!(guidelines.contains("parent-channel")); } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 5f2bc404ecb..9da761b8b78 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -49,8 +49,11 @@ use app_state::{build_app_state, resolve_persisted_identity, AppState}; use builderlab::*; use commands::*; use deep_link::{ - acknowledge_pending_community_deep_link, handle_deep_link_url, - take_pending_community_deep_link, PendingCommunityDeepLinks, + acknowledge_pending_community_deep_link, acknowledge_pending_entity_deep_link, + acknowledge_pending_navigation_deep_link, clear_pending_navigation_deep_links, + handle_deep_link_url, take_pending_community_deep_link, take_pending_entity_deep_link, + take_pending_navigation_deep_link, PendingCommunityDeepLinks, PendingEntityDeepLinks, + PendingNavigationDeepLinks, }; use huddle::audio_output::{ get_audio_output_device, list_audio_output_devices, set_audio_output_device, @@ -291,7 +294,6 @@ pub fn run() { } else { builder.plugin(tauri_plugin_updater::Builder::new().build()) }; - let app = app_menu::install(builder) .register_asynchronous_uri_scheme_protocol("buzz-media", |ctx, request, responder| { let app = ctx.app_handle().clone(); @@ -303,6 +305,8 @@ pub fn run() { .manage(build_app_state()) .manage(ClipboardState::new()) .manage(PendingCommunityDeepLinks::default()) + .manage(PendingNavigationDeepLinks::default()) + .manage(PendingEntityDeepLinks::default()) .manage(BuilderlabSession::default()) .manage(BuilderlabLogin::default()) .manage(commands::pairing::PairingHandle::new()) @@ -514,15 +518,7 @@ pub fn run() { // and on cold start. The single-instance plugin handles forwarding // from duplicate launches on Windows/Linux. #[cfg(desktop)] - { - use tauri_plugin_deep_link::DeepLinkExt; - let dl_handle = app.handle().clone(); - app.deep_link().on_open_url(move |event| { - for url in event.urls() { - handle_deep_link_url(&dl_handle, url.as_str()); - } - }); - } + deep_link::install_deep_link_handlers(app); // Defer launch-time agent restoration until `apply_workspace` has // installed the active workspace relay and identity. Starting here @@ -615,6 +611,11 @@ pub fn run() { terminal_runtime::terminal_focus, take_pending_community_deep_link, acknowledge_pending_community_deep_link, + take_pending_navigation_deep_link, + acknowledge_pending_navigation_deep_link, + clear_pending_navigation_deep_links, + take_pending_entity_deep_link, + acknowledge_pending_entity_deep_link, start_builderlab_login, cancel_builderlab_login, get_builderlab_auth, @@ -655,8 +656,11 @@ pub fn run() { delete_project_remote_branch, push_project_local_repository, pull_project_local_repository, + publish_project_owner_announcement, sign_project_pull_request_status, sign_project_pull_request_review_request, + sign_project_issue_assignment, + sign_project_issue_unassignment, publish_project_pull_request_merged_status, merge_project_pull_request, open_project_terminal, @@ -736,6 +740,7 @@ pub fn run() { upload_media_bytes, upload_media_bytes_raw, cancel_media_upload, + release_media_upload, download_image, save_png_data_url, download_file, diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 643c69c2dd3..35121f8b900 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": "BitcoinMarkets", - "version": "0.5.11", + "version": "0.5.14", "identifier": "app.bitcoinmarkets.desktop", "build": { "beforeDevCommand": { diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index befb56cb2b8..6257a75b720 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -90,7 +90,7 @@ import { useWebviewScrollBoundaryLock } from "@/shared/hooks/useWebviewScrollBou import { joinChannel } from "@/shared/api/tauri"; import type { Channel, ChannelVisibility, SearchHit } from "@/shared/api/types"; import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext"; -import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; +import { useAppDeepLinks } from "@/shared/useAppDeepLinks"; import { SidebarProvider } from "@/shared/ui/sidebar"; import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; @@ -640,8 +640,8 @@ export function AppShell() { unreadChannelIds, unreadChannelNotificationCount, }); - // Dispatch `buzz://message` deep links only from the main window; the companion is dedicated to its active Huddle route. - useMessageDeepLinks(!isHuddleRoom); + // Dispatch `buzz://` deep links only from the main window; the companion is dedicated to its active Huddle route. + useAppDeepLinks(!isHuddleRoom); const handleOpenCreateChannel = React.useCallback( () => setIsCreateChannelOpen(true), [], diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 4c7382a306b..2203aa03a6a 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -11,6 +11,7 @@ import { resolveSearchHitDestination } from "@/app/navigation/resolveSearchHitDe import type { SearchHit } from "@/shared/api/types"; type NavigationBehavior = { + force?: boolean; replace?: boolean; resetScroll?: boolean; }; @@ -27,12 +28,13 @@ export function useAppNavigation() { to: string; params?: Record; search?: Record; + state?: Record; }, behavior: NavigationBehavior = {}, ) => { const nextLocation = router.buildLocation(next as never); - if (location.href === nextLocation.href) { + if (location.href === nextLocation.href && !behavior.force) { return false; } @@ -110,6 +112,11 @@ export function useAppNavigation() { pullRequestId?: string; issueId?: string; repositoryId?: string; + /** Workspace tab requested by a share link (link vocabulary). */ + tab?: string; + /** Unique per entity-link activation so repeating the same link can + * re-apply an unchanged tab selection. */ + entityNavigationId?: string; }, ) => commitNavigation( @@ -129,9 +136,16 @@ export function useAppNavigation() { ...(behavior?.repositoryId ? { repositoryId: behavior.repositoryId } : {}), + ...(behavior?.tab ? { tab: behavior.tab } : {}), }, + state: behavior?.entityNavigationId + ? { entityNavigationId: behavior.entityNavigationId } + : undefined, + }, + { + ...behavior, + force: Boolean(behavior?.entityNavigationId), }, - behavior, ), [commitNavigation], ); diff --git a/desktop/src/app/routes/projects.$projectId.tsx b/desktop/src/app/routes/projects.$projectId.tsx index 49544287487..34af176adba 100644 --- a/desktop/src/app/routes/projects.$projectId.tsx +++ b/desktop/src/app/routes/projects.$projectId.tsx @@ -1,7 +1,8 @@ import * as React from "react"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useLocation } from "@tanstack/react-router"; import { usePreviewFeatureWarning } from "@/shared/features"; +import { isEntityLinkTab } from "@/shared/lib/entityLink"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; const ProjectDetailScreen = React.lazy(async () => { @@ -21,23 +22,34 @@ export const Route = createFileRoute("/projects/$projectId")({ issueId: typeof search.issueId === "string" ? search.issueId : undefined, repositoryId: typeof search.repositoryId === "string" ? search.repositoryId : undefined, + tab: isEntityLinkTab(search.tab) ? search.tab : undefined, }), }); function ProjectDetailRouteComponent() { usePreviewFeatureWarning("projects"); const { projectId } = Route.useParams(); - const { commitHash, pullRequestId, issueId, repositoryId } = + const { commitHash, pullRequestId, issueId, repositoryId, tab } = Route.useSearch(); + const entityNavigationId = useLocation({ + select: (location) => { + const value = ( + location.state as { entityNavigationId?: unknown } | undefined + )?.entityNavigationId; + return typeof value === "string" ? value : undefined; + }, + }); return ( }> ); diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 0dc73ef4c3f..7accd54af69 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -180,6 +180,23 @@ with a TypeScript lookup table or an id comparison in a component. import boundary. Do not silently strip them: rejection keeps the reviewed string identical to the executed string. New sharing paths must reuse the same validation before they persist or activate a definition. +13. **Profile runtime sections render only reported agent data.** Missing + runtime, model, status, command, MCP, advanced, or diagnostics values stay + absent in every build mode. Do not fill profile or agent-panel gaps with + development/staging examples, preview controls, or synthetic configuration; + those values can be mistaken for the viewed agent's real configuration. + Configuration rows show the effective value regardless of whether it came + from an explicit choice, global default, config file, or runtime override. + Do not add provenance lines, shadowed/struck-through values, pre-start + placeholders, or whole-section dimming; use an em dash for an unknown value. + Info, activity, agent-configuration, and model-setting rows use the same bare + 16px leading-icon treatment as agent management actions. Keep semantic icons + visible in profile variants and do not wrap them in background shapes. An + owned agent profile is entry-point invariant: opening the same deployed + agent from Agents, a DM, or a channel must expose the same actions, tabs, + fields, and profile-wide activity selection. Caller context may control the + panel shell or return navigation, but must not filter or replace profile + content. ## The tests that enforce this @@ -203,6 +220,12 @@ with a TypeScript lookup table or an id comparison in a component. - `lib/personaCatalogRelay.test.mjs` and `ui/personaCatalogOwnerLabel.test.mjs` — reject invisible definition text and keep Markdown concealment syntax literal in the review surface. +- `../profile/ui/UserProfileRuntimeContent.test.mjs` — profile runtime panels + cannot reintroduce build-mode previews or synthetic fallback controls. +- `desktop/tests/e2e/profile.spec.ts` — the owned-agent parity flow compares + every profile tab when opened from Agents and from the agent's DM. +- `ui/AgentConfigPanelPresentation.test.mjs` — shared profile/agent config rows + show only effective values, with an em dash for unknown values. - `desktop/tests/e2e/onboarding-agent-defaults.spec.ts` — onboarding behavior acceptance coverage for readiness, failure states, defaults, session-draft restoration, zero-write Skip, Next save failure/retry, navigation, and diff --git a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs index 44e43525b6d..2658469b918 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs +++ b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs @@ -12,6 +12,7 @@ import { restoreActiveAgentTurnsForCommunity, clearSavedCommunitySnapshot, clearActiveTurnsForAgent, + createActiveAgentTurnsObserverListener, } from "./activeAgentTurnsStore.ts"; import { injectObserverEventsForE2E, @@ -1625,6 +1626,82 @@ describe("observer → active-turns bridge sync", () => { ); }); + it("publishes only newly admitted events for the changed agent", () => { + const updates = []; + const unsubscribeObserver = subscribeAgentObserverStore((update) => { + updates.push(update); + }); + const retained = makeEvent({ seq: 1, kind: "turn_started" }); + const admitted = makeEvent({ + seq: 2, + kind: "acp_write", + timestamp: "2024-01-01T00:00:01Z", + }); + + injectObserverEventsForE2E(AGENT, [retained]); + injectObserverEventsForE2E(AGENT, [retained, admitted]); + unsubscribeObserver(); + + assert.equal(updates.length, 2); + assert.equal(updates[1].agentPubkey, AGENT); + assert.deepEqual( + updates[1].events.map((event) => event.seq), + [2], + "the publication must omit retained and duplicate history", + ); + }); + + it("steady-state listener processes the changed active agent only", () => { + const listener = createActiveAgentTurnsObserverListener([ + { pubkey: AGENT, status: "deployed" }, + { pubkey: AGENT_2, status: "stopped" }, + ]); + + listener({ + agentPubkey: AGENT, + events: [makeEvent({ seq: 1, turnId: "active-turn" })], + }); + listener({ + agentPubkey: AGENT_2, + events: [ + makeEvent({ + seq: 1, + turnId: "stopped-turn", + channelId: "stopped-channel", + }), + ], + }); + + assert.equal(getActiveTurnsForAgent(AGENT).length, 1); + assert.equal( + getActiveTurnsForAgent(AGENT_2).length, + 0, + "an unrelated stopped agent update must not enter turn state", + ); + }); + + it("incremental terminal update clears a hydrated turn without replay", () => { + injectObserverEventsForE2E(AGENT, [ + makeEvent({ seq: 1, kind: "turn_started" }), + ]); + syncActiveAgentTurnsFromObserver(bridgeAgents); + assert.equal(getActiveTurnsForAgent(AGENT).length, 1); + + const listener = createActiveAgentTurnsObserverListener(bridgeAgents); + listener({ + agentPubkey: AGENT, + events: [ + makeEvent({ + seq: 2, + kind: "turn_completed", + timestamp: "2024-01-01T00:00:05Z", + }), + ], + }); + + assert.equal(getActiveTurnsForAgent(AGENT).length, 0); + }); + it("publishes one observer update for a batch while preserving outcomes", () => { let observerNotifications = 0; const unsubscribeObserver = subscribeAgentObserverStore(() => { diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index 40af42c794a..ebafb3f1976 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -4,6 +4,7 @@ import { subscribeAgentObserverStore, getAgentObserverSnapshot, compareObserverEvents, + type AgentObserverStoreUpdate, } from "@/features/agents/observerRelayStore"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { @@ -631,19 +632,40 @@ export function syncActiveAgentTurnsFromObserver( } /** - * Bridge hook: processes observer events into the active-turns store. - * Should be called by a parent component that has access to the observer events. + * Build the steady-state observer listener once per agent-list revision. Observer + * publications carry only newly admitted events for one agent, so this callback + * does not revisit unrelated agents or their retained journals. */ +export function createActiveAgentTurnsObserverListener( + agents: readonly { pubkey: string; status: string }[], +): (update?: AgentObserverStoreUpdate) => void { + const activeAgentPubkeys = new Set( + agents + .filter( + (agent) => agent.status === "running" || agent.status === "deployed", + ) + .map((agent) => normalizePubkey(agent.pubkey)), + ); + + return (update?: AgentObserverStoreUpdate) => { + if ( + !update || + !activeAgentPubkeys.has(normalizePubkey(update.agentPubkey)) + ) { + return; + } + syncAgentTurnsFromEvents(update.agentPubkey, [...update.events]); + }; +} + export function useActiveAgentTurnsBridge( agents: readonly { pubkey: string; status: string }[], ) { React.useEffect(() => { - function syncAll() { - syncActiveAgentTurnsFromObserver(agents); - } - - syncAll(); - return subscribeAgentObserverStore(syncAll); + syncActiveAgentTurnsFromObserver(agents); + return subscribeAgentObserverStore( + createActiveAgentTurnsObserverListener(agents), + ); }, [agents]); } diff --git a/desktop/src/features/agents/lib/pickProfileAgent.test.mjs b/desktop/src/features/agents/lib/pickProfileAgent.test.mjs new file mode 100644 index 00000000000..cdb47bccd3a --- /dev/null +++ b/desktop/src/features/agents/lib/pickProfileAgent.test.mjs @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { pickProfileAgent } from "./pickProfileAgent.ts"; + +test("the shared profile target prefers the active persona instance", () => { + const stopped = { + name: "Earlier instance", + pubkey: "a".repeat(64), + status: "stopped", + }; + const running = { + name: "Current instance", + pubkey: "b".repeat(64), + status: "running", + }; + + assert.equal(pickProfileAgent([stopped, running]), running); + assert.equal(pickProfileAgent([running, stopped]), running); +}); diff --git a/desktop/src/features/agents/lib/pickProfileAgent.ts b/desktop/src/features/agents/lib/pickProfileAgent.ts new file mode 100644 index 00000000000..c845145495b --- /dev/null +++ b/desktop/src/features/agents/lib/pickProfileAgent.ts @@ -0,0 +1,18 @@ +import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; +import type { ManagedAgent } from "@/shared/api/types"; + +/** + * Pick the instance that represents a persona throughout the UI. + * + * A persona can have several historical agent instances. Keeping this rule in + * one place prevents an avatar click on an older message from opening a + * different detail surface than the card in the Agents library. + */ +export function pickProfileAgent(agents: readonly ManagedAgent[]) { + return [...agents].sort((left, right) => { + const activeDiff = + Number(isManagedAgentActive(right)) - Number(isManagedAgentActive(left)); + if (activeDiff !== 0) return activeDiff; + return left.name.localeCompare(right.name); + })[0]; +} diff --git a/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts index 898848ec5f4..781b555bca0 100644 --- a/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts @@ -15,8 +15,12 @@ import { import type { AgentPersona, UpdatePersonaInput } from "@/shared/api/types"; import { KIND_PERSONA } from "@/shared/constants/kinds"; -/** Keeps focused polling at the established 2-minute backstop cadence. */ -export const PERSONA_CATALOG_REFETCH_INTERVAL_MS = 120_000; +/** Poll backstop cadence. The live subscription (invalidate on any new + * 30175) and the reconnect invalidation are the freshness paths; this poll + * exists only to cover a silently dropped live event, so it can be rare. At + * the previous 2-minute cadence it re-walked the entire persona catalog + * (~150 KB burst) often enough to be a top-three desktop traffic source. */ +export const PERSONA_CATALOG_REFETCH_INTERVAL_MS = 20 * 60_000; /** Suppresses the focus refetch until persona catalog data is genuinely stale. * The live subscription (invalidateQueries) is the primary freshness path. */ export const PERSONA_CATALOG_FOCUS_STALE_TIME_MS = 5 * 60_000; diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 4a8313a1e1c..7ae4d0bfc81 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -27,6 +27,15 @@ import { } from "./ui/agentSessionTranscript"; const MAX_OBSERVER_EVENTS = 3000; +// Length the per-agent journal is evicted down to when it overflows +// MAX_OBSERVER_EVENTS. Eviction rebuilds the transcript from the retained +// window (see appendAgentEvents), so trimming back to exactly the cap re-arms +// eviction on the very next append — every steady-state append then replays the +// whole history. Leaving 10% headroom amortizes one rebuild across the ~300 +// appends that refill it, while keeping the window within the cap. Expressed as +// a fraction (not a fixed count) so the same math stays correct if the cap is +// ever made per-agent, where a fixed headroom could exceed a smaller cap. +const OBSERVER_EVENTS_LOW_WATER = Math.floor(MAX_OBSERVER_EVENTS * 0.9); const MAX_PENDING_UNKNOWN_AGENT_FRAMES = 100; export type ObserverSnapshot = { @@ -44,11 +53,32 @@ const IDLE_SNAPSHOT: ObserverSnapshot = { const EMPTY_EVENTS: ObserverEvent[] = []; const EMPTY_TRANSCRIPT: TranscriptItem[] = []; -const listeners = new Set<() => void>(); +export type AgentObserverStoreUpdate = { + agentPubkey: string; + events: readonly ObserverEvent[]; +}; + +type AgentObserverStoreListener = (update?: AgentObserverStoreUpdate) => void; + +const listeners = new Set(); const eventsByAgent = new Map(); const transcriptByAgent = new Map(); const snapshotByAgent = new Map(); +// Per-agent eviction floor: the ordering key of the newest event that eviction +// has ever discarded for this agent. Once the journal is trimmed to the +// low-water mark, the dedup set (built only from the retained array) no longer +// remembers the discarded frames, so a delayed/replayed relay frame at or below +// that boundary would be re-admitted into the headroom — and a later refill to +// the cap would then trim away 300 legitimate retained events with no new +// activity. The floor rejects any arrival at or before it (equal included: the +// floor event itself was evicted), so already-evicted history can never +// re-enter. Cleared with the observer store; only advances forward. +const evictionFloorByAgent = new Map< + string, + { timestamp: string; seq: number } +>(); + // Channel-scoped archive event journal — holds paged history loaded from the local // SQLite archive without the MAX_OBSERVER_EVENTS live-relay cap. Keyed by // `${normalizedAgentPubkey}:${channelId}`. The live relay path writes to @@ -169,9 +199,9 @@ let startPromise: Promise | null = null; let eventProcessingQueue: Promise = Promise.resolve(); let generation = 0; -function notifyListeners() { +function notifyListeners(update?: AgentObserverStoreUpdate) { for (const listener of listeners) { - listener(); + listener(update); } } @@ -196,33 +226,56 @@ function observerTag(event: RelayEvent, tagName: string) { function appendAgentEvents( agentPubkey: string, events: readonly ObserverEvent[], -): boolean { - if (events.length === 0) return false; +): ObserverEvent[] | null { + if (events.length === 0) return null; const key = normalizePubkey(agentPubkey); const current = eventsByAgent.get(key) ?? []; + + // Reject any arrival at or before the eviction floor: those frames were + // already discarded, so re-admitting them (they fit within the headroom + // below the cap) would let a later refill trim away legitimate retained + // events. Admit only frames strictly after the floor — the floor event + // itself was evicted, so an equal ordering key is rejected too. + const floor = evictionFloorByAgent.get(key); + const admissible = floor + ? events.filter((event) => isObserverEventAfter(event, floor)) + : events; + if (admissible.length === 0) return null; + const seen = new Set( current.map( (event) => `${event.timestamp.length}:${event.timestamp}:${event.seq}`, ), ); const added: ObserverEvent[] = []; - for (const event of events) { + for (const event of admissible) { const eventKey = `${event.timestamp.length}:${event.timestamp}:${event.seq}`; if (seen.has(eventKey)) continue; seen.add(eventKey); added.push(event); } - if (added.length === 0) return false; + if (added.length === 0) return null; const sortedAdded = added.sort(compareObserverEvents); const sorted = [...current, ...sortedAdded].sort(compareObserverEvents); const trimmed = sorted.length > MAX_OBSERVER_EVENTS; const final = trimmed - ? sorted.slice(sorted.length - MAX_OBSERVER_EVENTS) + ? sorted.slice(sorted.length - OBSERVER_EVENTS_LOW_WATER) : sorted; eventsByAgent.set(key, final); + // Record the newest event this trim discarded as the agent's eviction floor. + // It is the entry just below the retained window; the floor only advances, + // since the retained window is always the newest tail. + if (trimmed) { + const boundary = sorted[sorted.length - OBSERVER_EVENTS_LOW_WATER - 1]; + evictionFloorByAgent.set(key, { + timestamp: boundary.timestamp, + seq: boundary.seq, + }); + } + // The common live path appends a sorted batch after the retained window. Fold // that batch through the transcript state once without rebuilding history. // Out-of-order arrivals and cap eviction rebuild from the final window so @@ -243,12 +296,24 @@ function appendAgentEvents( } invalidateSnapshot(key); - return true; + if (!trimmed) return sortedAdded; + + const retainedKeys = new Set( + final.map( + (event) => `${event.timestamp.length}:${event.timestamp}:${event.seq}`, + ), + ); + return sortedAdded.filter((event) => + retainedKeys.has( + `${event.timestamp.length}:${event.timestamp}:${event.seq}`, + ), + ); } function appendAgentEvent(agentPubkey: string, event: ObserverEvent) { - if (appendAgentEvents(agentPubkey, [event])) { - notifyListeners(); + const added = appendAgentEvents(agentPubkey, [event]); + if (added) { + notifyListeners({ agentPubkey, events: added }); } } @@ -389,7 +454,7 @@ function processLiveObserverEvents( // callbacks. Those callbacks historically observed their triggering frame // in the raw/transcript stores; batching must preserve that visibility while // deferring only the global external-store publication. - const observerChanged = appendAgentEvents(agentPubkey, events); + const addedEvents = appendAgentEvents(agentPubkey, events); for (const parsed of events) { // Track the latest-live-session-id per (agent, channel) on the live path. @@ -433,8 +498,8 @@ function processLiveObserverEvents( // Preserve the harness's envelope backpressure: retained state was committed // before specialized callbacks, but external-store subscribers publish once. - if (observerChanged) { - notifyListeners(); + if (addedEvents) { + notifyListeners({ agentPubkey, events: addedEvents }); } } @@ -542,7 +607,9 @@ export function ensureRelayObserverSubscription() { return startPromise; } -export function subscribeAgentObserverStore(listener: () => void) { +export function subscribeAgentObserverStore( + listener: AgentObserverStoreListener, +) { listeners.add(listener); return () => { listeners.delete(listener); @@ -781,8 +848,9 @@ export function injectObserverEventsForE2E( agentPubkey: string, events: ObserverEvent[], ) { - if (appendAgentEvents(agentPubkey, events)) { - notifyListeners(); + const added = appendAgentEvents(agentPubkey, events); + if (added) { + notifyListeners({ agentPubkey, events: added }); } } @@ -794,8 +862,9 @@ export function syncAgentObserverEvents( agentPubkey: string, events: ObserverEvent[], ) { - if (appendAgentEvents(agentPubkey, events)) { - notifyListeners(); + const added = appendAgentEvents(agentPubkey, events); + if (added) { + notifyListeners({ agentPubkey, events: added }); } } @@ -807,6 +876,7 @@ export function resetAgentObserverStore() { eventProcessingQueue = Promise.resolve(); eventsByAgent.clear(); transcriptByAgent.clear(); + evictionFloorByAgent.clear(); snapshotByAgent.clear(); archiveEventsByChannel.clear(); knownAgentPubkeys.clear(); diff --git a/desktop/src/features/agents/observerTranscriptRetention.test.mjs b/desktop/src/features/agents/observerTranscriptRetention.test.mjs new file mode 100644 index 00000000000..861940c1193 --- /dev/null +++ b/desktop/src/features/agents/observerTranscriptRetention.test.mjs @@ -0,0 +1,305 @@ +/** + * Retention behavior of the per-agent live observer journal. + * + * `appendAgentEvents` derives the transcript incrementally when a batch lands + * after the retained window, and falls back to a full `buildTranscriptState` + * replay when the window is evicted. Evicting back to *exactly* the cap made + * that fallback permanent: an agent parked at the cap evicts one event on every + * append, so `trimmed` is true forever and every steady-state append replays the + * whole history through `buildTranscriptState` (issue #5718: 188x headless, + * live CPU escalating to 119%/core after 5min idle). + * + * The fix evicts to a low-water mark below the cap, so the window must refill + * through ordinary appends before the next eviction — one replay amortized + * across the refill. Transcript content is identical on the fold and rebuild + * paths (that is the point of the fallback), so these tests assert the + * observable that distinguishes them: the retained window's SHAPE after + * eviction. They also pin the invariant that the derived transcript still equals + * a full replay of the retained window regardless of which path ran. + */ + +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "node:test"; + +import { + getAgentObserverSnapshot, + getAgentTranscript, + resetAgentObserverStore, + subscribeAgentObserverStore, + syncAgentObserverEvents, +} from "@/features/agents/observerRelayStore.ts"; +import { buildTranscript } from "@/features/agents/ui/agentSessionTranscript.ts"; + +// Mirrors the private constants in observerRelayStore.ts. LOW_WATER is +// Math.floor(MAX * 0.9); the tests assert exact shapes against these values so +// a regression in the eviction math (e.g. reverting to trim-to-cap) fails here. +const MAX_OBSERVER_EVENTS = 3000; +const OBSERVER_EVENTS_LOW_WATER = Math.floor(MAX_OBSERVER_EVENTS * 0.9); + +const AGENT_PUBKEY = "a".repeat(64); + +/** One live observer event; monotonic timestamp keyed to seq so the store's + * timestamp-then-seq sort matches insertion order. */ +function makeEvent(seq) { + return { + seq, + timestamp: new Date(1_760_000_000_000 + seq * 1000).toISOString(), + kind: "turn_started", + agentIndex: 0, + channelId: "chan-1", + sessionId: "sess-1", + turnId: `turn-${seq}`, + payload: {}, + }; +} + +function windowLength() { + return getAgentObserverSnapshot(AGENT_PUBKEY).events.length; +} + +/** Append events seq 1..count one at a time, mirroring the live relay path + * where each frame appends and notifies individually. */ +function fillSequential(count) { + for (let seq = 1; seq <= count; seq += 1) { + syncAgentObserverEvents(AGENT_PUBKEY, [makeEvent(seq)]); + } +} + +describe("live observer journal retention — amortized eviction", () => { + beforeEach(() => { + resetAgentObserverStore(); + }); + + it("test_window_never_exceeds_cap", () => { + for (let seq = 1; seq <= MAX_OBSERVER_EVENTS + 750; seq += 1) { + syncAgentObserverEvents(AGENT_PUBKEY, [makeEvent(seq)]); + assert.ok( + windowLength() <= MAX_OBSERVER_EVENTS, + `window grew to ${windowLength()} at seq ${seq}`, + ); + } + }); + + it("test_append_at_cap_does_not_trim_prematurely", () => { + // Filling to exactly the cap must NOT evict — `trimmed` is `length > cap`, + // and length === cap is not over. A premature trim here would mean the + // fraction math or the comparison regressed. + fillSequential(MAX_OBSERVER_EVENTS); + assert.equal( + windowLength(), + MAX_OBSERVER_EVENTS, + "reaching exactly the cap retains the full window, no eviction", + ); + }); + + it("test_append_crossing_cap_trims_to_exactly_low_water", () => { + // The append that pushes past the cap must leave the window at exactly the + // low-water mark — not back at the cap (which would re-arm eviction on the + // very next append and keep the transcript rebuilding forever). + fillSequential(MAX_OBSERVER_EVENTS); + syncAgentObserverEvents(AGENT_PUBKEY, [makeEvent(MAX_OBSERVER_EVENTS + 1)]); + + assert.equal( + windowLength(), + OBSERVER_EVENTS_LOW_WATER, + "crossing the cap trims to exactly the low-water mark, leaving headroom", + ); + assert.ok( + windowLength() < MAX_OBSERVER_EVENTS, + "headroom exists below the cap after eviction", + ); + }); + + it("test_headroom_refills_before_next_eviction", () => { + // After the first eviction leaves headroom, subsequent appends must GROW + // the window (no eviction) until it refills to the cap — proving eviction + // is amortized across the refill, not per-append. + fillSequential(MAX_OBSERVER_EVENTS + 1); + assert.equal(windowLength(), OBSERVER_EVENTS_LOW_WATER); + + const headroom = MAX_OBSERVER_EVENTS - OBSERVER_EVENTS_LOW_WATER; + for (let i = 1; i <= headroom; i += 1) { + syncAgentObserverEvents(AGENT_PUBKEY, [ + makeEvent(MAX_OBSERVER_EVENTS + 1 + i), + ]); + assert.equal( + windowLength(), + OBSERVER_EVENTS_LOW_WATER + i, + `append ${i} into the headroom must grow the window, not evict`, + ); + } + // The window is now back at the cap; the next append evicts again. + syncAgentObserverEvents(AGENT_PUBKEY, [ + makeEvent(MAX_OBSERVER_EVENTS + 2 + headroom), + ]); + assert.equal( + windowLength(), + OBSERVER_EVENTS_LOW_WATER, + "the window only evicts again after the headroom is refilled", + ); + }); + + it("test_eviction_keeps_newest_events_drops_oldest", () => { + const total = MAX_OBSERVER_EVENTS + 400; + fillSequential(total); + const events = getAgentObserverSnapshot(AGENT_PUBKEY).events; + assert.equal(events.at(-1).seq, total, "newest event is retained"); + assert.equal( + events.at(0).seq, + total - events.length + 1, + "retention is the newest-N contiguous tail", + ); + }); + + it("test_derived_transcript_equals_full_replay_after_eviction", () => { + // The rebuild fallback and the incremental fold must agree: after crossing + // the cap (rebuild path) the stored transcript equals a fresh replay of the + // retained window. + fillSequential(MAX_OBSERVER_EVENTS + 600); + const retained = getAgentObserverSnapshot(AGENT_PUBKEY).events; + assert.deepEqual( + getAgentTranscript(AGENT_PUBKEY), + buildTranscript(retained), + "the derived transcript matches a full replay of the retained window", + ); + }); + + it("test_single_batch_larger_than_cap_trims_to_low_water", () => { + const batch = []; + for (let seq = 1; seq <= MAX_OBSERVER_EVENTS + 900; seq += 1) { + batch.push(makeEvent(seq)); + } + syncAgentObserverEvents(AGENT_PUBKEY, batch); + assert.equal( + windowLength(), + OBSERVER_EVENTS_LOW_WATER, + "a single over-cap batch also trims to the low-water mark", + ); + assert.equal( + getAgentObserverSnapshot(AGENT_PUBKEY).events.at(-1).seq, + MAX_OBSERVER_EVENTS + 900, + "the newest event of an over-cap batch is retained", + ); + }); +}); + +describe("live observer journal retention — eviction floor (reconnect replay)", () => { + // The dedup set is built only from the retained array, so once eviction + // discards the oldest frames the journal no longer remembers them. Relay + // reconnect replays old frames as a normal behavior; without a floor a + // replayed pre-eviction frame is re-admitted into the headroom and a later + // refill to the cap trims away legitimate retained events with no new + // activity. These pin the floor that rejects already-evicted history. + beforeEach(() => { + resetAgentObserverStore(); + }); + + it("test_replayed_pre_floor_frames_do_not_change_retained_window", () => { + // Overflow to the low-water mark, then replay the discarded oldest frames. + // They are at or below the eviction floor, so none is re-admitted: the + // retained window is byte-identical and no listener is notified. + fillSequential(MAX_OBSERVER_EVENTS + 1); + const before = getAgentObserverSnapshot(AGENT_PUBKEY).events; + assert.equal(before.length, OBSERVER_EVENTS_LOW_WATER); + const beforeFirstSeq = before.at(0).seq; + + let notifications = 0; + const unsubscribe = subscribeAgentObserverStore(() => { + notifications += 1; + }); + try { + // seq 1..(beforeFirstSeq - 1) were discarded; replay a spread of them + // plus the boundary event itself (beforeFirstSeq - 1 is the floor). + for (let seq = 1; seq < beforeFirstSeq; seq += 1) { + syncAgentObserverEvents(AGENT_PUBKEY, [makeEvent(seq)]); + } + } finally { + unsubscribe(); + } + + const after = getAgentObserverSnapshot(AGENT_PUBKEY).events; + assert.deepEqual( + after, + before, + "replaying already-evicted frames must not change the retained window", + ); + assert.equal( + notifications, + 0, + "a stale-only replay does no work and notifies no listener", + ); + }); + + it("test_pre_floor_frame_after_refill_drops_no_retained_events", () => { + // Overflow to low-water, refill to the cap through ordinary appends, then + // inject a single pre-floor (already-evicted) frame. Pre-fix this pushed + // length to cap+1 and trimmed away 300 legitimate retained events; the + // floor now rejects it, so the retained window is untouched. + fillSequential(MAX_OBSERVER_EVENTS + 1); + const floorBoundarySeq = + getAgentObserverSnapshot(AGENT_PUBKEY).events.at(0).seq; + const headroom = MAX_OBSERVER_EVENTS - OBSERVER_EVENTS_LOW_WATER; + for (let i = 1; i <= headroom; i += 1) { + syncAgentObserverEvents(AGENT_PUBKEY, [ + makeEvent(MAX_OBSERVER_EVENTS + 1 + i), + ]); + } + const atCap = getAgentObserverSnapshot(AGENT_PUBKEY).events; + assert.equal(atCap.length, MAX_OBSERVER_EVENTS, "refilled back to the cap"); + + // A pre-floor frame (seq strictly below the boundary that was evicted). + syncAgentObserverEvents(AGENT_PUBKEY, [makeEvent(floorBoundarySeq - 1)]); + + const after = getAgentObserverSnapshot(AGENT_PUBKEY).events; + assert.equal( + after.length, + MAX_OBSERVER_EVENTS, + "a rejected pre-floor frame must not trigger a trim below the cap", + ); + assert.deepEqual( + after, + atCap, + "no legitimate retained event is dropped by a pre-floor arrival", + ); + }); + + it("test_out_of_order_frame_newer_than_floor_is_still_admitted", () => { + // The floor must reject only already-evicted history, never a legitimate + // out-of-order frame that sorts after the floor. Such a frame lands in the + // retained window (via the rebuild fallback) and advances the length. + fillSequential(MAX_OBSERVER_EVENTS + 1); + const retained = getAgentObserverSnapshot(AGENT_PUBKEY).events; + const oldestRetainedSeq = retained.at(0).seq; + const lengthBefore = retained.length; + + // The eviction floor is the boundary event just below the retained window + // (seq oldestRetainedSeq - 1). Construct a never-seen frame whose timestamp + // sits strictly between the floor and the oldest retained event — out of + // order versus the tail, but newer than the floor, so it must be admitted. + const floorSeq = oldestRetainedSeq - 1; + const oooEvent = { + seq: 1_000_000_000, + timestamp: new Date( + 1_760_000_000_000 + floorSeq * 1000 + 500, + ).toISOString(), + kind: "turn_started", + agentIndex: 0, + channelId: "chan-1", + sessionId: "sess-1", + turnId: "turn-ooo", + payload: {}, + }; + syncAgentObserverEvents(AGENT_PUBKEY, [oooEvent]); + + const after = getAgentObserverSnapshot(AGENT_PUBKEY).events; + assert.equal( + after.length, + lengthBefore + 1, + "an out-of-order frame newer than the floor is admitted, not rejected", + ); + assert.ok( + after.some((event) => event.seq === oooEvent.seq), + "the admitted frame is present in the retained window", + ); + }); +}); diff --git a/desktop/src/features/agents/ui/AgentConfigPanel.tsx b/desktop/src/features/agents/ui/AgentConfigPanel.tsx index c6081f96885..046bd13c473 100644 --- a/desktop/src/features/agents/ui/AgentConfigPanel.tsx +++ b/desktop/src/features/agents/ui/AgentConfigPanel.tsx @@ -10,7 +10,6 @@ import { Layers, MessageSquare, Pencil, - PenOff, Server, } from "lucide-react"; import { useAgentConfigSurface } from "../hooks"; @@ -25,7 +24,6 @@ import { McpServersSection, shouldRenderMcpServers } from "./McpServersSection"; import type { ConfigField, ConfigOrigin, - ConfigWriteMechanism, NormalizedConfig, NormalizedField, RuntimeConfigSurface, @@ -54,16 +52,6 @@ type AgentConfigSurfaceRowsProps = { sections?: readonly AgentConfigPanelSection[]; }; -function isReadOnlyField({ - origin, - writeVia, -}: { - origin: ConfigOrigin; - writeVia: ConfigWriteMechanism; -}) { - return writeVia.type === "readOnly" || origin === "harnessConstraint"; -} - function ConfigFieldLabel({ label }: { label: string }) { return ( @@ -72,25 +60,6 @@ function ConfigFieldLabel({ label }: { label: string }) { ); } -function ProvenanceHint({ - locked, - provenance, - showLockIcon = true, -}: { - locked: boolean; - provenance: string; - showLockIcon?: boolean; -}) { - return ( - - {locked && showLockIcon ? ( - - ) : null} - {provenance} - - ); -} - function shouldOfferCopy({ fieldKey, origin, @@ -130,42 +99,6 @@ function shouldOfferCopy({ type RowVariant = "compact" | "profile"; -// ── Provenance sentence ────────────────────────────────────────────────────── - -function provenanceSentence( - origin: ConfigOrigin, - writeVia: ConfigWriteMechanism, - configFilePath: string | null, -): string | null { - switch (origin) { - case "buzzExplicit": - return "Set in Buzz"; - case "personaDefault": - return null; - case "runtimeOverride": - return "Live override (this session only)"; - case "harnessConstraint": - return "Locked by harness"; - case "envVar": { - if (writeVia.type === "respawnWithEnvVar") { - return `From environment variable (${writeVia.envKey})`; - } - return "From environment variable"; - } - case "configFile": - return configFilePath - ? `From config file (${configFilePath})` - : "From config file"; - case "acpConfigOption": - case "acpNativeRead": - return "From ACP session"; - case "globalDefault": - return "Inherited from global defaults"; - case "harnessDefault": - return "Inherited from harness definition"; - } -} - // ── Normalized row ──────────────────────────────────────────────────────────── const NORMALIZED_LABELS: Record = { @@ -192,42 +125,29 @@ function NormalizedRow({ fieldKey, label, field, - isPreSpawn, - configFilePath, onEdit, variant = "compact", }: { fieldKey: keyof NormalizedConfig; label: string; field: NormalizedField; - isPreSpawn: boolean; - configFilePath: string | null; onEdit?: () => void; variant?: RowVariant; }) { const Icon = NORMALIZED_ICONS[fieldKey]; - // ACP-sourced origins only become meaningful post-spawn - const isAcpOnly = - field.origin === "acpNativeRead" || field.origin === "acpConfigOption"; - const rawDisplayValue = - isPreSpawn && isAcpOnly - ? "Available after agent starts" - : (field.value ?? "—"); + const rawDisplayValue = field.value ?? "—"; const displayValue = fieldKey === "provider" ? providerDisplayLabel(rawDisplayValue) : rawDisplayValue; - const provenance = field.value - ? provenanceSentence(field.origin, field.writeVia, configFilePath) - : null; - const locked = isReadOnlyField(field); const isEditable = variant === "profile" && onEdit !== undefined; const content = ( <> - {variant === "compact" ? ( - - ) : null} + {variant === "profile" ? ( @@ -241,25 +161,7 @@ function NormalizedRow({ title={field.value ?? undefined} > {displayValue} - {!(isPreSpawn && isAcpOnly) && field.overriddenValue ? ( - - {field.overriddenValue} - - ) : null} - {provenance ? ( - - ) : null} {isEditable ? ( — )} - {provenance ? ( -
- {provenance} -
- ) : null} ); } @@ -351,13 +242,6 @@ function AdvancedRow({ > {field.value ?? "—"} - {provenance ? ( - - ) : null} {isCopyable ? ( -
+
{normalizedEntries.map(([key, field]) => ( {advanced.map((field) => ( - + ))} ) : null} @@ -567,9 +437,7 @@ export function AgentConfigSurfaceRows({ return (
{/* Normalized section */} -
+
{normalizedEntries.length === 0 ? (

No config fields available. @@ -577,12 +445,10 @@ export function AgentConfigSurfaceRows({ ) : ( normalizedEntries.map(([key, field]) => ( )) @@ -613,11 +479,7 @@ export function AgentConfigSurfaceRows({ {advancedOpen ? (

{advanced.map((field) => ( - + ))}
) : null} diff --git a/desktop/src/features/agents/ui/AgentConfigPanelPresentation.test.mjs b/desktop/src/features/agents/ui/AgentConfigPanelPresentation.test.mjs new file mode 100644 index 00000000000..458d762b81b --- /dev/null +++ b/desktop/src/features/agents/ui/AgentConfigPanelPresentation.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const source = await readFile( + new URL("./AgentConfigPanel.tsx", import.meta.url), + "utf8", +); + +test("shared configuration rows show only the effective value", () => { + for (const forbiddenPattern of [ + /Available after agent starts/, + /provenanceSentence/, + /ProvenanceHint/, + /field\.overriddenValue/, + /line-through/, + /isPreSpawn\s*&&\s*["']opacity-/, + ]) { + assert.doesNotMatch(source, forbiddenPattern); + } +}); + +test("unknown normalized values use an em dash", () => { + assert.match(source, /const rawDisplayValue = field\.value \?\? "—";/); +}); + +test("profile model rows keep their bare leading icons", () => { + assert.match(source, /data-slot="agent-config-field-icon"/); + assert.doesNotMatch(source, /rounded-full bg-muted[^\n]* { - const activeDiff = - Number(isManagedAgentActive(right)) - Number(isManagedAgentActive(left)); - if (activeDiff !== 0) return activeDiff; - return left.name.localeCompare(right.name); - })[0]; -} diff --git a/desktop/src/features/channels/readState/readStateIdentity.ts b/desktop/src/features/channels/readState/readStateIdentity.ts new file mode 100644 index 00000000000..c6ab7300414 --- /dev/null +++ b/desktop/src/features/channels/readState/readStateIdentity.ts @@ -0,0 +1,58 @@ +import { localExtraSlotIdsKey } from "@/features/channels/readState/readStateFormat"; +import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; + +/** + * localStorage-persisted identity for the read-state manager: the stable + * client id, this client's slot id, and any extra slot ids allocated when the + * blob outgrows the single-slot budget (NIP-RS multi-slot mode). + */ + +const CLIENT_ID_KEY_PREFIX = "buzz.nip-rs.client-id"; +const SLOT_ID_KEY_PREFIX = "buzz.nip-rs.slot-id"; + +export function generateHex(bytes: number): string { + const arr = new Uint8Array(bytes); + crypto.getRandomValues(arr); + return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +export function getOrCreatePersisted( + key: string, + generator: () => string, +): string { + let value = localStorage.getItem(key); + if (!value) { + value = generator(); + setLocalStorageItemWithRecovery(key, value); + } + return value; +} + +export function clientIdKey(pubkey: string): string { + return `${CLIENT_ID_KEY_PREFIX}:${pubkey}`; +} + +export function slotIdKey(pubkey: string): string { + return `${SLOT_ID_KEY_PREFIX}:${pubkey}`; +} + +export function loadExtraSlotIds(pubkey: string): string[] { + try { + const raw = localStorage.getItem(localExtraSlotIdsKey(pubkey)); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (v): v is string => typeof v === "string" && v.length > 0, + ); + } catch { + return []; + } +} + +export function saveExtraSlotIds(pubkey: string, ids: string[]): void { + setLocalStorageItemWithRecovery( + localExtraSlotIdsKey(pubkey), + JSON.stringify(ids), + ); +} diff --git a/desktop/src/features/channels/readState/readStateManager.test.mjs b/desktop/src/features/channels/readState/readStateManager.test.mjs index 8831a952bf6..c46654f32e6 100644 --- a/desktop/src/features/channels/readState/readStateManager.test.mjs +++ b/desktop/src/features/channels/readState/readStateManager.test.mjs @@ -841,3 +841,71 @@ test("publishSplitSlots_noopSuppression_skipsWhenUnchanged", async () => { mgr.destroy(); }); + +// ── ReadStateManager — self-echo drop ───────────────────────────────────────── + +// The live subscription (authors=[us]) echoes back every event we publish. +// handleIncomingEvent must drop those echoes by id BEFORE the decrypt/parse +// step: with hundreds of channels a blob is tens of KB, so decrypting our own +// echo on every read was a large recurring cost. Strategy mirrors the split- +// mode test above: stub the private parseEvent seam to count decrypt attempts. +test("handleIncomingEvent_dropsSelfEchoBeforeDecrypt", async () => { + globalThis.window.localStorage = makeLocalStorage(); + + const pubkey = "c".repeat(64); + const mgr = new ReadStateManager(pubkey, makeFakeRelay()); + + let parseCount = 0; + mgr.parseEvent = async () => { + parseCount++; + return null; + }; + + const makeEvent = (id) => ({ + id, + pubkey, + kind: 30078, + created_at: 1_000, + content: "ciphertext", + tags: [ + ["d", "read-state:slot"], + ["t", "read-state"], + ], + }); + + // An event we published ourselves: echo must be dropped without a parse. + const ownId = "e".repeat(64); + mgr.rememberPublishedId(ownId); + await mgr.handleIncomingEvent(makeEvent(ownId)); + assert.equal(parseCount, 0, "self-echo must not reach decrypt/parse"); + + // The drop consumes the remembered id — a replayed duplicate (e.g. from a + // reconnect catch-up) goes through the normal parse path. + await mgr.handleIncomingEvent(makeEvent(ownId)); + assert.equal(parseCount, 1, "second delivery of same id must parse"); + + // An event from another client of the same pubkey must always parse. + await mgr.handleIncomingEvent(makeEvent("f".repeat(64))); + assert.equal(parseCount, 2, "foreign-client event must parse"); + + mgr.destroy(); +}); + +// The remembered-id set must stay bounded even if publishes fail (a failed +// publish leaves an id that is never echoed back, so nothing deletes it). +test("rememberPublishedId_evictsOldestBeyondCap", () => { + globalThis.window.localStorage = makeLocalStorage(); + + const mgr = new ReadStateManager("d".repeat(64), makeFakeRelay()); + + const total = 100; // beyond the 64-id cap + for (let i = 0; i < total; i++) { + mgr.rememberPublishedId(`id-${i}`); + } + const ids = mgr.recentlyPublishedIds; + assert.equal(ids.size, 64, "set must be capped"); + assert.ok(!ids.has("id-0"), "oldest id must be evicted"); + assert.ok(ids.has(`id-${total - 1}`), "newest id must be retained"); + + mgr.destroy(); +}); diff --git a/desktop/src/features/channels/readState/readStateManager.ts b/desktop/src/features/channels/readState/readStateManager.ts index 3ba382dc61e..87fc1ceaac8 100644 --- a/desktop/src/features/channels/readState/readStateManager.ts +++ b/desktop/src/features/channels/readState/readStateManager.ts @@ -10,10 +10,17 @@ import { READ_STATE_MAX_SLOTS, MSG_PREFIX, THREAD_PREFIX, - localExtraSlotIdsKey, type ReadStateBlob, } from "@/features/channels/readState/readStateFormat"; import { parseReadStateEvent } from "@/features/channels/readState/readStateSnapshot"; +import { + clientIdKey, + generateHex, + getOrCreatePersisted, + loadExtraSlotIds, + saveExtraSlotIds, + slotIdKey, +} from "@/features/channels/readState/readStateIdentity"; import { readStoredReadState, writeStoredReadState, @@ -21,54 +28,12 @@ import { import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; import { truncatePubkey } from "@/shared/lib/pubkey"; -const CLIENT_ID_KEY_PREFIX = "buzz.nip-rs.client-id"; -const SLOT_ID_KEY_PREFIX = "buzz.nip-rs.slot-id"; const PUBLISH_DEBOUNCE_MS = 5_000; const LOCAL_PERSIST_MAX_WAIT_MS = 1_000; - -function generateHex(bytes: number): string { - const arr = new Uint8Array(bytes); - crypto.getRandomValues(arr); - return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join(""); -} - -function getOrCreatePersisted(key: string, generator: () => string): string { - let value = localStorage.getItem(key); - if (!value) { - value = generator(); - setLocalStorageItemWithRecovery(key, value); - } - return value; -} - -function clientIdKey(pubkey: string): string { - return `${CLIENT_ID_KEY_PREFIX}:${pubkey}`; -} - -function slotIdKey(pubkey: string): string { - return `${SLOT_ID_KEY_PREFIX}:${pubkey}`; -} - -function loadExtraSlotIds(pubkey: string): string[] { - try { - const raw = localStorage.getItem(localExtraSlotIdsKey(pubkey)); - if (!raw) return []; - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - return parsed.filter( - (v): v is string => typeof v === "string" && v.length > 0, - ); - } catch { - return []; - } -} - -function saveExtraSlotIds(pubkey: string, ids: string[]): void { - setLocalStorageItemWithRecovery( - localExtraSlotIdsKey(pubkey), - JSON.stringify(ids), - ); -} +/** How many of our own just-published event ids to remember so the live + * subscription can drop their relay echoes before the nip44 decrypt. A + * publish cycle emits at most a handful of slot events; 64 is generous. */ +const PUBLISHED_ID_MEMORY = 64; export type ApplyRemoteContextResult = "unchanged" | "advanced"; @@ -322,6 +287,8 @@ export class ReadStateManager { private pendingSyncedAdvances = new Set(); private destroyed = false; private parentResolver: ContextParentResolver | null = null; + /** Event ids we published ourselves; used to skip decrypting their echoes. */ + private recentlyPublishedIds = new Set(); constructor(pubkey: string, relayClient: RelayClient) { this.pubkey = pubkey; @@ -496,7 +463,7 @@ export class ReadStateManager { >(); for (const event of events) { - const parsed = await parseReadStateEvent(event, this.pubkey); + const parsed = await this.parseEvent(event); if (this.destroyed) return; if (!parsed) continue; @@ -533,7 +500,7 @@ export class ReadStateManager { // Conflict detection: check if another client_id is squatting on our // d-tag coordinate. If so, rotate our slotId to avoid clobbering. for (const event of events) { - const parsed = await parseReadStateEvent(event, this.pubkey); + const parsed = await this.parseEvent(event); if (this.destroyed) return; if (!parsed || parsed.dTag !== `read-state:${this.slotId}`) continue; if (parsed.blob.client_id !== this.clientId) { @@ -588,11 +555,24 @@ export class ReadStateManager { private async handleIncomingEvent(event: RelayEvent): Promise { if (this.destroyed || event.pubkey !== this.pubkey) return; + + // Echo drop: the live subscription (authors=[us]) receives every event we + // publish right back from the relay. Decrypting and re-parsing our own + // blob is pure waste — with hundreds of channels a single blob runs tens + // of KB, so on an actively-reading client the echo was a large recurring + // nip44-decrypt + JSON.parse for information we already hold. + if (this.recentlyPublishedIds.delete(event.id)) { + console.debug( + `[ReadStateManager] dropped self-echo event=${event.id.substring(0, 8)}…`, + ); + return; + } + console.debug( `[ReadStateManager] incoming event=${event.id.substring(0, 8)}… created_at=${event.created_at}`, ); - const parsed = await parseReadStateEvent(event, this.pubkey); + const parsed = await this.parseEvent(event); if (!parsed || this.destroyed) return; this.maxFetchedCreatedAt = Math.max( @@ -635,6 +615,22 @@ export class ReadStateManager { } } + /** Seam over `parseReadStateEvent` so tests can count/stub decrypts + * (see readStateManager.test.mjs echo-drop tests). */ + private parseEvent(event: RelayEvent) { + return parseReadStateEvent(event, this.pubkey); + } + + /** Record an id we just published, capped so failed publishes can't grow + * the set unboundedly. Set preserves insertion order, so eviction is FIFO. */ + private rememberPublishedId(id: string): void { + this.recentlyPublishedIds.add(id); + if (this.recentlyPublishedIds.size > PUBLISHED_ID_MEMORY) { + const oldest = this.recentlyPublishedIds.values().next().value; + if (oldest !== undefined) this.recentlyPublishedIds.delete(oldest); + } + } + private schedulePublish(): void { if (this.destroyed) return; if (this.debounceTimer !== null) { @@ -713,6 +709,10 @@ export class ReadStateManager { tags, }); + // Remember the id BEFORE publishing: the relay may fan the event out to + // our own live subscription before the publish OK resolves. A failed + // publish leaves a never-echoed id in the set; the size cap evicts it. + this.rememberPublishedId(event.id); await this.relayClient.publishEvent( event, "Timed out publishing read state.", diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 8fc7cfaf51a..1ec6cee95e3 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -303,6 +303,11 @@ export const ChannelPane = React.memo(function ChannelPane({ mentionPubkeys: string[], mediaTags?: string[][], channelId?: string | null, + threadContext?: { + parentEventId: string | null; + threadHeadId: string | null; + } | null, + forceRest?: boolean, ) => { const shouldCompleteWelcomeBanner = isActiveWelcomeChannel && @@ -310,7 +315,14 @@ export const ChannelPane = React.memo(function ChannelPane({ mentionsKnownAgent(mentionPubkeys, knownAgentPubkeys)); messageTimelineRef.current?.scrollToBottomOnNextUpdate(); - await onSendMessage(content, mentionPubkeys, mediaTags, channelId); + await onSendMessage( + content, + mentionPubkeys, + mediaTags, + channelId, + threadContext, + forceRest, + ); if ( channelId && @@ -901,7 +913,6 @@ export const ChannelPane = React.memo(function ChannelPane({ const panel = ( void; onOpenDm?: (pubkeys: string[]) => Promise | void; onOpenMembers?: () => void; - onOpenProfilePanel: (pubkey: string) => void; + onOpenProfilePanel: ( + pubkey: string, + options?: ProfilePanelOpenOptions, + ) => void; onOpenThread: (message: TimelineMessage) => void; onResetThreadPanelWidth: () => void; onSelectThreadReplyTarget: (message: TimelineMessage) => void; @@ -106,6 +110,11 @@ export type ChannelPaneProps = { mentionPubkeys: string[], mediaTags?: string[][], channelId?: string | null, + threadContext?: { + parentEventId: string | null; + threadHeadId: string | null; + } | null, + forceRest?: boolean, ) => Promise; onSendToChannel: ( message: TimelineMessage, diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 8150f6df7de..6254afd8c71 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -122,6 +122,7 @@ export function ChannelScreen({ clearMessageRouteTarget, openAgentSessionChannelId, openAgentSessionPubkey, + openProfilePanel, openThreadHeadId, profilePanelPubkey, profilePanelTab, @@ -585,6 +586,7 @@ export function ChannelScreen({ const { handleOpenProfilePanel, handleCloseProfilePanel, handleOpenDm } = useChannelProfilePanel({ closeAgentSession: handleCloseAgentSession, + openProfilePanel, setChannelManagementOpen, setExpandedThreadReplyIds, setOpenThreadHeadId, diff --git a/desktop/src/features/channels/ui/ForumChannelContent.tsx b/desktop/src/features/channels/ui/ForumChannelContent.tsx index 428413c5977..35655026161 100644 --- a/desktop/src/features/channels/ui/ForumChannelContent.tsx +++ b/desktop/src/features/channels/ui/ForumChannelContent.tsx @@ -10,6 +10,7 @@ import type { ProfilePanelView, } from "@/features/profile/ui/UserProfilePanelUtils"; import type { Channel } from "@/shared/api/types"; +import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type ForumChannelContentProps = { @@ -20,7 +21,10 @@ type ForumChannelContentProps = { onClosePost: () => void; onCloseProfilePanel: () => void; onOpenDm?: (pubkeys: string[]) => Promise | void; - onOpenProfilePanel: (pubkey: string) => void; + onOpenProfilePanel: ( + pubkey: string, + options?: ProfilePanelOpenOptions, + ) => void; onPanelResizeStart: (event: React.PointerEvent) => void; onProfilePanelTabChange: ( tab: ProfilePanelTab, @@ -97,7 +101,6 @@ export function ForumChannelContent({ > + applyPatch({ + profile: pubkey, + profileTab: options?.tab === "info" ? null : (options?.tab ?? null), + profileView: null, + }), + [applyPatch], + ); + const setProfilePanelView = React.useCallback( (value: ProfilePanelView, options?: PanelSetterOptions) => applyPatch({ profileView: value === "summary" ? null : value }, options), @@ -116,6 +127,7 @@ export function useChannelPanelHistoryState() { clearMessageRouteTarget, openAgentSessionChannelId: values.agentSessionChannel, openAgentSessionPubkey: values.agentSession, + openProfilePanel, openThreadHeadId: values.thread, profilePanelPubkey: values.profile, profilePanelTab: profilePanelTabFromSearch(values.profileTab), diff --git a/desktop/src/features/channels/ui/useChannelProfilePanel.ts b/desktop/src/features/channels/ui/useChannelProfilePanel.ts index 9d6961a93da..61e9211480b 100644 --- a/desktop/src/features/channels/ui/useChannelProfilePanel.ts +++ b/desktop/src/features/channels/ui/useChannelProfilePanel.ts @@ -2,9 +2,11 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useOpenDmMutation } from "@/features/channels/hooks"; +import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; type UseChannelProfilePanelOptions = { closeAgentSession: () => void; + openProfilePanel: (pubkey: string, options?: ProfilePanelOpenOptions) => void; setChannelManagementOpen: (open: boolean) => void; setExpandedThreadReplyIds: (value: Set) => void; setOpenThreadHeadId: (value: string | null) => void; @@ -15,6 +17,7 @@ type UseChannelProfilePanelOptions = { export function useChannelProfilePanel({ closeAgentSession, + openProfilePanel, setChannelManagementOpen, setExpandedThreadReplyIds, setOpenThreadHeadId, @@ -26,21 +29,21 @@ export function useChannelProfilePanel({ const openDmMutation = useOpenDmMutation(); const handleOpenProfilePanel = React.useCallback( - (pubkey: string) => { + (pubkey: string, options?: ProfilePanelOpenOptions) => { setOpenThreadHeadId(null); setExpandedThreadReplyIds(new Set()); setThreadScrollTargetId(null); setThreadReplyTargetId(null); closeAgentSession(); setChannelManagementOpen(false); - setProfilePanelPubkey(pubkey); + openProfilePanel(pubkey, options); }, [ closeAgentSession, + openProfilePanel, setChannelManagementOpen, setExpandedThreadReplyIds, setOpenThreadHeadId, - setProfilePanelPubkey, setThreadReplyTargetId, setThreadScrollTargetId, ], diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index d7b2e5a6fd7..f9c57f6656a 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -284,12 +284,18 @@ export function useChannelPaneHandlers({ mentionPubkeys: string[], mediaTags?: string[][], channelId?: string | null, + _threadContext?: { + parentEventId: string | null; + threadHeadId: string | null; + } | null, + forceRest?: boolean, ) => { await sendMutateRef.current({ content, mentionPubkeys, mediaTags, channelId: channelId ?? undefined, + forceRest, }); }, [], @@ -327,6 +333,7 @@ export function useChannelPaneHandlers({ parentEventId: string | null; threadHeadId: string | null; } | null, + forceRest?: boolean, ) => { // Resolve target using captured submit-time context (race-free) or live // refs (legacy path). When threadContext is supplied, no live-ref reads @@ -359,6 +366,7 @@ export function useChannelPaneHandlers({ parentEventId, mediaTags, channelId: channelId ?? undefined, + forceRest, }); // Only update thread UI state if the user is still viewing the same diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 0c27ab0541f..93255fc8ba6 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -15,12 +15,14 @@ import { getOverrides } from "@/shared/features"; import { resetMediaCaches } from "@/shared/lib/mediaUrl"; import { resetLinkPreviewMetadataCache } from "@/shared/lib/useResolvedLinkPreviews"; import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache"; +import { resetNavigationDeepLinkDrain } from "@/shared/deep-link"; import { clearAllDrafts, initDraftStore, } from "@/features/messages/lib/useDrafts"; import { resetRenderScopedReactionHydration } from "@/features/messages/lib/renderScopedReactions"; import { resetBackgroundMediaUploads } from "@/features/messages/lib/backgroundMediaUploadStore"; +import { resetLinkPreviewPreparations } from "@/features/messages/lib/linkPreviewPreparationStore"; import { resetActiveAgentTurnsStore, saveActiveAgentTurnsForCommunity, @@ -47,12 +49,13 @@ import type { Community } from "./types"; * destroyed via effect cleanup and do not need entries here. * See AGENTS.md "Community Switching" for the full contract. */ -function resetCommunityState({ +async function resetCommunityState({ resetAvatarState, }: { resetAvatarState: boolean; -}): void { +}): Promise { relayClient.disconnect(); + await resetNavigationDeepLinkDrain(); resetRateLimitGate(); clearAllDrafts(); resetAgentObserverStore(); @@ -71,6 +74,7 @@ function resetCommunityState({ resetVideoPlayerState(); resetRenderScopedReactionHydration(); resetBackgroundMediaUploads(); + resetLinkPreviewPreparations(); clearSearchHitEventCache(); clearMarkdownNodeCache(); } @@ -128,7 +132,23 @@ export function useCommunityInit( saveActiveAgentTurnsForCommunity(prevCommunityIdRef.current); prevCommunityIdRef.current = null; } - resetCommunityState({ resetAvatarState: true }); + try { + await resetCommunityState({ resetAvatarState: true }); + } catch (error) { + console.error("Failed to reset community state:", error); + if (!cancelled) { + setResult({ + isReady: false, + needsSetup: false, + appliedKey: null, + error: + error instanceof Error + ? `Could not safely leave community: ${error.message}` + : "Could not safely leave community", + }); + } + return; + } appliedRelayUrlRef.current = null; hasInitializedRef.current = false; } @@ -207,10 +227,26 @@ export function useCommunityInit( // store under the outgoing community ID and delete its snapshot. prevCommunityIdRef.current = null; } - resetCommunityState({ - resetAvatarState: - appliedRelayUrlRef.current !== activeCommunity.relayUrl, - }); + try { + await resetCommunityState({ + resetAvatarState: + appliedRelayUrlRef.current !== activeCommunity.relayUrl, + }); + } catch (error) { + console.error("Failed to reset community state:", error); + if (!cancelled) { + setResult({ + isReady: false, + needsSetup: false, + appliedKey: null, + error: + error instanceof Error + ? `Could not safely switch communities: ${error.message}` + : "Could not safely switch communities", + }); + } + return; + } } hasInitializedRef.current = true; appliedRelayUrlRef.current = activeCommunity.relayUrl; diff --git a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx index a2056b6d37c..06b19d61679 100644 --- a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx +++ b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx @@ -12,6 +12,7 @@ import { import { useUsersBatchQuery } from "@/features/profile/hooks"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; +import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup"; import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; import type { RelayMember, @@ -314,17 +315,16 @@ export function CommunityMembersSettingsCard({ description="Manage members and community access." /> -
-
-

+ Members {members.length > 0 ? ( - - {members.length} - + {members.length} ) : null} -

-
+ + } + >
@@ -376,7 +376,7 @@ export function CommunityMembersSettingsCard({ /> )}
-
+ ; -const listTimeFormatter = new Intl.DateTimeFormat("en-US", { - hour: "numeric", - minute: "2-digit", -}); - const fullTimeFormatter = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", @@ -116,31 +116,6 @@ const fullTimeFormatter = new Intl.DateTimeFormat("en-US", { minute: "2-digit", }); -const shortDateFormatter = new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", -}); - -const shortDateWithYearFormatter = new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", - year: "numeric", -}); - -const weekdayFormatter = new Intl.DateTimeFormat("en-US", { - weekday: "long", -}); - -function startOfDay(value: Date) { - return new Date(value.getFullYear(), value.getMonth(), value.getDate()); -} - -function diffInDays(from: Date, to: Date) { - return Math.round( - (startOfDay(from).getTime() - startOfDay(to).getTime()) / 86_400_000, - ); -} - function tagValue(item: FeedItem, name: string) { return item.tags.find((tag) => tag[0] === name)?.[1]?.trim() || null; } @@ -445,23 +420,7 @@ export function findInboxItemByEventId( } function formatInboxTimestamp(unixSeconds: number) { - const date = new Date(unixSeconds * 1_000); - const now = new Date(); - const dayDiff = diffInDays(now, date); - - if (dayDiff === 0) { - return listTimeFormatter.format(date); - } - - if (dayDiff === 1) { - return "Yesterday"; - } - - if (now.getFullYear() === date.getFullYear()) { - return shortDateFormatter.format(date); - } - - return shortDateWithYearFormatter.format(date); + return formatItemTimestamp(unixSeconds); } export function formatInboxFullTimestamp(unixSeconds: number) { @@ -480,21 +439,14 @@ export function relayEventFromFeedItem(item: FeedItem): RelayEvent { }; } -export function groupInboxItems(items: InboxItem[]): InboxGroup[] { +export function groupInboxItems( + items: InboxItem[], + nowSeconds = Date.now() / 1_000, +): InboxGroup[] { const groups = new Map(); - const now = new Date(); for (const item of items) { - const date = new Date(item.latestActivityAt * 1_000); - const dayDiff = diffInDays(now, date); - const label = - dayDiff === 0 - ? "Today" - : dayDiff === 1 - ? "Yesterday" - : dayDiff < 7 - ? weekdayFormatter.format(date) - : shortDateWithYearFormatter.format(date); + const label = formatDayGroupLabel(item.latestActivityAt, nowSeconds); const current = groups.get(label) ?? []; current.push(item); diff --git a/desktop/src/features/home/lib/inboxViewHelpers.ts b/desktop/src/features/home/lib/inboxViewHelpers.ts index 42ed8e1a5a6..d1bffd1899c 100644 --- a/desktop/src/features/home/lib/inboxViewHelpers.ts +++ b/desktop/src/features/home/lib/inboxViewHelpers.ts @@ -228,6 +228,7 @@ export function toInboxContextMessage( export function toTimelineMessage( message: InboxContextMessage, ): TimelineMessage { + const threadReference = getThreadReference(message.tags ?? []); return { id: message.id, author: message.authorLabel, @@ -239,8 +240,10 @@ export function toTimelineMessage( createdAt: message.createdAt, depth: message.depth, kind: message.kind, + parentId: message.parentId ?? threadReference.parentId, pubkey: message.authorPubkey, reactions: message.reactions ?? [], + rootId: message.rootId ?? threadReference.rootId, signerPubkey: message.signerPubkey, tags: message.tags, time: message.timeLabel ?? message.fullTimestampLabel, diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 8ad02376750..9bb593087d0 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -19,7 +19,10 @@ import { ProjectInboxDetail } from "@/features/home/ui/ProjectInboxDetail"; import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar"; import { useCommunities } from "@/features/communities/useCommunities"; import { formatInboxTypeLabel } from "@/features/home/lib/inbox"; -import { hasInboxThreadContext } from "@/features/home/lib/inboxViewHelpers"; +import { + hasInboxThreadContext, + toTimelineMessage, +} from "@/features/home/lib/inboxViewHelpers"; import { type InboxDisplayMessage, InboxMessageRow, @@ -35,6 +38,10 @@ import { orderMentionPubkeysByText } from "@/features/messages/lib/orderMentionP import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage"; import { buildEditMentionState } from "@/features/messages/lib/draftMentionRefs"; import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown"; +import { + buildVideoReviewPresentationByMessageId, + hasRenderedVideoAttachment, +} from "@/features/messages/lib/videoReviewContext"; import { getThreadReference } from "@/features/messages/lib/threading"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; @@ -46,6 +53,7 @@ import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; +import { VideoReviewNavigationProvider } from "@/shared/ui/VideoReviewNavigation"; import { DropdownMenu, DropdownMenuContent, @@ -64,6 +72,9 @@ const MembersSidebar = React.lazy(async () => { return { default: module.MembersSidebar }; }); +const EMPTY_CONTEXT_MESSAGES: InboxContextMessage[] = []; +const EMPTY_REPLIES: InboxReply[] = []; + type InboxDetailPaneProps = { agentPubkeys?: ReadonlySet; canDelete: boolean; @@ -143,7 +154,11 @@ export function InboxDetailPane(props: InboxDetailPaneProps) { ); } - return ; + return ( + + + + ); } function InboxMessageDetailPane({ @@ -160,9 +175,9 @@ function InboxMessageDetailPane({ hasThreadContextLoadError = false, isThreadContextLoading = false, item, - messages = [], + messages = EMPTY_CONTEXT_MESSAGES, profiles, - replies = [], + replies = EMPTY_REPLIES, channel, contextChannelName = null, currentPubkey, @@ -199,7 +214,6 @@ function InboxMessageDetailPane({ // Build the plain, non-virtualized timeline the shared hook anchors against. // Live arrivals rerun its layout compensation without changing the target. - const selectedMessage = messages.find((message) => message.isSelected); // A latest reply can represent an Inbox conversation. Resolve the actual // root from loaded context or the complete feed group; never treat an // unresolved root/profile lookup as an authoritative empty audience. @@ -234,34 +248,100 @@ function InboxMessageDetailPane({ ) : [] : undefined; - const pendingReplyMessages: InboxDisplayMessage[] = replies.map((reply) => ({ - ...reply, - depth: reply.depth ?? (selectedMessage?.depth ?? 0) + 1, - isSelected: false, - mentionNames: [], - })); - const displayMessages: InboxDisplayMessage[] = - messages.length > 0 - ? [...messages, ...pendingReplyMessages] - : item - ? [ - { - authorLabel: item.senderLabel, - authorPubkey: item.item.pubkey, - avatarUrl: item.avatarUrl, - content: item.preview, - createdAt: item.item.createdAt, - depth: 0, - fullTimestampLabel: item.fullTimestampLabel, - id: item.id, - isSelected: true, - mentionNames: item.mentionNames, - mentionPubkeysByName: item.mentionPubkeysByName, - timeLabel: formatTime(item.item.createdAt), - }, - ...pendingReplyMessages, - ] - : pendingReplyMessages; + const displayMessages = React.useMemo(() => { + const selectedMessage = messages.find((message) => message.isSelected); + const pendingReplyMessages: InboxDisplayMessage[] = replies.map( + (reply) => ({ + ...reply, + depth: reply.depth ?? (selectedMessage?.depth ?? 0) + 1, + isSelected: false, + mentionNames: [], + }), + ); + + if (messages.length > 0) { + return [...messages, ...pendingReplyMessages]; + } + if (!item) return pendingReplyMessages; + + const threadReference = getThreadReference(item.item.tags); + return [ + { + authorLabel: item.senderLabel, + authorPubkey: item.item.pubkey, + avatarUrl: item.avatarUrl, + content: item.preview, + createdAt: item.item.createdAt, + depth: 0, + fullTimestampLabel: item.fullTimestampLabel, + id: item.id, + isSelected: true, + mentionNames: item.mentionNames, + mentionPubkeysByName: item.mentionPubkeysByName, + kind: item.item.kind, + parentId: threadReference.parentId, + rootId: threadReference.rootId, + tags: item.item.tags, + timeLabel: formatTime(item.item.createdAt), + }, + ...pendingReplyMessages, + ]; + }, [item, messages, replies]); + const videoReviewMessages = React.useMemo( + () => displayMessages.map(toTimelineMessage), + [displayMessages], + ); + const videoReviewChannelType = + item?.item.channelType === "dm" || + item?.item.channelType === "stream" || + item?.item.channelType === "forum" + ? item.item.channelType + : null; + const handleSendVideoReviewComment = React.useCallback( + ( + message: TimelineMessage, + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + parentEventId?: string, + ) => + onSendReply({ + content, + mediaTags, + mentionPubkeys, + parentEventId: parentEventId ?? message.id, + }), + [onSendReply], + ); + const videoReviewPresentation = React.useMemo( + () => + buildVideoReviewPresentationByMessageId( + { + channelId: item?.item.channelId, + channelName: contextChannelName ?? item?.channelLabel ?? undefined, + channelType: videoReviewChannelType, + isSendingVideoReviewComment: isSendingReply, + messages: videoReviewMessages, + onSendVideoReviewComment: canReply + ? handleSendVideoReviewComment + : undefined, + onToggleReaction, + profiles, + }, + hasRenderedVideoAttachment, + ), + [ + canReply, + contextChannelName, + handleSendVideoReviewComment, + isSendingReply, + item, + onToggleReaction, + profiles, + videoReviewChannelType, + videoReviewMessages, + ], + ); const { onScroll } = useAnchoredScroll({ channelId: conversationId, contentRef, @@ -674,6 +754,12 @@ function InboxMessageDetailPane({ onSelectReplyTarget={handleSelectReplyTarget} onToggleReaction={onToggleReaction} showUnreadBoundary={hasUnreadBoundary} + videoReviewCommentRootId={videoReviewPresentation.commentRootIdsByMessageId.get( + message.id, + )} + videoReviewContext={videoReviewPresentation.contextsByMessageId.get( + message.id, + )} /> ); })} diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 17b06bf284d..b83a19a1673 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -8,6 +8,8 @@ import { type InboxTypeLabel, } from "@/features/home/lib/inbox"; import { buildInboxListRows } from "@/features/home/lib/inboxListRows"; +import { hasRenderedVideoAttachment } from "@/features/messages/lib/videoReviewContext"; +import { getThreadReference } from "@/features/messages/lib/threading"; import { InboxFilterMenu } from "@/features/home/ui/InboxFilterMenu"; import { DraftsPanel, @@ -30,7 +32,7 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@/shared/ui/context-menu"; -import { Markdown } from "@/shared/ui/markdown"; +import { VideoReviewCommentMarkdown } from "@/shared/ui/VideoReviewCommentMarkdown"; import { MENTION_CHIP_BASE_CLASSES, MESSAGE_MARKDOWN_CLASS, @@ -121,6 +123,34 @@ function formatReminderStatus(notBefore: number | undefined) { return `Reminder in ${Math.floor(secondsUntil / 86_400)}d`; } +function getInboxVideoReviewCommentRootId(item: InboxItem) { + const feedItems = [item.item, ...item.groupItems]; + const feedItemById = new Map( + feedItems.map((feedItem) => [feedItem.id, feedItem]), + ); + const videoMessageIds = new Set( + feedItems + .filter((feedItem) => + hasRenderedVideoAttachment({ + body: feedItem.content, + tags: feedItem.tags, + }), + ) + .map((feedItem) => feedItem.id), + ); + const visited = new Set(); + let ancestorId = getThreadReference(item.item.tags).parentId; + + while (ancestorId && !visited.has(ancestorId)) { + if (videoMessageIds.has(ancestorId)) return ancestorId; + visited.add(ancestorId); + const ancestor = feedItemById.get(ancestorId); + ancestorId = ancestor ? getThreadReference(ancestor.tags).parentId : null; + } + + return undefined; +} + function PersonalItemRow({ id, location, @@ -274,6 +304,7 @@ export function InboxListPane({ ); const hasChannelTarget = Boolean(item.item.channelId); const typeLabel = getInboxTypeLabel(item); + const videoReviewCommentRootId = getInboxVideoReviewCommentRootId(item); const isSenderAgent = agentPubkeys?.has(normalizePubkey(item.item.pubkey)) === true; const profileRole = isSenderAgent ? "bot" : undefined; @@ -408,11 +439,12 @@ export function InboxListPane({ : "font-semibold text-foreground", )} > -
diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index 0c49b2423b0..0d04fd076a2 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -4,10 +4,12 @@ import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import type { InboxContextMessage } from "@/features/home/lib/inbox"; import { toTimelineMessage } from "@/features/home/lib/inboxViewHelpers"; import { formatTimeWithoutDayPeriod } from "@/features/messages/lib/dateFormatters"; +import { formatItemTimestamp } from "@/shared/lib/datetime"; import type { TimelineMessage } from "@/features/messages/types"; import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAuthPubkey"; import { MessageActionBar } from "@/features/messages/ui/MessageActionBar"; import { MessageAgentOwner } from "@/features/messages/ui/MessageAgentOwner"; +import { MessageMetaSeparator } from "@/features/messages/ui/MessageHeader"; import { MessageReactions } from "@/features/messages/ui/MessageReactions"; import { UnreadDivider } from "@/features/messages/ui/UnreadDivider"; import { useReactionHandler } from "@/features/messages/ui/useReactionHandler"; @@ -15,9 +17,11 @@ import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; -import { Markdown } from "@/shared/ui/markdown"; import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import type { VideoReviewContext } from "@/shared/ui/VideoPlayer"; +import { VideoReviewCommentMarkdown } from "@/shared/ui/VideoReviewCommentMarkdown"; +import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; export type InboxDisplayMessage = InboxContextMessage & { depth: number; @@ -41,6 +45,8 @@ type InboxMessageRowProps = { remove: boolean, ) => Promise; showUnreadBoundary?: boolean; + videoReviewCommentRootId?: string; + videoReviewContext?: VideoReviewContext; }; export function InboxMessageRow({ @@ -56,11 +62,17 @@ export function InboxMessageRow({ onSelectReplyTarget, onToggleReaction, showUnreadBoundary = false, + videoReviewCommentRootId, + videoReviewContext, }: InboxMessageRowProps) { const timelineMessage = React.useMemo( () => toTimelineMessage(message), [message], ); + const imetaByUrl = React.useMemo( + () => (message.tags ? parseImetaTags(message.tags) : undefined), + [message.tags], + ); const { customEmoji, emojiOnly } = useMessageEmoji( message.content, message.tags, @@ -94,6 +106,21 @@ export function InboxMessageRow({ const hoverTimestampLabel = formatTimeWithoutDayPeriod( message.timeLabel ?? message.fullTimestampLabel, ); + // Derived here rather than plumbed in with the message: the thread pane has no + // day divider to supply the date, and deriving on render means a row does not + // keep saying "Today" after midnight. `fullTimestampLabel` stays the absolute + // value behind the hover title. + const timestampLabel = formatItemTimestamp(message.createdAt, { + withTime: true, + }); + const timestampNode = ( +

+ {timestampLabel} +

+ ); return (
@@ -192,19 +219,29 @@ export function InboxMessageRow({ {message.isAgent ? ( - - ) : null} -

- {message.fullTimestampLabel} -

+ <> + + {/* + Grouped with the timestamp so the divider never wraps to the + start of a line on its own. Gap matches the container's, so + spacing reads the same either side of the divider. + */} + + + {timestampNode} + + + ) : ( + timestampNode + )}
)}
-
diff --git a/desktop/src/features/huddle/HuddleContext.tsx b/desktop/src/features/huddle/HuddleContext.tsx index ad0bc1e9cce..8e6ccbbd890 100644 --- a/desktop/src/features/huddle/HuddleContext.tsx +++ b/desktop/src/features/huddle/HuddleContext.tsx @@ -11,8 +11,12 @@ import { useHuddlePttState, } from "./lib/useHuddlePttState"; import { useHuddleSpeakerActivity } from "./lib/useHuddleSpeakerActivity"; +import { useMicLevelAnalyser } from "./lib/useMicLevelAnalyser"; import { useTtsSubscription } from "./lib/useTtsSubscription"; -import type { HuddleContextValue } from "./HuddleContext.types"; +import type { + HuddleContextValue, + HuddleLevelsValue, +} from "./HuddleContext.types"; /** * Huddle lifecycle (React context): @@ -47,29 +51,16 @@ const HUDDLE_AUDIO_COMMAND_EVENT = "huddle-audio-command"; const HUDDLE_AUDIO_STATE_EVENT = "huddle-audio-state"; const HUDDLE_AUDIO_LEVEL_EVENT = "huddle-audio-level"; -const MIC_ANALYSER_UPDATE_INTERVAL_MS = 33; -const MIC_INITIAL_NOISE_FLOOR = 0.01; -const MIC_VOICE_GATE_ON_RMS = 0.018; -const MIC_VOICE_GATE_OFF_RMS = 0.012; -const MIC_VOICE_GATE_MARGIN_RMS = 0.012; -const MIC_LEVEL_ACTIVE_RANGE_RMS = 0.11; -const MIC_MIN_ACTIVE_LEVEL = 0.18; -const MIC_LEVEL_ATTACK = 0.58; -const MIC_ACTIVE_NOISE_FLOOR_RISE = 0.006; - function isRedundantHuddlePhaseError(message: string): boolean { return /^cannot (?:start|join) huddle: already in phase /i.test(message); } -function clamp01(value: number): number { - return Math.min(1, Math.max(0, value)); -} - function interruptAgentSpeech(agentPubkey: string) { return invoke("interrupt_huddle_speech", { agentPubkey }); } const HuddleContext = React.createContext(null); +const HuddleLevelsContext = React.createContext(null); export function HuddleProvider({ children, @@ -110,7 +101,6 @@ export function HuddleProvider({ const [mirroredAudioState, setMirroredAudioState] = React.useState(null); const [mirroredMicLevel, setMirroredMicLevel] = React.useState(0); - const [micLevel, setMicLevel] = React.useState(0); const { getVoiceInputMode, pttActive, @@ -790,77 +780,7 @@ export function HuddleProvider({ usePipelineHotstart(ephemeralChannelId); // Mic level analyser — drives the voice activity indicator - React.useEffect(() => { - if (!localAudioTrack || !micConnected) { - setMicLevel(0); - return; - } - - const ctx = new AudioContext(); - const analyser = ctx.createAnalyser(); - analyser.fftSize = 512; - const source = ctx.createMediaStreamSource( - new MediaStream([localAudioTrack]), - ); - source.connect(analyser); - const buf = new Float32Array(analyser.fftSize); - - let raf = 0; - let lastUpdate = 0; - let voiceActive = false; - let noiseFloor = MIC_INITIAL_NOISE_FLOOR; - let smoothedLevel = 0; - function tick(now: number) { - raf = requestAnimationFrame(tick); - if (now - lastUpdate < MIC_ANALYSER_UPDATE_INTERVAL_MS) return; - lastUpdate = now; - analyser.getFloatTimeDomainData(buf); - - let sumSquares = 0; - for (let i = 0; i < buf.length; i += 1) { - sumSquares += buf[i] * buf[i]; - } - - const rms = Math.sqrt(sumSquares / buf.length); - const activeThreshold = Math.max( - MIC_VOICE_GATE_ON_RMS, - noiseFloor + MIC_VOICE_GATE_MARGIN_RMS, - ); - const idleThreshold = Math.max( - MIC_VOICE_GATE_OFF_RMS, - noiseFloor + MIC_VOICE_GATE_MARGIN_RMS * 0.55, - ); - voiceActive = voiceActive ? rms > idleThreshold : rms > activeThreshold; - - const floorRate = - rms < noiseFloor - ? 0.18 - : voiceActive - ? MIC_ACTIVE_NOISE_FLOOR_RISE - : 0.025; - noiseFloor += (rms - noiseFloor) * floorRate; - - if (!voiceActive) { - smoothedLevel = 0; - setMicLevel(0); - return; - } - - const normalized = clamp01( - (rms - noiseFloor) / MIC_LEVEL_ACTIVE_RANGE_RMS, - ); - const targetLevel = Math.max(normalized, MIC_MIN_ACTIVE_LEVEL); - smoothedLevel += (targetLevel - smoothedLevel) * MIC_LEVEL_ATTACK; - setMicLevel(smoothedLevel); - } - raf = requestAnimationFrame(tick); - - return () => { - cancelAnimationFrame(raf); - source.disconnect(); - void ctx.close(); - }; - }, [localAudioTrack, micConnected]); + const micLevel = useMicLevelAnalyser(localAudioTrack, micConnected); React.useEffect(() => { if (ownsAudioSession) { @@ -950,42 +870,87 @@ export function HuddleProvider({ }; }, [ownsAudioSession]); + // High-frequency (20-30 Hz) audio levels live in their own context so their + // churn re-renders only the meter components, not every useHuddle consumer. + const levelsValue = React.useMemo( + () => ({ + micLevel: ownsAudioSession ? micLevel : mirroredMicLevel, + activeSpeakers, + speakerLevels, + }), + [ + activeSpeakers, + micLevel, + mirroredMicLevel, + ownsAudioSession, + speakerLevels, + ], + ); + + const effectiveMicConnected = ownsAudioSession + ? micConnected + : (mirroredAudioState?.micConnected ?? false); + const contextValue = React.useMemo( + () => ({ + localAudioTrack, + isStarting, + huddleError, + clearHuddleError, + micConnected: effectiveMicConnected, + isMuted: effectiveIsMuted, + toggleMute, + interruptAgentSpeech, + pttActive, + voiceInputMode: effectiveVoiceInputMode, + setVoiceInputMode, + audioDevices, + selectedDeviceId, + setSelectedDeviceId, + micGain, + setMicGain, + outputDevices, + selectedOutputDevice, + setSelectedOutputDevice, + activeEphemeralChannelId: ephemeralChannelId, + showHuddleInMainApp, + viewHuddleChannel, + startHuddle, + joinHuddle, + leaveHuddle, + }), + [ + audioDevices, + clearHuddleError, + effectiveIsMuted, + effectiveMicConnected, + effectiveVoiceInputMode, + ephemeralChannelId, + huddleError, + isStarting, + joinHuddle, + leaveHuddle, + localAudioTrack, + micGain, + outputDevices, + pttActive, + selectedDeviceId, + selectedOutputDevice, + setMicGain, + setSelectedDeviceId, + setSelectedOutputDevice, + setVoiceInputMode, + showHuddleInMainApp, + startHuddle, + toggleMute, + viewHuddleChannel, + ], + ); + return ( - - {children} + + + {children} + ); } @@ -997,3 +962,16 @@ export function useHuddle(): HuddleContextValue { } return ctx; } + +/** + * High-frequency (20-30 Hz) mic/speaker levels. Consume only from components + * that render audio meters; everything else should use {@link useHuddle} so it + * is insulated from level churn. + */ +export function useHuddleLevels(): HuddleLevelsValue { + const ctx = React.useContext(HuddleLevelsContext); + if (!ctx) { + throw new Error("useHuddleLevels must be used within a HuddleProvider"); + } + return ctx; +} diff --git a/desktop/src/features/huddle/HuddleContext.types.ts b/desktop/src/features/huddle/HuddleContext.types.ts index 311366cb864..e3e9458a9d4 100644 --- a/desktop/src/features/huddle/HuddleContext.types.ts +++ b/desktop/src/features/huddle/HuddleContext.types.ts @@ -1,6 +1,17 @@ import type { AudioInputDevice } from "./lib/useAudioDevices"; import type { VoiceInputMode } from "./lib/useHuddlePttState"; +/** + * High-frequency audio-level fields, split from {@link HuddleContextValue} so + * their 20-30 Hz updates only re-render the meter components that consume + * them — not every `useHuddle()` consumer across the app. + */ +export interface HuddleLevelsValue { + micLevel: number; + activeSpeakers: string[]; + speakerLevels: Record; +} + export interface HuddleContextValue { localAudioTrack: MediaStreamTrack | null; isStarting: boolean; @@ -11,12 +22,9 @@ export interface HuddleContextValue { toggleMute: () => void; /** Interrupt this agent only if it still owns the active utterance. */ interruptAgentSpeech: (agentPubkey: string) => Promise; - micLevel: number; pttActive: boolean; voiceInputMode: VoiceInputMode; setVoiceInputMode: (mode: VoiceInputMode) => Promise; - activeSpeakers: string[]; - speakerLevels: Record; audioDevices: AudioInputDevice[]; selectedDeviceId: string; setSelectedDeviceId: (id: string) => void; diff --git a/desktop/src/features/huddle/components/HuddleBar.tsx b/desktop/src/features/huddle/components/HuddleBar.tsx index 81920e5ea94..16641f4cc34 100644 --- a/desktop/src/features/huddle/components/HuddleBar.tsx +++ b/desktop/src/features/huddle/components/HuddleBar.tsx @@ -27,7 +27,7 @@ import { Button } from "@/shared/ui/button"; import { useEmojiBurst } from "@/shared/ui/EmojiBurstProvider"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; -import { useHuddle } from "../HuddleContext"; +import { useHuddle, useHuddleLevels } from "../HuddleContext"; import { AddAgentDialog, type AgentAddResult } from "./AddAgentDialog"; import type { HuddleAgentVoiceSettings } from "./AgentVoiceMenu"; import { MicControls, SpeakerControls } from "./MicControls"; @@ -157,11 +157,8 @@ export function HuddleBar({ micConnected, isMuted, toggleMute, - micLevel, voiceInputMode, setVoiceInputMode, - activeSpeakers, - speakerLevels, huddleError, clearHuddleError, audioDevices, @@ -173,6 +170,7 @@ export function HuddleBar({ selectedOutputDevice, setSelectedOutputDevice, } = useHuddle(); + const { activeSpeakers, micLevel, speakerLevels } = useHuddleLevels(); const customEmoji = useCustomEmoji(); const identityQuery = useIdentityQuery(); const profileQuery = useProfileQuery(); diff --git a/desktop/src/features/huddle/components/HuddleProfileControl.tsx b/desktop/src/features/huddle/components/HuddleProfileControl.tsx index 6a09797ba2a..dbf3d5f6b84 100644 --- a/desktop/src/features/huddle/components/HuddleProfileControl.tsx +++ b/desktop/src/features/huddle/components/HuddleProfileControl.tsx @@ -5,7 +5,7 @@ import * as React from "react"; import type { Channel } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; -import { useHuddle } from "../HuddleContext"; +import { useHuddle, useHuddleLevels } from "../HuddleContext"; import { MicControls } from "./MicControls"; type HuddleProfileState = { @@ -42,7 +42,6 @@ export function HuddleProfileControl({ leaveHuddle, micConnected, micGain, - micLevel, selectedDeviceId, setMicGain, setSelectedDeviceId, @@ -50,6 +49,7 @@ export function HuddleProfileControl({ toggleMute, voiceInputMode, } = useHuddle(); + const { micLevel } = useHuddleLevels(); const [isLeaving, setIsLeaving] = React.useState(false); const [state, setState] = React.useState(null); const lastHuddleChannelIdRef = React.useRef(null); diff --git a/desktop/src/features/huddle/components/HuddleRoomHeader.tsx b/desktop/src/features/huddle/components/HuddleRoomHeader.tsx index 17e2bcfacd4..a356c6f2a93 100644 --- a/desktop/src/features/huddle/components/HuddleRoomHeader.tsx +++ b/desktop/src/features/huddle/components/HuddleRoomHeader.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import { useProfileQuery, useSelfProfileCache } from "@/features/profile/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; -import { useHuddle } from "../HuddleContext"; +import { useHuddle, useHuddleLevels } from "../HuddleContext"; import type { HuddleAgentVoiceSettings } from "./AgentVoiceMenu"; import { HuddleParticipantsControl } from "./ParticipantList"; @@ -28,14 +28,8 @@ function isVisible(state: HuddleRosterState | null) { /** Larger, persistent roster for the companion huddle room window. */ export function HuddleRoomHeader() { - const { - activeSpeakers, - interruptAgentSpeech, - isMuted, - micConnected, - micLevel, - speakerLevels, - } = useHuddle(); + const { interruptAgentSpeech, isMuted, micConnected } = useHuddle(); + const { activeSpeakers, micLevel, speakerLevels } = useHuddleLevels(); const identityQuery = useIdentityQuery(); const profileQuery = useProfileQuery(); const selfProfileCache = useSelfProfileCache(); diff --git a/desktop/src/features/huddle/index.ts b/desktop/src/features/huddle/index.ts index cda38e31b68..c25b4e9de78 100644 --- a/desktop/src/features/huddle/index.ts +++ b/desktop/src/features/huddle/index.ts @@ -1,4 +1,8 @@ -export { HuddleProvider, useHuddle } from "./HuddleContext"; +export { + HuddleProvider, + useHuddle, + useHuddleLevels, +} from "./HuddleContext"; export { setupAudioWorklet } from "./lib/audioWorklet"; export { HuddleBar } from "./components/HuddleBar"; export { HuddleProfileControl } from "./components/HuddleProfileControl"; diff --git a/desktop/src/features/huddle/lib/useMicLevelAnalyser.ts b/desktop/src/features/huddle/lib/useMicLevelAnalyser.ts new file mode 100644 index 00000000000..402054befae --- /dev/null +++ b/desktop/src/features/huddle/lib/useMicLevelAnalyser.ts @@ -0,0 +1,100 @@ +import * as React from "react"; + +const MIC_ANALYSER_UPDATE_INTERVAL_MS = 33; +const MIC_INITIAL_NOISE_FLOOR = 0.01; +const MIC_VOICE_GATE_ON_RMS = 0.018; +const MIC_VOICE_GATE_OFF_RMS = 0.012; +const MIC_VOICE_GATE_MARGIN_RMS = 0.012; +const MIC_LEVEL_ACTIVE_RANGE_RMS = 0.11; +const MIC_MIN_ACTIVE_LEVEL = 0.18; +const MIC_LEVEL_ATTACK = 0.58; +const MIC_ACTIVE_NOISE_FLOOR_RISE = 0.006; + +function clamp01(value: number): number { + return Math.min(1, Math.max(0, value)); +} + +/** + * Mic level analyser — drives the voice activity indicator. Emits a smoothed + * 0..1 level at up to ~30 Hz while the local track is live; 0 when idle. + */ +export function useMicLevelAnalyser( + localAudioTrack: MediaStreamTrack | null, + micConnected: boolean, +): number { + const [micLevel, setMicLevel] = React.useState(0); + + React.useEffect(() => { + if (!localAudioTrack || !micConnected) { + setMicLevel(0); + return; + } + + const ctx = new AudioContext(); + const analyser = ctx.createAnalyser(); + analyser.fftSize = 512; + const source = ctx.createMediaStreamSource( + new MediaStream([localAudioTrack]), + ); + source.connect(analyser); + const buf = new Float32Array(analyser.fftSize); + + let raf = 0; + let lastUpdate = 0; + let voiceActive = false; + let noiseFloor = MIC_INITIAL_NOISE_FLOOR; + let smoothedLevel = 0; + function tick(now: number) { + raf = requestAnimationFrame(tick); + if (now - lastUpdate < MIC_ANALYSER_UPDATE_INTERVAL_MS) return; + lastUpdate = now; + analyser.getFloatTimeDomainData(buf); + + let sumSquares = 0; + for (let i = 0; i < buf.length; i += 1) { + sumSquares += buf[i] * buf[i]; + } + + const rms = Math.sqrt(sumSquares / buf.length); + const activeThreshold = Math.max( + MIC_VOICE_GATE_ON_RMS, + noiseFloor + MIC_VOICE_GATE_MARGIN_RMS, + ); + const idleThreshold = Math.max( + MIC_VOICE_GATE_OFF_RMS, + noiseFloor + MIC_VOICE_GATE_MARGIN_RMS * 0.55, + ); + voiceActive = voiceActive ? rms > idleThreshold : rms > activeThreshold; + + const floorRate = + rms < noiseFloor + ? 0.18 + : voiceActive + ? MIC_ACTIVE_NOISE_FLOOR_RISE + : 0.025; + noiseFloor += (rms - noiseFloor) * floorRate; + + if (!voiceActive) { + smoothedLevel = 0; + setMicLevel(0); + return; + } + + const normalized = clamp01( + (rms - noiseFloor) / MIC_LEVEL_ACTIVE_RANGE_RMS, + ); + const targetLevel = Math.max(normalized, MIC_MIN_ACTIVE_LEVEL); + smoothedLevel += (targetLevel - smoothedLevel) * MIC_LEVEL_ATTACK; + setMicLevel(smoothedLevel); + } + raf = requestAnimationFrame(tick); + + return () => { + cancelAnimationFrame(raf); + source.disconnect(); + void ctx.close(); + }; + }, [localAudioTrack, micConnected]); + + return micLevel; +} diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index e595c30323d..8b457a7adf8 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -450,6 +450,7 @@ export function useSendMessageMutation( mentionPubkeys?: string[]; parentEventId?: string | null; mediaTags?: string[][]; + forceRest?: boolean; sentFromThreadRootId?: string | null; sentFromThreadRootExcerpt?: string | null; transport?: "auto" | "http"; @@ -463,6 +464,7 @@ export function useSendMessageMutation( mentionPubkeys, parentEventId, mediaTags, + forceRest, sentFromThreadRootId, sentFromThreadRootExcerpt, transport = "auto", @@ -525,6 +527,7 @@ export function useSendMessageMutation( // the relay's tag validation runs. The WebSocket path emits no extra // tags, so emoji-only messages would otherwise lose their emoji tag. if ( + forceRest || transport === "http" || parentEventId || imetaTags.length > 0 || diff --git a/desktop/src/features/messages/lib/backgroundMediaUploadStore.test.mjs b/desktop/src/features/messages/lib/backgroundMediaUploadStore.test.mjs new file mode 100644 index 00000000000..2a4366be5a8 --- /dev/null +++ b/desktop/src/features/messages/lib/backgroundMediaUploadStore.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + cancelStartedMediaUploads, + dispatchTrackedMediaUpload, +} from "./backgroundMediaUploadStore.ts"; + +const descriptor = { + url: "https://relay.example/media/file.bin", + sha256: "a".repeat(64), + size: 1, + type: "application/octet-stream", + uploaded: 0, +}; + +function deferred() { + let resolve; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +test("cancels only uploads whose native commands were dispatched", async () => { + const releaseUpload = deferred(); + const startedProgressIds = new Map(); + const cancelled = []; + const dispatched = []; + const released = []; + const upload = async (_file, id, _signal, onDispatch) => { + dispatched.push(id); + onDispatch(); + await releaseUpload.promise; + return descriptor; + }; + const ids = Array.from({ length: 129 }, (_, index) => `attachment-${index}`); + + const uploadPromise = dispatchTrackedMediaUpload( + {}, + ids[0], + new AbortController().signal, + startedProgressIds, + upload, + async (id) => released.push(id), + ); + await Promise.resolve(); + + cancelStartedMediaUploads(startedProgressIds, async (id) => { + cancelled.push(id); + }); + + assert.deepEqual(dispatched, [ids[0]]); + assert.deepEqual(cancelled, [ids[0]]); + assert.equal(startedProgressIds.size, 1); + + releaseUpload.resolve(); + await uploadPromise; + assert.equal(startedProgressIds.size, 0); + assert.deepEqual(released, [ids[0]]); +}); + +test("releases ownership when dispatch rejects", async () => { + const startedProgressIds = new Map(); + const released = []; + + await assert.rejects( + dispatchTrackedMediaUpload( + {}, + "rejected", + new AbortController().signal, + startedProgressIds, + async (_file, id, _signal, onDispatch) => { + onDispatch(); + throw new Error(`rejected ${id}`); + }, + async (id) => released.push(id), + ), + /rejected rejected/, + ); + + assert.equal(startedProgressIds.size, 0); + assert.deepEqual(released, ["rejected"]); +}); + +test("waits for cancellation before releasing renderer ownership", async () => { + const releaseUpload = deferred(); + const releaseCancellation = deferred(); + const startedProgressIds = new Map(); + const events = []; + const uploadPromise = dispatchTrackedMediaUpload( + {}, + "ordered", + new AbortController().signal, + startedProgressIds, + async (_file, _id, _signal, onDispatch) => { + onDispatch(); + await releaseUpload.promise; + return descriptor; + }, + async () => events.push("release"), + ); + await Promise.resolve(); + + cancelStartedMediaUploads(startedProgressIds, async () => { + events.push("cancel-start"); + await releaseCancellation.promise; + events.push("cancel-finish"); + }); + releaseUpload.resolve(); + await Promise.resolve(); + assert.deepEqual(events, ["cancel-start"]); + + releaseCancellation.resolve(); + await uploadPromise; + assert.deepEqual(events, ["cancel-start", "cancel-finish", "release"]); +}); diff --git a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts index ace711985ad..8066926067a 100644 --- a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts +++ b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts @@ -1,7 +1,11 @@ import * as React from "react"; import type { BlobDescriptor } from "@/shared/api/tauri"; -import { cancelMediaUpload, uploadMediaFile } from "@/shared/api/tauriMedia"; +import { + cancelMediaUpload, + releaseMediaUpload, + uploadMediaFile, +} from "@/shared/api/tauriMedia"; import { type BackgroundMediaUploadPhase, isNativeMediaUploadPhase, @@ -23,6 +27,7 @@ type BackgroundUploadTask = { id: number; isCompleting: boolean; onCancel?: () => void; + startedProgressIds: Map | null>; }; type BackgroundUploadSnapshot = { @@ -180,12 +185,43 @@ function cancelTask( task.canceled = true; task.abortController.abort(); if (notify) task.onCancel?.(); - for (let index = 0; index < task.fileProgress.length; index += 1) { - void cancelMediaUpload(progressId(task.id, index)).catch(() => undefined); - } + cancelStartedMediaUploads(task.startedProgressIds); finishTask(task.id); } +export function cancelStartedMediaUploads( + startedProgressIds: Map | null>, + cancel: (progressId: string) => Promise = cancelMediaUpload, +): void { + for (const [id, cancellation] of startedProgressIds) { + if (cancellation) continue; + startedProgressIds.set( + id, + cancel(id).catch(() => undefined), + ); + } +} + +export async function dispatchTrackedMediaUpload( + file: File, + id: string, + signal: AbortSignal, + startedProgressIds: Map | null>, + upload: typeof uploadMediaFile = uploadMediaFile, + release: (progressId: string) => Promise = releaseMediaUpload, +): Promise { + try { + return await upload(file, id, signal, () => + startedProgressIds.set(id, null), + ); + } finally { + const cancellation = startedProgressIds.get(id); + startedProgressIds.delete(id); + if (cancellation) await cancellation; + await release(id).catch(() => undefined); + } +} + function yieldForUploadFeedback(): Promise { if ( typeof window === "undefined" || @@ -228,6 +264,7 @@ export function prepareBackgroundMediaUpload( })), id: taskId, isCompleting: false, + startedProgressIds: new Map(), }; let started = false; tasks.set(taskId, task); @@ -252,10 +289,12 @@ export function prepareBackgroundMediaUpload( for (let index = 0; index < attachments.length; index += 1) { if (task.canceled) return; const attachment = attachments[index]; - const descriptor = await uploadMediaFile( + const id = progressId(taskId, index); + const descriptor = await dispatchTrackedMediaUpload( attachment.file, - progressId(taskId, index), + id, task.abortController.signal, + task.startedProgressIds, ); if (task.canceled) return; task.filePhases[index] = "finishing"; diff --git a/desktop/src/features/messages/lib/channelLink.test.mjs b/desktop/src/features/messages/lib/channelLink.test.mjs new file mode 100644 index 00000000000..7852bfab389 --- /dev/null +++ b/desktop/src/features/messages/lib/channelLink.test.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isChannelLink, parseChannelLink } from "./channelLink.ts"; + +const CHANNEL_ID = "580ca78b-9dae-46f3-8854-bd671853ba32"; +const MESSAGE_ID = + "8455293f0123456789abcdef0123456789abcdef0123456789abcdef01234567"; + +test("parseChannelLink accepts the canonical channel path", () => { + assert.deepEqual(parseChannelLink(`buzz://channel/${CHANNEL_ID}`), { + ok: true, + value: { channelId: CHANNEL_ID }, + }); +}); + +test("parseChannelLink accepts a channel message path", () => { + assert.deepEqual( + parseChannelLink(`buzz://channel/${CHANNEL_ID}/${MESSAGE_ID}`), + { + ok: true, + value: { channelId: CHANNEL_ID, messageId: MESSAGE_ID }, + }, + ); +}); + +test("parseChannelLink accepts v7 and canonicalizes uppercase UUIDs", () => { + assert.deepEqual( + parseChannelLink("buzz://channel/018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9"), + { + ok: true, + value: { channelId: "018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9" }, + }, + ); + assert.deepEqual( + parseChannelLink("buzz://channel/580CA78B-9DAE-46F3-8854-BD671853BA32"), + { + ok: true, + value: { channelId: "580ca78b-9dae-46f3-8854-bd671853ba32" }, + }, + ); +}); + +test("parseChannelLink rejects malformed channel links", () => { + for (const href of [ + "buzz://channel", + "buzz://channel/", + "buzz://channel/one/two", + `buzz://channel/${CHANNEL_ID}/not-hex`, + `buzz://channel/${CHANNEL_ID}/${"a".repeat(63)}`, + `buzz://channel/${CHANNEL_ID}/${MESSAGE_ID}/extra`, + `buzz://channel/${CHANNEL_ID}/`, + "buzz://channel/one?extra=true", + "buzz://channel/one#fragment", + "https://channel/one", + "buzz://channel/not-a-uuid", + "buzz://channel/%", + "buzz://channel/%ZZ", + "buzz://channel/%2F", + "buzz://channel/%00", + ]) { + assert.equal(parseChannelLink(href).ok, false, href); + } +}); + +test("isChannelLink recognizes only a valid canonical link", () => { + assert.equal( + isChannelLink("buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32"), + true, + ); + assert.equal( + isChannelLink("buzz://message?channel=channel-1&id=message-1"), + false, + ); +}); diff --git a/desktop/src/features/messages/lib/channelLink.ts b/desktop/src/features/messages/lib/channelLink.ts new file mode 100644 index 00000000000..08281697ea3 --- /dev/null +++ b/desktop/src/features/messages/lib/channelLink.ts @@ -0,0 +1,70 @@ +/** `buzz://channel/[/]` link encoding and parsing. */ + +const CHANNEL_LINK_SCHEME = "buzz:"; +const CHANNEL_LINK_HOST = "channel"; +const CHANNEL_UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; +const EVENT_ID_PATTERN = /^[0-9a-f]{64}$/iu; + +export type ParsedChannelLink = { + channelId: string; + messageId?: string; +}; + +export type ChannelLinkParseResult = + | { ok: true; value: ParsedChannelLink } + | { ok: false; reason: string }; + +export function buildChannelLink(channelId: string): string { + if (!channelId) { + throw new Error("buildChannelLink: channelId is required"); + } + return `${CHANNEL_LINK_SCHEME}//${CHANNEL_LINK_HOST}/${encodeURIComponent(channelId)}`; +} + +export function parseChannelLink(url: string): ChannelLinkParseResult { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { ok: false, reason: "invalid-url" }; + } + if (parsed.protocol !== CHANNEL_LINK_SCHEME) { + return { ok: false, reason: "wrong-scheme" }; + } + if (parsed.hostname !== CHANNEL_LINK_HOST) { + return { ok: false, reason: "wrong-host" }; + } + if (parsed.search || parsed.hash || parsed.username || parsed.password) { + return { ok: false, reason: "unexpected-components" }; + } + const segments = parsed.pathname.split("/").slice(1); + if (segments.length < 1 || segments.length > 2 || segments.includes("")) { + return { ok: false, reason: "missing-or-extra-channel" }; + } + let channelId: string; + let messageId: string | null = null; + try { + channelId = decodeURIComponent(segments[0]); + messageId = segments[1] ? decodeURIComponent(segments[1]) : null; + } catch { + return { ok: false, reason: "invalid-channel-encoding" }; + } + if (!CHANNEL_UUID_PATTERN.test(channelId)) { + return { ok: false, reason: "invalid-channel-uuid" }; + } + if (messageId !== null && !EVENT_ID_PATTERN.test(messageId)) { + return { ok: false, reason: "invalid-message-id" }; + } + return { + ok: true, + value: { + channelId: channelId.toLowerCase(), + ...(messageId ? { messageId: messageId.toLowerCase() } : {}), + }, + }; +} + +export function isChannelLink(href: string | undefined | null): boolean { + return href ? parseChannelLink(href).ok : false; +} diff --git a/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs b/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs index 867f37677da..5aee675592e 100644 --- a/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs +++ b/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs @@ -3,6 +3,7 @@ import { createRequire } from "node:module"; import test from "node:test"; import { + ComposerMessageLinkNode, registerComposerMessageLinkMarkdownIt, resolveComposerMessageLinkAttributes, } from "./composerMessageLinkNode.ts"; @@ -13,6 +14,13 @@ const MarkdownIt = requireFromTiptap("markdown-it"); const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; const MESSAGE_ID = "root-event"; const HREF = `buzz://message?channel=${CHANNEL_ID}&id=${MESSAGE_ID}`; +const CHANNEL_HREF = `buzz://channel/${CHANNEL_ID}`; +const CHANNEL_MESSAGE_ID = "a".repeat(64); +const CHANNEL_MESSAGE_HREF = `buzz://channel/${CHANNEL_ID}/${CHANNEL_MESSAGE_ID}`; +const OWNER = "a".repeat(64); +const REPO_HREF = `buzz://repo?owner=${OWNER}&d=buzz-world`; +const ISSUE_ID = "b".repeat(64); +const ISSUE_HREF = `buzz://issue?id=${ISSUE_ID}&owner=${OWNER}&d=buzz-world`; test("resolves a composer preview and canonicalizes the underlying href", () => { assert.deepEqual( @@ -34,6 +42,32 @@ test("rejects malformed message links", () => { ); }); +test("resolves channel and entity links as composer chips", () => { + assert.deepEqual( + resolveComposerMessageLinkAttributes(CHANNEL_HREF, (channelId) => + channelId === CHANNEL_ID ? "general" : undefined, + ), + { channelName: "general", href: CHANNEL_HREF }, + ); + assert.deepEqual( + resolveComposerMessageLinkAttributes(CHANNEL_MESSAGE_HREF, (channelId) => + channelId === CHANNEL_ID ? "general" : undefined, + ), + { + channelName: "general", + href: `buzz://message?channel=${CHANNEL_ID}&id=${CHANNEL_MESSAGE_ID}`, + }, + ); + assert.deepEqual( + resolveComposerMessageLinkAttributes(REPO_HREF, () => undefined), + { channelName: "", href: REPO_HREF }, + ); + assert.deepEqual( + resolveComposerMessageLinkAttributes(ISSUE_HREF, () => undefined), + { channelName: "", href: ISSUE_HREF }, + ); +}); + function captureMarkdownRule() { let capturedAnchor = null; let capturedRule = null; @@ -84,11 +118,38 @@ test("real markdown-it parsing materializes a restored message link", () => { }); const html = md.renderInline(`See ${HREF}.`); - assert.match(html, /See { + const md = new MarkdownIt(); + registerComposerMessageLinkMarkdownIt(md, { + resolveChannelName: (channelId) => + channelId === CHANNEL_ID ? "general" : undefined, + }); + + const html = md.renderInline(`${HREF} ${CHANNEL_HREF} ${REPO_HREF}`); + assert.equal((html.match(/data-composer-buzz-link=""/g) ?? []).length, 3); + assert.match(html, /data-href="buzz:\/\/channel\/9a1657ac/); + assert.match(html, /data-href="buzz:\/\/repo\?owner=a{64}&d=buzz-world/); +}); + +test("real markdown-it parsing preserves underscores in restored entity links", () => { + const md = new MarkdownIt(); + registerComposerMessageLinkMarkdownIt(md, { + resolveChannelName: () => undefined, + }); + const href = `buzz://repo?owner=${OWNER}&d=my_repo`; + + const html = md.renderInline(href); + + assert.equal((html.match(/data-composer-buzz-link=""/g) ?? []).length, 1); + assert.match(html, /data-href="buzz:\/\/repo\?owner=a{64}&d=my_repo"/); + assert.doesNotMatch(html, /<\/span>_repo/); +}); + test("markdown parsing resumes after markdown-it consumes the buzz prefix", () => { const { rule } = captureMarkdownRule(); let token = null; @@ -125,12 +186,62 @@ test("markdown parsing stops message links before emphasis delimiters", () => { assert.deepEqual(token.meta, { channelName: "general", href: HREF }); }); +test("composer node uses the sent-message chip presentation", () => { + const node = { + attrs: { channelName: "general", href: HREF }, + }; + const rendered = globalThis.structuredClone( + // TipTap invokes renderHTML with the extension instance as `this`. + // Exercise the production renderer directly so the composer and message + // list cannot silently drift back to separate visual languages. + ComposerMessageLinkNode.config.renderHTML.call( + { options: { resolveChannelName: () => "general" } }, + { HTMLAttributes: {}, node }, + ), + ); + + assert.equal(rendered[0], "span"); + assert.match(rendered[1].class, /mention-chip/); + assert.match(rendered[1].class, /inline-chip-with-icon/); + assert.match(rendered[1].class, /inline-chip-icon-message/); + assert.equal(rendered[1]["data-buzz-link"], ""); + assert.equal(rendered[2], "general · root-eve"); +}); + +test("composer node renders channel and entity chip presentations", () => { + const render = (href) => + globalThis.structuredClone( + ComposerMessageLinkNode.config.renderHTML.call( + { options: { resolveChannelName: () => "general" } }, + { + HTMLAttributes: {}, + node: { attrs: { channelName: "general", href } }, + }, + ), + ); + + const channel = render(CHANNEL_HREF); + assert.equal(channel[1]["data-channel-deep-link"], ""); + assert.match(channel[1].class, /inline-chip-icon-channel/); + assert.equal(channel[2], "general"); + + const repo = render(REPO_HREF); + assert.equal(repo[1]["data-buzz-link-kind"], "repo"); + assert.match(repo[1].class, /inline-chip-icon-repo/); + assert.equal(repo[2], "buzz-world"); + + const issue = render(ISSUE_HREF); + assert.equal(issue[1]["data-buzz-link-kind"], "issue"); + assert.match(issue[1].class, /inline-chip-icon-issue/); + assert.equal(issue[2], "buzz-world · bbbbbbbb"); +}); + test("markdown rendering stores identity in attributes, not visible id text", () => { const { md } = captureMarkdownRule(); const render = md.renderer.rules.buzz_composer_message_link; const html = render([{ meta: { channelName: "general", href: HREF } }], 0); - assert.match(html, /data-composer-message-link=""/); + assert.match(html, /data-composer-buzz-link=""/); assert.match(html, /data-channel-name="general"/); assert.match(html, /data-href="buzz:\/\/message\?channel=.*&id=/); assert.doesNotMatch(html, />[^<]*root-event/); diff --git a/desktop/src/features/messages/lib/composerMessageLinkNode.ts b/desktop/src/features/messages/lib/composerMessageLinkNode.ts index 5431e2c9605..a01109e010d 100644 --- a/desktop/src/features/messages/lib/composerMessageLinkNode.ts +++ b/desktop/src/features/messages/lib/composerMessageLinkNode.ts @@ -3,12 +3,20 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { TextSelection } from "@tiptap/pm/state"; import type { EditorView } from "@tiptap/pm/view"; -import { MENTION_CHIP_BASE_CLASSES } from "@/shared/ui/mentionChip"; import { - getMessageLinkChannelLabel, - getMessageLinkLabel, - MESSAGE_LINK_PREFIX, -} from "./messageLinkLabel"; + buildIssueLink, + buildProjectLink, + buildPullRequestLink, + buildRepoLink, + parseEntityLink, +} from "@/shared/lib/entityLink"; +import { + inlineChipIconClasses, + type InlineChipIconKind, + MENTION_CHIP_BASE_CLASSES, +} from "@/shared/ui/mentionChip"; +import { buildChannelLink, parseChannelLink } from "./channelLink"; +import { getMessageLinkLabel } from "./messageLinkLabel"; import { buildMessageLink, parseMessageLink } from "./messageLink"; export const COMPOSER_MESSAGE_LINK_NODE_NAME = "composerMessageLink"; @@ -22,10 +30,13 @@ export type ComposerMessageLinkAttributes = { href: string; }; -const BARE_MESSAGE_LINK_AT_START = /^(?:buzz):\/\/message\?[^\s<>"')\]}*_]+/i; +const BARE_BUZZ_LINK_AT_START = + /^buzz:\/\/(?:message\?|channel\/|(?:pr|issue|repo|project)\?)[^\s<>"')\]}*]+/i; +const BUZZ_LINK_SUFFIX_AT_START = + /^:\/\/(?:message\?|channel\/|(?:pr|issue|repo|project)\?)[^\s<>"')\]}*]+/i; const TRAILING_PUNCTUATION = /[.,;:!?]+$/; -function trimBareMessageLink(value: string): string { +function trimBareBuzzLink(value: string): string { let trimmed = value.replace(TRAILING_PUNCTUATION, ""); while (/[)\]]$/.test(trimmed)) { const closing = trimmed.at(-1) ?? ""; @@ -40,23 +51,66 @@ export function resolveComposerMessageLinkAttributes( href: string, resolveChannelName: ComposerMessageLinkNodeOptions["resolveChannelName"], ): ComposerMessageLinkAttributes | null { - const parsed = parseMessageLink(href); - if (!parsed.ok) return null; - return { - channelName: resolveChannelName(parsed.value.channelId) ?? "", - href: buildMessageLink({ - channelId: parsed.value.channelId, - messageId: parsed.value.messageId, - threadRootId: parsed.value.threadRootId, - }), - }; + const message = parseMessageLink(href); + if (message.ok) { + return { + channelName: resolveChannelName(message.value.channelId) ?? "", + href: buildMessageLink({ + channelId: message.value.channelId, + messageId: message.value.messageId, + threadRootId: message.value.threadRootId, + }), + }; + } + + const channel = parseChannelLink(href); + if (channel.ok) { + return { + channelName: resolveChannelName(channel.value.channelId) ?? "", + href: channel.value.messageId + ? buildMessageLink({ + channelId: channel.value.channelId, + messageId: channel.value.messageId, + }) + : buildChannelLink(channel.value.channelId), + }; + } + + const entity = parseEntityLink(href); + if (!entity.ok) return null; + switch (entity.value.type) { + case "repo": + return { + channelName: "", + href: buildRepoLink(entity.value), + }; + case "project": + return { + channelName: "", + href: buildProjectLink(entity.value), + }; + case "pr": + return { + channelName: "", + href: buildPullRequestLink(entity.value), + }; + case "issue": + return { + channelName: "", + href: buildIssueLink(entity.value), + }; + } } -function unwrapExactMessageLink(text: string): string | null { +function unwrapExactBuzzLink(text: string): string | null { const href = text.startsWith("<") && text.endsWith(">") ? text.slice(1, -1) : text; if (!href || /\s/.test(href)) return null; - return parseMessageLink(href).ok ? href : null; + return parseMessageLink(href).ok || + parseChannelLink(href).ok || + parseEntityLink(href).ok + ? href + : null; } function unwrapExactHttpLink(text: string): string | null { @@ -83,16 +137,16 @@ export function createComposerLinkPasteHandler( ) { return (view: EditorView, event: ClipboardEvent): boolean => { const text = event.clipboardData?.getData("text/plain") ?? ""; - const messageHref = unwrapExactMessageLink(text); - const messageLinkType = + const buzzHref = unwrapExactBuzzLink(text); + const buzzLinkType = view.state.schema.nodes[COMPOSER_MESSAGE_LINK_NODE_NAME]; - if (messageHref && messageLinkType) { + if (buzzHref && buzzLinkType) { const attrs = resolveComposerMessageLinkAttributes( - messageHref, + buzzHref, resolveChannelName, ); if (attrs) { - replaceSelectionWithNode(view, messageLinkType.create(attrs)); + replaceSelectionWithNode(view, buzzLinkType.create(attrs)); event.preventDefault(); return true; } @@ -122,14 +176,14 @@ export function registerComposerMessageLinkMarkdownIt( // biome-ignore lint/suspicious/noExplicitAny: markdown-it state/silent const rule = (state: any, silent: boolean): boolean => { const remaining = state.src.slice(state.pos); - const fullMatch = BARE_MESSAGE_LINK_AT_START.exec(remaining); - const suffixMatch = /^:\/\/message\?[^\s<>"')\]}*_]+/i.exec(remaining); + const fullMatch = BARE_BUZZ_LINK_AT_START.exec(remaining); + const suffixMatch = BUZZ_LINK_SUFFIX_AT_START.exec(remaining); const resumesTextToken = !fullMatch && suffixMatch && /buzz$/i.test(state.pending ?? ""); const rawHref = fullMatch?.[0] ?? (resumesTextToken ? `buzz${suffixMatch[0]}` : null); if (!rawHref) return false; - const href = trimBareMessageLink(rawHref); + const href = trimBareBuzzLink(rawHref); const attrs = resolveComposerMessageLinkAttributes( href, options.resolveChannelName, @@ -149,7 +203,83 @@ export function registerComposerMessageLinkMarkdownIt( md.renderer.rules[tokenType] = (tokens: any[], index: number): string => { const attrs = tokens[index].meta as ComposerMessageLinkAttributes; const escapeHtml = md.utils.escapeHtml; - return ``; + return ``; + }; +} + +type ComposerLinkPresentation = { + ariaLabel: string; + channelName: string; + dataAttributes: Record; + icon: InlineChipIconKind; + label: string; +}; + +function composerLinkPresentation( + href: string, + channelName: string, + resolveChannelName: ComposerMessageLinkNodeOptions["resolveChannelName"], +): ComposerLinkPresentation { + const message = parseMessageLink(href); + if (message.ok) { + const resolvedChannelName = + resolveChannelName(message.value.channelId) || channelName || "channel"; + return { + ariaLabel: getMessageLinkLabel({ channelName: resolvedChannelName }), + channelName: resolvedChannelName, + dataAttributes: { + "data-composer-message-link": "", + "data-message-link": "", + }, + icon: "message", + label: `${resolvedChannelName} · ${message.value.messageId.slice(0, 8)}`, + }; + } + + const channel = parseChannelLink(href); + if (channel.ok) { + const resolvedChannelName = + resolveChannelName(channel.value.channelId) || + channelName || + channel.value.channelId.slice(0, 8); + return { + ariaLabel: `Open channel ${resolvedChannelName}`, + channelName: resolvedChannelName, + dataAttributes: { "data-channel-deep-link": "" }, + icon: "channel", + label: resolvedChannelName, + }; + } + + const entity = parseEntityLink(href); + if (!entity.ok) { + return { + ariaLabel: "Buzz link", + channelName: "", + dataAttributes: {}, + icon: "message", + label: "Buzz link", + }; + } + + const shortId = + entity.value.type === "repo" || entity.value.type === "project" + ? "" + : entity.value.id.slice(0, 8); + return { + ariaLabel: + entity.value.type === "repo" + ? `Open repository ${entity.value.dtag}` + : entity.value.type === "project" + ? `Open project ${entity.value.dtag}` + : `Open ${entity.value.type === "pr" ? "pull request" : "issue"} ${shortId} in repository ${entity.value.dtag}`, + channelName: "", + dataAttributes: { "data-buzz-link-kind": entity.value.type }, + icon: entity.value.type, + label: + entity.value.type === "repo" || entity.value.type === "project" + ? entity.value.dtag + : `${entity.value.dtag} · ${shortId}`, }; } @@ -183,39 +313,32 @@ export const ComposerMessageLinkNode = }, parseHTML() { - return [{ tag: "span[data-composer-message-link]" }]; + return [ + { tag: "span[data-composer-buzz-link]" }, + { tag: "span[data-composer-message-link]" }, + ]; }, renderHTML({ node, HTMLAttributes }) { const href = String(node.attrs.href ?? ""); - const parsed = parseMessageLink(href); - const channelName = parsed.ok - ? (this.options.resolveChannelName(parsed.value.channelId) ?? - (String(node.attrs.channelName ?? "") || "channel")) - : "channel"; - const label = getMessageLinkLabel({ channelName }); - const channelLinkLabel = getMessageLinkChannelLabel(channelName); + const presentation = composerLinkPresentation( + href, + String(node.attrs.channelName ?? ""), + this.options.resolveChannelName, + ); return [ "span", mergeAttributes(HTMLAttributes, { - "aria-label": label, - class: - "inline-flex min-w-0 max-w-80 items-center gap-1.5 align-baseline", - "data-channel-name": channelName, - "data-composer-message-link": "", + "aria-label": presentation.ariaLabel, + class: `${MENTION_CHIP_BASE_CLASSES} ${inlineChipIconClasses(presentation.icon)} cursor-text`, + "data-buzz-link": "", + "data-channel-name": presentation.channelName, + "data-composer-buzz-link": "", "data-href": href, - "data-message-link": "", - title: label, + ...presentation.dataAttributes, + title: presentation.ariaLabel, }), - ["span", { class: "shrink-0" }, MESSAGE_LINK_PREFIX], - [ - "span", - { - class: `${MENTION_CHIP_BASE_CLASSES} min-w-0 max-w-full truncate`, - "data-channel-link": "", - }, - channelLinkLabel, - ], + presentation.label, ]; }, diff --git a/desktop/src/features/messages/lib/dateFormatters.test.mjs b/desktop/src/features/messages/lib/dateFormatters.test.mjs index f579cbfcf66..138851b163c 100644 --- a/desktop/src/features/messages/lib/dateFormatters.test.mjs +++ b/desktop/src/features/messages/lib/dateFormatters.test.mjs @@ -2,8 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - formatDayHeading, - formatShortMonthDayOrdinal, + formatShortMonthDay, formatThreadSummaryLastReplyTime, formatTimeWithoutDayPeriod, startOfLocalDaySeconds, @@ -13,66 +12,16 @@ function localUnixSeconds(year, monthIndex, day) { return new Date(year, monthIndex, day, 12).getTime() / 1_000; } -function weekday(date) { - return new Intl.DateTimeFormat("en-US", { weekday: "long" }).format(date); -} - -function month(date) { - return new Intl.DateTimeFormat("en-US", { month: "long" }).format(date); -} - -test("formatShortMonthDayOrdinal formats month before ordinal day", () => { - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 19)), - "May 19th", - ); +test("formatShortMonthDay abbreviates the month and omits the ordinal", () => { + assert.equal(formatShortMonthDay(localUnixSeconds(2026, 4, 19)), "May 19"); + assert.equal(formatShortMonthDay(localUnixSeconds(2026, 4, 1)), "May 1"); }); -test("formatShortMonthDayOrdinal handles ordinal suffixes", () => { - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 1)), - "May 1st", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 2)), - "May 2nd", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 3)), - "May 3rd", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 4)), - "May 4th", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 11)), - "May 11th", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 12)), - "May 12th", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 13)), - "May 13th", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 21)), - "May 21st", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 22)), - "May 22nd", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 23)), - "May 23rd", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 31)), - "May 31st", - ); +test("no day carries an ordinal suffix", () => { + for (const day of [1, 2, 3, 4, 11, 12, 13, 21, 22, 23, 31]) { + const label = formatShortMonthDay(localUnixSeconds(2026, 4, day)); + assert.doesNotMatch(label, /\d(?:st|nd|rd|th)\b/, `ordinal in "${label}"`); + } }); test("formatTimeWithoutDayPeriod removes AM/PM suffixes", () => { @@ -108,31 +57,11 @@ test("formatThreadSummaryLastReplyTime expands relative units", () => { ); }); -test("formatThreadSummaryLastReplyTime uses ordinal dates for older replies", () => { +test("formatThreadSummaryLastReplyTime dates older replies without an ordinal", () => { const now = localUnixSeconds(2026, 5, 15); const replyAt = localUnixSeconds(2026, 4, 19); - assert.equal(formatThreadSummaryLastReplyTime(replyAt, now), "on May 19th"); -}); - -test("formatDayHeading omits the year for current-year dates", () => { - const now = new Date(); - const date = new Date(now.getFullYear(), (now.getMonth() + 6) % 12, 19, 12); - - assert.equal( - formatDayHeading(date.getTime() / 1_000), - `${weekday(date)}, ${month(date)} 19th`, - ); -}); - -test("formatDayHeading includes the year for other years", () => { - const year = new Date().getFullYear() - 1; - const date = new Date(year, 4, 19, 12); - - assert.equal( - formatDayHeading(date.getTime() / 1_000), - `${weekday(date)}, May 19th, ${year}`, - ); + assert.equal(formatThreadSummaryLastReplyTime(replyAt, now), "on May 19"); }); test("startOfLocalDaySeconds collapses a day's timestamps to one value", () => { diff --git a/desktop/src/features/messages/lib/dateFormatters.ts b/desktop/src/features/messages/lib/dateFormatters.ts index 04c85d81506..f752bdfd204 100644 --- a/desktop/src/features/messages/lib/dateFormatters.ts +++ b/desktop/src/features/messages/lib/dateFormatters.ts @@ -4,9 +4,17 @@ * - `formatTime` — short clock time ("2:34 PM"), used in message rows. * - `formatFullDateTime` — verbose string for tooltips * ("Wednesday, April 2, 2026 at 2:34 PM"). - * - `formatDayHeading` — label for day dividers / sticky headers. - * Returns "Today", "Yesterday", or a date like "Monday, March 31st". * - `isSameDay` — compare two unix-second timestamps. + * + * Relative labels ("Today", "Yesterday", "June 20", "Yesterday at 9:05 AM") are + * not here: chat and the Inbox share them from `shared/lib/datetime.ts`. What + * stays in this file is the absolute end of the range — a bare clock time, the + * verbose tooltip string, and same-day comparison. + * + * `formatTime` is for places with only enough room for a clock: the hover gutter + * that replaces the avatar on continuation rows. A message header uses the + * relative ladder instead, because the day divider that supplies its date + * scrolls away while the messages under it stay on screen. */ const TIME_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -25,16 +33,9 @@ const FULL_DATE_TIME_FORMATTER = new Intl.DateTimeFormat("en-US", { minute: "2-digit", }); -const WEEKDAY_FORMATTER = new Intl.DateTimeFormat("en-US", { - weekday: "long", -}); - -const LONG_MONTH_FORMATTER = new Intl.DateTimeFormat("en-US", { - month: "long", -}); - -const SHORT_MONTH_FORMATTER = new Intl.DateTimeFormat("en-US", { +const SHORT_MONTH_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { month: "short", + day: "numeric", }); /** Short clock time, e.g. "2:34 PM". */ @@ -52,34 +53,6 @@ export function formatFullDateTime(unixSeconds: number): string { return FULL_DATE_TIME_FORMATTER.format(new Date(unixSeconds * 1_000)); } -/** - * Human-friendly day label for dividers and sticky headers. - * Returns "Today", "Yesterday", a current-year date like "Monday, March 31st", - * or a prior-year date like "Monday, March 31st, 2025". - */ -export function formatDayHeading(unixSeconds: number): string { - const date = new Date(unixSeconds * 1_000); - const now = new Date(); - - if (isSameDayDate(date, now)) { - return "Today"; - } - - const yesterday = new Date(now); - yesterday.setDate(yesterday.getDate() - 1); - if (isSameDayDate(date, yesterday)) { - return "Yesterday"; - } - - const dateLabel = `${WEEKDAY_FORMATTER.format(date)}, ${formatMonthDayOrdinal( - date, - LONG_MONTH_FORMATTER, - )}`; - return date.getFullYear() === now.getFullYear() - ? dateLabel - : `${dateLabel}, ${date.getFullYear()}`; -} - /** True when two unix-second timestamps fall on the same calendar day (local time). */ export function isSameDay(a: number, b: number): boolean { return isSameDayDate(new Date(a * 1_000), new Date(b * 1_000)); @@ -97,17 +70,14 @@ export function startOfLocalDaySeconds(unixSeconds: number): number { return Math.floor(date.getTime() / 1_000); } -/** Short month + ordinal day, e.g. "May 19th". */ -export function formatShortMonthDayOrdinal(unixSeconds: number): string { - return formatMonthDayOrdinal( - new Date(unixSeconds * 1_000), - SHORT_MONTH_FORMATTER, - ); +/** Short month + day, e.g. "May 19". No ordinal suffix, per the writing standard. */ +export function formatShortMonthDay(unixSeconds: number): string { + return SHORT_MONTH_DAY_FORMATTER.format(new Date(unixSeconds * 1_000)); } /** * Relative thread-summary timestamp with expanded units, e.g. "3 hours ago", - * falling back to "on May 19th" for older replies. + * falling back to "on May 19" for older replies. */ export function formatThreadSummaryLastReplyTime( unixSeconds: number, @@ -120,7 +90,7 @@ export function formatThreadSummaryLastReplyTime( if (diff < 86_400) return formatAgo(Math.floor(diff / 3_600), "hour"); if (diff < 604_800) return formatAgo(Math.floor(diff / 86_400), "day"); - return `on ${formatShortMonthDayOrdinal(unixSeconds)}`; + return `on ${formatShortMonthDay(unixSeconds)}`; } function isSameDayDate(a: Date, b: Date): boolean { @@ -131,33 +101,6 @@ function isSameDayDate(a: Date, b: Date): boolean { ); } -function formatMonthDayOrdinal( - date: Date, - monthFormatter: Intl.DateTimeFormat, -): string { - return `${monthFormatter.format(date)} ${date.getDate()}${ordinalSuffix( - date.getDate(), - )}`; -} - function formatAgo(value: number, unit: string): string { return `${value} ${unit}${value === 1 ? "" : "s"} ago`; } - -function ordinalSuffix(day: number): string { - const lastTwoDigits = day % 100; - if (lastTwoDigits >= 11 && lastTwoDigits <= 13) { - return "th"; - } - - switch (day % 10) { - case 1: - return "st"; - case 2: - return "nd"; - case 3: - return "rd"; - default: - return "th"; - } -} diff --git a/desktop/src/features/messages/lib/linkPreviewPreparationStore.test.mjs b/desktop/src/features/messages/lib/linkPreviewPreparationStore.test.mjs new file mode 100644 index 00000000000..293fb63e154 --- /dev/null +++ b/desktop/src/features/messages/lib/linkPreviewPreparationStore.test.mjs @@ -0,0 +1,274 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + __linkPreviewPreparationTest, + prepareBackgroundLinkPreviews, + prepareLinkPreview, + resetLinkPreviewPreparations, + skipBackgroundLinkPreviews, +} from "./linkPreviewPreparationStore.ts"; + +const first = { href: "https://example.com/first" }; +const second = { href: "https://example.com/second" }; +const firstTag = ["link-preview", "snapshot", first.href]; + +function deferred() { + let resolve; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function seed( + candidate, + promise, + settled = false, + settledAt = Date.now(), + fallbackTag = null, + resolvedTag = null, +) { + __linkPreviewPreparationTest.jobs.set(candidate.href, { + controller: new AbortController(), + promise, + fallbackTag, + resolvedTag, + settled, + settledAt: settled ? settledAt : null, + }); +} + +test.afterEach(() => { + __linkPreviewPreparationTest.reset(); +}); + +test("adopts one in-flight job for the same canonical URL", () => { + const pending = deferred(); + seed(first, pending.promise); + + assert.equal(prepareLinkPreview(first), pending.promise); + assert.equal(prepareLinkPreview(first), pending.promise); + pending.resolve(firstTag); +}); + +test("expires settled jobs while retaining in-flight and recent work", () => { + const now = 1_000_000; + assert.equal( + __linkPreviewPreparationTest.isReusableJob( + { + controller: new AbortController(), + promise: Promise.resolve(firstTag), + fallbackTag: null, + resolvedTag: null, + settled: false, + settledAt: null, + }, + now, + ), + true, + ); + assert.equal( + __linkPreviewPreparationTest.isReusableJob( + { + controller: new AbortController(), + promise: Promise.resolve(firstTag), + fallbackTag: null, + resolvedTag: null, + settled: true, + settledAt: now - 1, + }, + now, + ), + true, + ); + assert.equal( + __linkPreviewPreparationTest.isReusableJob( + { + controller: new AbortController(), + promise: Promise.resolve(firstTag), + fallbackTag: null, + resolvedTag: null, + settled: true, + settledAt: now - 5 * 60_000, + }, + now, + ), + false, + ); +}); + +test("keeps successful sibling tags when another URL fails", async () => { + const pending = deferred(); + seed(first, Promise.resolve(firstTag), true); + seed(second, pending.promise); + + const preparation = prepareBackgroundLinkPreviews([first, second], 1_000); + assert.ok(preparation); + pending.resolve(null); + + assert.deepEqual(await preparation.promise, { + status: "ready", + tags: [firstTag], + }); +}); + +test("total deadline keeps full and fallback sibling tags", async () => { + const pending = deferred(); + const fallbackTag = ["link-preview", "snapshot", second.href, "metadata"]; + seed(first, Promise.resolve(firstTag), true, Date.now(), null, firstTag); + seed(second, pending.promise, false, Date.now(), fallbackTag); + + const preparation = prepareBackgroundLinkPreviews([first, second], 0); + assert.ok(preparation); + assert.deepEqual(await preparation.promise, { + status: "ready", + tags: [firstTag, fallbackTag], + }); + + const lateTag = ["link-preview", "snapshot", second.href, "image"]; + pending.resolve(lateTag); + await pending.promise; + assert.deepEqual(await preparation.promise, { + status: "ready", + tags: [firstTag, fallbackTag], + }); +}); + +test("timeout keeps metadata-only fallback and ignores late upload completion", async () => { + const pending = deferred(); + const fallbackTag = [ + "link-preview", + "snapshot", + "1", + first.href, + "First", + "Example", + "", + "", + "", + "", + "", + ]; + seed(first, pending.promise, false, Date.now(), fallbackTag); + + const preparation = prepareBackgroundLinkPreviews([first], 0); + assert.ok(preparation); + assert.deepEqual(await preparation.promise, { + status: "ready", + tags: [fallbackTag], + }); + + pending.resolve(firstTag); + await pending.promise; + assert.deepEqual(await preparation.promise, { + status: "ready", + tags: [fallbackTag], + }); +}); + +test("Skip wins completion and resolves exactly once", async () => { + const pending = deferred(); + seed(first, pending.promise); + + const preparation = prepareBackgroundLinkPreviews([first], 1_000); + assert.ok(preparation); + preparation.skip(); + pending.resolve(firstTag); + + assert.deepEqual(await preparation.promise, { status: "ready", tags: [] }); +}); + +test("Skip only settles the latest concurrent preparation", async () => { + const firstPending = deferred(); + const secondPending = deferred(); + seed(first, firstPending.promise); + seed(second, secondPending.promise); + + const firstPreparation = prepareBackgroundLinkPreviews([first], 1_000); + const secondPreparation = prepareBackgroundLinkPreviews([second], 1_000); + assert.ok(firstPreparation); + assert.ok(secondPreparation); + + skipBackgroundLinkPreviews(); + firstPending.resolve(firstTag); + secondPending.resolve(["link-preview", "snapshot", second.href]); + + assert.deepEqual(await secondPreparation.promise, { + status: "ready", + tags: [], + }); + assert.deepEqual(await firstPreparation.promise, { + status: "ready", + tags: [firstTag], + }); +}); + +test("Skip after completion cannot replace finalized tags", async () => { + const pending = deferred(); + seed(first, pending.promise); + + const preparation = prepareBackgroundLinkPreviews([first], 1_000); + assert.ok(preparation); + pending.resolve(firstTag); + assert.deepEqual(await preparation.promise, { + status: "ready", + tags: [firstTag], + }); + + preparation.skip(); + assert.deepEqual(await preparation.promise, { + status: "ready", + tags: [firstTag], + }); +}); + +test("already-settled partial results contain only successful tags", async () => { + seed(first, Promise.resolve(firstTag), true); + seed(second, Promise.resolve(null), true); + + const preparation = prepareBackgroundLinkPreviews([first, second]); + assert.ok(preparation); + assert.deepEqual(await preparation.promise, { + status: "ready", + tags: [firstTag], + }); +}); + +test("reset aborts a promoted send after preview preparation settles", async () => { + seed(first, Promise.resolve(firstTag), true); + + const preparation = prepareBackgroundLinkPreviews([first]); + assert.ok(preparation); + assert.deepEqual(await preparation.promise, { + status: "ready", + tags: [firstTag], + }); + + resetLinkPreviewPreparations(); + assert.equal(preparation.signal.aborted, true); +}); + +test("released promoted sends are no longer cancelled by reset", async () => { + seed(first, Promise.resolve(firstTag), true); + + const preparation = prepareBackgroundLinkPreviews([first]); + assert.ok(preparation); + await preparation.promise; + preparation.release(); + + resetLinkPreviewPreparations(); + assert.equal(preparation.signal.aborted, false); +}); + +test("reset cancels pending preparations instead of authorizing send", async () => { + const pending = deferred(); + seed(first, pending.promise); + + const preparation = prepareBackgroundLinkPreviews([first], 1_000); + assert.ok(preparation); + resetLinkPreviewPreparations(); + pending.resolve(firstTag); + + assert.deepEqual(await preparation.promise, { status: "cancelled" }); +}); diff --git a/desktop/src/features/messages/lib/linkPreviewPreparationStore.ts b/desktop/src/features/messages/lib/linkPreviewPreparationStore.ts new file mode 100644 index 00000000000..d61750fcd86 --- /dev/null +++ b/desktop/src/features/messages/lib/linkPreviewPreparationStore.ts @@ -0,0 +1,356 @@ +import * as React from "react"; + +import { uploadMediaBytes } from "@/shared/api/tauri"; +import { cancelMediaUpload, releaseMediaUpload } from "@/shared/api/tauriMedia"; +import type { SupportedLinkPreview } from "@/shared/lib/linkPreview"; +import { + buildLinkPreviewSnapshotTag, + isValidLinkPreviewSnapshotCanonicalUrl, +} from "@/shared/lib/linkPreviewSnapshot"; +import { + loadLinkPreviewMetadata, + resolveLinkPreview, +} from "@/shared/lib/useResolvedLinkPreviews"; + +const POST_SUBMIT_PREVIEW_BUDGET_MS = 10_000; +const SETTLED_PREVIEW_JOB_TTL_MS = 5 * 60_000; + +type PreviewJob = { + controller: AbortController; + promise: Promise; + fingerprint: string | null; + fallbackTag: string[] | null; + resolvedTag: string[] | null; + settled: boolean; + settledAt: number | null; +}; + +type BackgroundPreviewTask = { + cancel: () => void; + id: number; + skip: () => void; +}; + +type BackgroundPreviewSnapshot = { + canSkip: boolean; + isPreparing: boolean; +}; + +export type BackgroundLinkPreviewResult = + | { status: "cancelled" } + | { status: "ready"; tags: string[][] }; + +export type PreparedBackgroundLinkPreviews = { + cancel: () => void; + promise: Promise; + signal: AbortSignal; + release: () => void; + skip: () => void; +}; + +const jobs = new Map(); +const tasks = new Map(); +const promotedSends = new Map(); +const listeners = new Set<() => void>(); +let nextTaskId = 0; +let nextPromotedSendId = 0; +let nextUploadId = 0; +let snapshot: BackgroundPreviewSnapshot = { + canSkip: false, + isPreparing: false, +}; + +function publishSnapshot(): void { + snapshot = { + canSkip: tasks.size > 0, + isPreparing: tasks.size > 0, + }; + for (const listener of listeners) listener(); +} + +function dataUrlBytes(dataUrl: string | null | undefined): Uint8Array | null { + if (!dataUrl) return null; + const comma = dataUrl.indexOf(","); + if (comma < 0) return null; + try { + const binary = atob(dataUrl.slice(comma + 1)); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); + } catch { + return null; + } +} + +async function uploadDataUrl( + dataUrl: string | null | undefined, + filename: string, + signal: AbortSignal, +): Promise<{ failed: boolean; sha256: string; url: string }> { + const bytes = dataUrlBytes(dataUrl); + if (!bytes) return { failed: false, sha256: "", url: "" }; + if (signal.aborted) return { failed: true, sha256: "", url: "" }; + + const progressId = `link-preview-${nextUploadId++}`; + let cancellation: Promise | null = null; + const cancel = () => { + cancellation ??= cancelMediaUpload(progressId).catch(() => undefined); + }; + signal.addEventListener("abort", cancel, { once: true }); + try { + const uploaded = await uploadMediaBytes([...bytes], filename, progressId); + if (signal.aborted) return { failed: true, sha256: "", url: "" }; + return { failed: false, sha256: uploaded.sha256, url: uploaded.url }; + } catch { + return { failed: true, sha256: "", url: "" }; + } finally { + signal.removeEventListener("abort", cancel); + if (cancellation) await cancellation; + await releaseMediaUpload(progressId).catch(() => undefined); + } +} + +async function buildSnapshot( + candidate: SupportedLinkPreview, + signal: AbortSignal, + onMetadataReady: (tag: string[]) => void, +): Promise { + const metadata = await loadLinkPreviewMetadata(candidate.href); + if (signal.aborted || !metadata) return null; + const preview = resolveLinkPreview(candidate, metadata); + if (!preview.snapshotReady) return null; + const fallbackTag = buildLinkPreviewSnapshotTag({ + canonicalUrl: preview.href, + title: preview.title, + siteName: preview.provider, + description: preview.description ?? "", + imageUrl: "", + imageSha256: "", + faviconUrl: "", + faviconSha256: "", + }); + if (!fallbackTag || signal.aborted) return null; + onMetadataReady(fallbackTag); + const [image, favicon] = await Promise.all([ + uploadDataUrl(preview.imageDataUrl, "link-preview-image.png", signal), + uploadDataUrl(preview.faviconDataUrl, "link-preview-favicon.png", signal), + ]); + if (signal.aborted) return null; + if (image.failed || favicon.failed) return fallbackTag; + return ( + buildLinkPreviewSnapshotTag({ + canonicalUrl: preview.href, + title: preview.title, + siteName: preview.provider, + description: preview.description ?? "", + imageUrl: image.url, + imageSha256: image.sha256, + faviconUrl: favicon.url, + faviconSha256: favicon.sha256, + }) ?? fallbackTag + ); +} + +function isReusableJob(job: PreviewJob, now = Date.now()): boolean { + return ( + !job.settled || + (job.settledAt !== null && now - job.settledAt < SETTLED_PREVIEW_JOB_TTL_MS) + ); +} + +/** + * Supersede preparation derived from an older composer incarnation. Deleting + * before aborting lets a fresh job for the same URL start immediately while + * the old upload unwinds; the old job's identity check prevents it from + * deleting or publishing over its replacement. + */ +export function invalidateLinkPreviewPreparation(href: string): void { + const job = jobs.get(href); + if (!job) return; + jobs.delete(href); + job.controller.abort(); +} + +function previewFingerprint(candidate: SupportedLinkPreview): string | null { + if (!("snapshotReady" in candidate) || !candidate.snapshotReady) return null; + return JSON.stringify([ + candidate.title, + candidate.provider, + "description" in candidate ? candidate.description : null, + "imageDataUrl" in candidate ? candidate.imageDataUrl : null, + "faviconDataUrl" in candidate ? candidate.faviconDataUrl : null, + ]); +} + +/** Start or adopt the one preparation job for this exact canonical URL. */ +export function prepareLinkPreview( + candidate: SupportedLinkPreview, +): Promise { + if ( + candidate.href.startsWith("buzz://") || + !isValidLinkPreviewSnapshotCanonicalUrl(candidate.href) + ) { + return Promise.resolve(null); + } + const fingerprint = previewFingerprint(candidate); + const existing = jobs.get(candidate.href); + if ( + existing && + isReusableJob(existing) && + (fingerprint === null || existing.fingerprint === fingerprint) + ) { + return existing.promise; + } + if (existing) invalidateLinkPreviewPreparation(candidate.href); + + const controller = new AbortController(); + const job: PreviewJob = { + controller, + promise: Promise.resolve(null), + fingerprint, + fallbackTag: null, + resolvedTag: null, + settled: false, + settledAt: null, + }; + job.promise = buildSnapshot(candidate, controller.signal, (fallbackTag) => { + job.fallbackTag = fallbackTag; + }) + .catch(() => null) + .then((tag) => { + job.resolvedTag = tag; + if (tag === null && jobs.get(candidate.href) === job) { + jobs.delete(candidate.href); + } + return tag; + }) + .finally(() => { + job.settled = true; + job.settledAt = Date.now(); + }); + jobs.set(candidate.href, job); + return job.promise; +} + +/** + * Promote the frozen composer generation into a navigation-safe send task. + * Preparation is best effort: Skip, timeout, or failure all authorize the + * already-requested send without previews. + */ +export function prepareBackgroundLinkPreviews( + candidates: readonly SupportedLinkPreview[], + timeoutMs = POST_SUBMIT_PREVIEW_BUDGET_MS, +): PreparedBackgroundLinkPreviews | null { + const external = candidates.filter( + (candidate) => + !candidate.href.startsWith("buzz://") && + isValidLinkPreviewSnapshotCanonicalUrl(candidate.href), + ); + if (external.length === 0) return null; + + const sendId = nextPromotedSendId++; + const controller = new AbortController(); + promotedSends.set(sendId, controller); + const release = () => { + if (promotedSends.get(sendId) === controller) { + promotedSends.delete(sendId); + } + }; + const preparedSend = ( + promise: Promise, + skip: () => void, + ): PreparedBackgroundLinkPreviews => ({ + cancel: () => controller.abort(), + promise, + signal: controller.signal, + release, + skip, + }); + + const pending = external.some( + (candidate) => !jobs.get(candidate.href)?.settled, + ); + if (!pending) { + return preparedSend( + Promise.all(external.map(prepareLinkPreview)).then((tags) => ({ + status: "ready" as const, + tags: tags.filter((tag): tag is string[] => tag !== null), + })), + () => undefined, + ); + } + + const availableTags = () => + external.flatMap((candidate) => { + const job = jobs.get(candidate.href); + const tag = job?.resolvedTag ?? job?.fallbackTag; + return tag ? [tag] : []; + }); + const taskId = nextTaskId++; + let finish: ((result: BackgroundLinkPreviewResult) => void) | null = null; + let terminal = false; + let timer: ReturnType | null = null; + const complete = (result: BackgroundLinkPreviewResult) => { + if (terminal) return; + terminal = true; + if (timer !== null) clearTimeout(timer); + tasks.delete(taskId); + publishSnapshot(); + finish?.(result); + }; + const promise = new Promise((resolve) => { + finish = resolve; + }); + const cancel = () => complete({ status: "cancelled" }); + const skip = () => complete({ status: "ready", tags: [] }); + tasks.set(taskId, { cancel, id: taskId, skip }); + publishSnapshot(); + + timer = setTimeout( + () => complete({ status: "ready", tags: availableTags() }), + timeoutMs, + ); + void Promise.all(external.map(prepareLinkPreview)).then((tags) => { + complete({ + status: "ready", + tags: tags.filter((tag): tag is string[] => tag !== null), + }); + }); + + return preparedSend(promise, skip); +} + +export function skipBackgroundLinkPreviews(): void { + const latestTask = [...tasks.values()].reduce< + BackgroundPreviewTask | undefined + >( + (latest, task) => (!latest || task.id > latest.id ? task : latest), + undefined, + ); + latestTask?.skip(); +} + +export function resetLinkPreviewPreparations(): void { + for (const controller of promotedSends.values()) controller.abort(); + promotedSends.clear(); + for (const task of [...tasks.values()]) task.cancel(); + for (const job of jobs.values()) job.controller.abort(); + jobs.clear(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function getSnapshot(): BackgroundPreviewSnapshot { + return snapshot; +} + +export function useBackgroundLinkPreviewPreparation(): BackgroundPreviewSnapshot { + return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +export const __linkPreviewPreparationTest = { + isReusableJob, + jobs, + reset: resetLinkPreviewPreparations, +}; diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.ts b/desktop/src/features/messages/lib/mentionHighlightExtension.ts index 1fad4140845..a8d3ef8ff0a 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.ts +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.ts @@ -2,6 +2,11 @@ import { Extension } from "@tiptap/core"; import { Plugin, PluginKey, type Transaction } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { + inlineChipIconClasses, + MENTION_CHIP_BASE_CLASSES, +} from "@/shared/ui/mentionChip"; + export const mentionHighlightKey = new PluginKey("mentionHighlight"); /** @@ -267,22 +272,24 @@ function buildDecorations( node.text, pos, mentionPatterns, - "mention-chip", + `${MENTION_CHIP_BASE_CLASSES} ${inlineChipIconClasses("human")}`, + { hidePrefix: true }, ); addMatchesForPatterns( decorations, node.text, pos, agentMentionPatterns, - "mention-chip agent-mention-highlight", - { hideMentionPrefix: true }, + `${MENTION_CHIP_BASE_CLASSES} ${inlineChipIconClasses("agent")}`, + { hidePrefix: true }, ); addMatchesForPatterns( decorations, node.text, pos, channelPatterns, - "mention-chip", + `${MENTION_CHIP_BASE_CLASSES} ${inlineChipIconClasses("channel")}`, + { hidePrefix: true }, ); }); @@ -295,7 +302,7 @@ function addMatchesForPatterns( position: number, patterns: RegExp[], className: string, - options?: { hideMentionPrefix?: boolean }, + options?: { hidePrefix?: boolean }, ) { for (const pattern of patterns) { pattern.lastIndex = 0; @@ -303,10 +310,10 @@ function addMatchesForPatterns( while (match !== null) { const from = position + match.index; const to = from + match[0].length; - if (options?.hideMentionPrefix && match[0].startsWith("@")) { + if (options?.hidePrefix && /^[@#]/.test(match[0])) { decorations.push( Decoration.inline(from, from + 1, { - class: "agent-mention-at-hidden", + class: "mention-prefix-hidden", spellcheck: "false", }), ); diff --git a/desktop/src/features/messages/lib/remarkChannelDeepLinks.test.mjs b/desktop/src/features/messages/lib/remarkChannelDeepLinks.test.mjs new file mode 100644 index 00000000000..ce45d2fa549 --- /dev/null +++ b/desktop/src/features/messages/lib/remarkChannelDeepLinks.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import remarkChannelDeepLinks from "./remarkChannelDeepLinks.ts"; + +function run(value) { + const tree = { + type: "root", + children: [{ type: "paragraph", children: [{ type: "text", value }] }], + }; + remarkChannelDeepLinks()(tree); + return tree.children[0].children; +} + +test("turns a bare channel deep link into a custom node", () => { + const children = run( + "Open buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32 now", + ); + assert.equal(children[1].type, "channel-deep-link"); + assert.equal( + children[1].value, + "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32", + ); +}); + +test("peels trailing sentence punctuation", () => { + const children = run( + "Open buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32.", + ); + assert.equal( + children[1].value, + "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32", + ); + assert.equal(children[2].value, "."); +}); diff --git a/desktop/src/features/messages/lib/remarkChannelDeepLinks.ts b/desktop/src/features/messages/lib/remarkChannelDeepLinks.ts new file mode 100644 index 00000000000..efafec770e3 --- /dev/null +++ b/desktop/src/features/messages/lib/remarkChannelDeepLinks.ts @@ -0,0 +1,22 @@ +/** Detect bare `buzz://channel/` URLs in markdown text nodes. */ +import { createRemarkPrefixPlugin } from "../../../shared/lib/createRemarkPrefixPlugin.ts"; + +const CHANNEL_URL_PATTERN = /buzz:\/\/channel\/[^\s<>"')\]]+/g; +const TRAILING_PUNCTUATION_PATTERN = /[.,;:!?]+$/; + +export default function remarkChannelDeepLinks() { + return createRemarkPrefixPlugin(CHANNEL_URL_PATTERN, (matchText) => { + const value = matchText.replace(TRAILING_PUNCTUATION_PATTERN, ""); + return { + node: { + type: "channel-deep-link", + value, + data: { + hName: "channel-deep-link", + hChildren: [{ type: "text", value }], + }, + }, + trailing: matchText.slice(value.length), + }; + }); +} diff --git a/desktop/src/features/messages/lib/remarkEntityLinks.test.mjs b/desktop/src/features/messages/lib/remarkEntityLinks.test.mjs new file mode 100644 index 00000000000..1d7fdeacb3d --- /dev/null +++ b/desktop/src/features/messages/lib/remarkEntityLinks.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import remarkEntityLinks from "./remarkEntityLinks.ts"; + +function run(value) { + const tree = { + type: "root", + children: [{ type: "paragraph", children: [{ type: "text", value }] }], + }; + remarkEntityLinks()(tree); + return tree.children[0].children; +} + +test("turns every bare Buzz entity permalink family into a chip node", () => { + const owner = "ab".repeat(32); + const id = "cd".repeat(32); + const links = [ + `buzz://repo?owner=${owner}&d=buzz`, + `buzz://pr?id=${id}&owner=${owner}&d=buzz`, + `buzz://issue?id=${id}&owner=${owner}&d=buzz`, + ]; + for (const link of links) { + const children = run(link); + assert.equal(children[0].type, "entity-link"); + assert.equal(children[0].value, link); + } +}); + +test("keeps sentence punctuation outside entity chip nodes", () => { + const link = `buzz://repo?owner=${"ab".repeat(32)}&d=buzz`; + const children = run(`${link}.`); + assert.equal(children[0].value, link); + assert.equal(children[1].value, "."); +}); diff --git a/desktop/src/features/messages/lib/remarkEntityLinks.ts b/desktop/src/features/messages/lib/remarkEntityLinks.ts new file mode 100644 index 00000000000..41cf4af20b5 --- /dev/null +++ b/desktop/src/features/messages/lib/remarkEntityLinks.ts @@ -0,0 +1,22 @@ +/** Detect bare `buzz://pr|issue|repo?…` URLs in markdown text nodes. */ +import { createRemarkPrefixPlugin } from "../../../shared/lib/createRemarkPrefixPlugin.ts"; + +const ENTITY_URL_PATTERN = /buzz:\/\/(?:pr|issue|repo)\?[^\s<>"')\]]+/g; +const TRAILING_PUNCTUATION_PATTERN = /[.,;:!?]+$/; + +export default function remarkEntityLinks() { + return createRemarkPrefixPlugin(ENTITY_URL_PATTERN, (matchText) => { + const value = matchText.replace(TRAILING_PUNCTUATION_PATTERN, ""); + return { + node: { + type: "entity-link", + value, + data: { + hName: "entity-link", + hChildren: [{ type: "text", value }], + }, + }, + trailing: matchText.slice(value.length), + }; + }); +} diff --git a/desktop/src/features/messages/lib/videoReviewContext.test.mjs b/desktop/src/features/messages/lib/videoReviewContext.test.mjs index 6c75883da98..f22cee06ca3 100644 --- a/desktop/src/features/messages/lib/videoReviewContext.test.mjs +++ b/desktop/src/features/messages/lib/videoReviewContext.test.mjs @@ -7,6 +7,7 @@ import { buildVideoReviewCommentRootIdsByMessageId, buildVideoReviewContextForMessage, buildVideoReviewContextsByMessageId, + hasRenderedVideoAttachment, hasVideoAttachment, } from "./videoReviewContext.ts"; @@ -59,6 +60,63 @@ test("hasVideoAttachment detects markdown and imeta videos", () => { ); assert.equal(hasVideoAttachment(message({ body: "plain text" })), false); + assert.equal( + hasVideoAttachment( + message({ + body: "orphan metadata only", + tags: [["imeta", "url https://cdn.example.com/cut.mp4", "m video/mp4"]], + }), + ), + true, + ); + assert.equal( + hasRenderedVideoAttachment( + message({ + body: "orphan metadata only", + tags: [["imeta", "url https://cdn.example.com/cut.mp4", "m video/mp4"]], + }), + ), + false, + ); +}); +test("hasVideoAttachment uses the Markdown renderer's video classification", () => { + assert.equal( + hasVideoAttachment( + message({ body: "![Demo](https://cdn.example.com/cut.mp4)" }), + ), + true, + ); + assert.equal( + hasVideoAttachment( + message({ body: "![Poster](https://cdn.example.com/cut.jpg)" }), + ), + false, + ); + assert.equal( + hasVideoAttachment( + message({ + body: "![Demo](https://relay/media/cut.mp4)", + tags: [["imeta", "url https://relay/media/cut.mp4", "m image/png"]], + }), + ), + false, + ); + assert.equal( + hasVideoAttachment( + message({ + body: "![Demo][clip]\n\n[clip]: https://cdn.example.com/cut.mp4", + }), + ), + true, + ); + assert.equal( + hasVideoAttachment( + message({ + body: "```md\n![Demo](https://cdn.example.com/cut.mp4)\n```", + }), + ), + false, + ); }); test("buildVideoReviewCommentsByRootId includes nested descendants", () => { @@ -211,6 +269,39 @@ test("buildVideoReviewCommentRootIdsByMessageId targets the nearest video ancest ); }); +test("buildVideoReviewCommentRootIdsByMessageId can require rendered video roots", () => { + const orphanVideo = message({ + id: "orphan-video", + body: "metadata only", + tags: [["imeta", "url https://relay/media/a.mp4", "m video/mp4"]], + }); + const comment = message({ + id: "comment", + body: "[00:01] review this", + parentId: orphanVideo.id, + rootId: orphanVideo.id, + }); + + assert.deepEqual( + [ + ...buildVideoReviewCommentRootIdsByMessageId([ + orphanVideo, + comment, + ]).entries(), + ], + [[comment.id, orphanVideo.id]], + ); + assert.deepEqual( + [ + ...buildVideoReviewCommentRootIdsByMessageId( + [orphanVideo, comment], + hasRenderedVideoAttachment, + ).entries(), + ], + [], + ); +}); + test("buildVideoReviewContextForMessage posts against the source video", async () => { const video = message({ id: "video", diff --git a/desktop/src/features/messages/lib/videoReviewContext.ts b/desktop/src/features/messages/lib/videoReviewContext.ts index 78401214a20..a63843c1ce3 100644 --- a/desktop/src/features/messages/lib/videoReviewContext.ts +++ b/desktop/src/features/messages/lib/videoReviewContext.ts @@ -1,6 +1,10 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; + import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; +import { isVideoMedia } from "@/shared/ui/markdown/mediaEntry"; +import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; import type { VideoReviewContext } from "@/shared/ui/VideoPlayer"; type SendVideoReviewComment = ( @@ -17,18 +21,79 @@ type ToggleMessageReaction = ( remove: boolean, ) => Promise; -export function hasVideoAttachment(message: TimelineMessage): boolean { - if (message.body.includes("![video](")) return true; +type VideoRootPredicate = ( + message: Pick, +) => boolean; + +type MarkdownAstNode = { + children?: MarkdownAstNode[]; + identifier?: string; + type: string; + url?: string; +}; + +function markdownImageUrls(body: string): string[] { + if (!body.includes("![")) return []; + + const definitions = new Map(); + const directUrls: string[] = []; + const referenceIds: string[] = []; + + const visit = (node: MarkdownAstNode) => { + if (node.type === "definition" && node.identifier && node.url) { + if (!definitions.has(node.identifier)) { + definitions.set(node.identifier, node.url); + } + } else if (node.type === "image" && node.url) { + directUrls.push(node.url); + } else if (node.type === "imageReference" && node.identifier) { + referenceIds.push(node.identifier); + } + + node.children?.forEach(visit); + }; - return ( - message.tags?.some( - (tag) => - tag[0] === "imeta" && - tag.some((part) => part.toLowerCase().startsWith("m video/")), - ) ?? false + visit(fromMarkdown(body) as MarkdownAstNode); + return [ + ...directUrls, + ...referenceIds.flatMap((identifier) => { + const url = definitions.get(identifier); + return url ? [url] : []; + }), + ]; +} + +/** + * Returns whether a message contains a video URL in a Markdown image that + * the renderer will actually mount. Orphan imeta entries are intentionally + * excluded because they do not produce a video player. + */ +export function hasRenderedVideoAttachment( + message: Pick, +): boolean { + const imetaByUrl = parseImetaTags(message.tags ?? []); + return markdownImageUrls(message.body).some((src) => + isVideoMedia(src, imetaByUrl.get(src)?.m), ); } +export function hasVideoAttachment( + message: Pick, +): boolean { + const imetaByUrl = parseImetaTags(message.tags ?? []); + if ( + [...imetaByUrl.values()].some((entry) => isVideoMedia(entry.url, entry.m)) + ) { + return true; + } + + for (const src of markdownImageUrls(message.body)) { + if (isVideoMedia(src, imetaByUrl.get(src)?.m)) return true; + } + + return false; +} + export function buildVideoReviewCommentsByRootId( messages: TimelineMessage[], ): Map { @@ -95,10 +160,11 @@ export function buildVideoReviewCommentsForRoot( export function buildVideoReviewCommentRootIdsByMessageId( messages: TimelineMessage[], + videoRootPredicate: VideoRootPredicate = hasVideoAttachment, ): ReadonlyMap { const messageById = new Map(messages.map((message) => [message.id, message])); const videoMessageIds = new Set( - messages.filter(hasVideoAttachment).map((message) => message.id), + messages.filter(videoRootPredicate).map((message) => message.id), ); const rootIdsByMessageId = new Map(); @@ -130,6 +196,7 @@ export function buildVideoReviewContextForMessage({ onSendVideoReviewComment, onToggleReaction, profiles, + videoRootPredicate = hasVideoAttachment, }: { channelId?: string | null; channelName?: string; @@ -140,8 +207,9 @@ export function buildVideoReviewContextForMessage({ onSendVideoReviewComment?: SendVideoReviewComment; onToggleReaction?: ToggleMessageReaction; profiles?: UserProfileLookup; + videoRootPredicate?: VideoRootPredicate; }): VideoReviewContext | undefined { - if (!hasVideoAttachment(message)) { + if (!videoRootPredicate(message)) { return undefined; } @@ -185,6 +253,7 @@ export function buildVideoReviewContextsByMessageId({ onSendVideoReviewComment, onToggleReaction, profiles, + videoRootPredicate = hasVideoAttachment, }: { channelId?: string | null; channelName?: string; @@ -194,9 +263,10 @@ export function buildVideoReviewContextsByMessageId({ onSendVideoReviewComment?: SendVideoReviewComment; onToggleReaction?: ToggleMessageReaction; profiles?: UserProfileLookup; + videoRootPredicate?: VideoRootPredicate; }): ReadonlyMap { const contexts = new Map(); - if (!messages.some(hasVideoAttachment)) { + if (!messages.some(videoRootPredicate)) { return contexts; } @@ -212,6 +282,7 @@ export function buildVideoReviewContextsByMessageId({ onSendVideoReviewComment, onToggleReaction, profiles, + videoRootPredicate, }); if (context) { contexts.set(message.id, context); @@ -221,17 +292,28 @@ export function buildVideoReviewContextsByMessageId({ return contexts; } +/** + * Builds the paired video-review maps used by timeline presentation: contexts + * are keyed by video message, while comment roots map each descendant back to + * its nearest video ancestor. + */ export function buildVideoReviewPresentationByMessageId( args: Parameters[0], + videoRootPredicate: VideoRootPredicate = hasVideoAttachment, ) { return { commentRootIdsByMessageId: buildVideoReviewCommentRootIdsByMessageId( args.messages, + videoRootPredicate, ), - contextsByMessageId: buildVideoReviewContextsByMessageId(args), + contextsByMessageId: buildVideoReviewContextsByMessageId({ + ...args, + videoRootPredicate, + }), }; } +/** The synchronized context and comment-root maps for a rendered timeline. */ export type VideoReviewPresentation = ReturnType< typeof buildVideoReviewPresentationByMessageId >; diff --git a/desktop/src/features/messages/ui/ComposerUploadProgressOverlay.tsx b/desktop/src/features/messages/ui/ComposerUploadProgressOverlay.tsx index d021e49783f..65f18538875 100644 --- a/desktop/src/features/messages/ui/ComposerUploadProgressOverlay.tsx +++ b/desktop/src/features/messages/ui/ComposerUploadProgressOverlay.tsx @@ -2,20 +2,37 @@ import { cancelBackgroundMediaUploads, useBackgroundMediaUpload, } from "@/features/messages/lib/backgroundMediaUploadStore"; +import { + skipBackgroundLinkPreviews, + useBackgroundLinkPreviewPreparation, +} from "@/features/messages/lib/linkPreviewPreparationStore"; import { ComposerUploadProgressPill } from "@/features/messages/ui/ComposerUploadProgressPill"; export function ComposerUploadProgressOverlay() { const backgroundUpload = useBackgroundMediaUpload(); + const linkPreviews = useBackgroundLinkPreviewPreparation(); return (
- + {linkPreviews.isPreparing ? ( + + ) : ( + + )}
); } diff --git a/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx b/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx index 288b32f8f16..14780051563 100644 --- a/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx +++ b/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx @@ -8,20 +8,25 @@ import { cn } from "@/shared/lib/cn"; import { Spinner } from "@/shared/ui/spinner"; export function ComposerUploadProgressPill({ + actionLabel = "Cancel", canCancel, isUploading, onCancel, phase, + phaseLabel: phaseLabelOverride, percentage, }: { + actionLabel?: string; canCancel: boolean; isUploading: boolean; onCancel: () => void; phase: BackgroundMediaUploadPhase; + phaseLabel?: string; percentage: number; }) { const reducedMotion = useReducedMotion(); - const phaseLabel = backgroundMediaUploadPhaseLabel(phase); + const phaseLabel = + phaseLabelOverride ?? backgroundMediaUploadPhaseLabel(phase); const isTransferring = phase === "uploading"; const phaseTransition = reducedMotion ? { duration: 0 } @@ -101,7 +106,6 @@ export function ComposerUploadProgressPill({ layout="position" transition={phaseTransition} > - {isTransferring ? ( - Cancel + {actionLabel} ) : null}
diff --git a/desktop/src/features/messages/ui/MessageAgentOwner.tsx b/desktop/src/features/messages/ui/MessageAgentOwner.tsx index e5b92cd7348..e394d567b48 100644 --- a/desktop/src/features/messages/ui/MessageAgentOwner.tsx +++ b/desktop/src/features/messages/ui/MessageAgentOwner.tsx @@ -17,14 +17,28 @@ export function MessageAgentOwner({ {ownerLabel ? "Agent managed by" : "Agent; owner unavailable"} + {/* + * Icon and label sit directly in this baseline row rather than in a nested + * flex wrapper, so the label's own baseline is what aligns with the author + * name beside it. Both branches share the icon for the same reason: two + * wrappers meant two alignment rules and the "owner unavailable" variant + * had drifted a pixel off the other one. + * + * `self-center` keeps the icon out of baseline alignment, so the label — + * not the icon's box — sets this chip's baseline. Centred on the line box + * the glyph's ink still rides ~1.6px above the text's cap band, reading as + * a couple of pixels too high; 0.125em drops its optical centre onto that + * band. In em so it holds under Cmd +/- zoom, and as a transform so it + * shifts nothing else in the row. + */} +
); @@ -627,11 +606,19 @@ export const MessageRow = React.memo( const inlineMetadataNode = (
- + {statusMetadataNode}
); + const personaNode = + message.personaDisplayName && + message.personaDisplayName !== message.author ? ( + + {message.personaDisplayName} + + ) : null; + const continuationMetadataNode = isDisplayedAsContinuation && statusMetadataNode ? (
@@ -657,14 +644,14 @@ export const MessageRow = React.memo( ) : ( authorNode )} - {agentOwnerNode} - {inlineMetadataNode} - {message.personaDisplayName && - message.personaDisplayName !== message.author ? ( - - {message.personaDisplayName} - - ) : null} + {/* Author is not a segment: "Alice 9:53 AM" needs no divider. */} + ); const bodyContainerClass = isDisplayedAsContinuation @@ -932,7 +919,9 @@ export const MessageRow = React.memo( prev.message.ownerLabel === next.message.ownerLabel && prev.message.avatarUrl === next.message.avatarUrl && prev.message.accent === next.message.accent && - prev.message.time === next.message.time && + // The header timestamp and hover gutter both derive from createdAt (the + // old `time` prop was the same value pre-formatted; this row reads neither). + prev.message.createdAt === next.message.createdAt && prev.message.depth === next.message.depth && prev.message.kind === next.message.kind && prev.message.pending === next.message.pending && diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 8f88d8b63de..c98d0bc0118 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -93,6 +93,7 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & { parentEventId: string | null; threadHeadId: string | null; } | null, + forceRest?: boolean, ) => Promise; onSendToChannel?: ( message: TimelineMessage, diff --git a/desktop/src/features/messages/ui/MessageTimestamp.tsx b/desktop/src/features/messages/ui/MessageTimestamp.tsx index ff5394b76bd..100f6c6be35 100644 --- a/desktop/src/features/messages/ui/MessageTimestamp.tsx +++ b/desktop/src/features/messages/ui/MessageTimestamp.tsx @@ -1,8 +1,10 @@ import { formatFullDateTime, + formatTime, formatTimeWithoutDayPeriod, } from "@/features/messages/lib/dateFormatters"; import { cn } from "@/shared/lib/cn"; +import { formatItemTimestamp } from "@/shared/lib/datetime"; import { Tooltip, TooltipContent, @@ -12,18 +14,40 @@ import { const TIMESTAMP_TOOLTIP_DELAY_MS = 500; +/** + * The timestamp beside a message author, and the clock that fades in over the + * avatar gutter on continuation rows. + * + * Both labels are derived from `createdAt` here rather than taken as a + * pre-formatted string, so the wording is recomputed on each render instead of + * being frozen at the time the message list was formatted. Note this does not + * make it live: `MessageRow` is memoized, so a row already on screen when the + * clock passes midnight keeps saying "Today" until something re-renders it. The + * day divider above it has the same property, and both correct themselves on the + * next message, scroll, or navigation. + * + * The two modes carry different information on purpose: + * + * - Header (default) — the full relative label ("Yesterday at 9:05 AM"). The day + * divider above the group says which day it is, but a divider scrolls out of + * view while its messages stay on screen, so a bare clock time on a row from + * last week has nothing to anchor it. + * - `hideDayPeriod` — clock only, minus the AM/PM marker. This renders in a + * 36px-wide gutter where the avatar would be, so it has room for "9:05" and + * nothing more. + */ export function MessageTimestamp({ className, createdAt, hideDayPeriod = false, - time, }: { className?: string; createdAt: number; hideDayPeriod?: boolean; - time: string; }) { - const displayTime = hideDayPeriod ? formatTimeWithoutDayPeriod(time) : time; + const displayTime = hideDayPeriod + ? formatTimeWithoutDayPeriod(formatTime(createdAt)) + : formatItemTimestamp(createdAt, { withTime: true }); return ( + {children} + + ) : ( - {highlight && !isAgentMention ? ( - @ - ) : null} {children} ); @@ -895,15 +897,20 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({ {description.title} {displayedIdentityIsAgent ? ( - - ) : null} - + <> + + {/* Grouped with the timestamp so the two wrap together. */} + + + + + + ) : ( + + )}

{description.action} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index bf2da03f406..d7ef78ea04b 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { VList } from "virtua"; import type { VListHandle } from "virtua"; -import { formatDayHeading } from "@/features/messages/lib/dateFormatters"; +import { formatDayGroupLabel } from "@/shared/lib/datetime"; import { buildTimelineDayGroups, buildTimelineItems, @@ -346,13 +346,13 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ data-day-label={ group.headingTimestamp === null ? undefined - : formatDayHeading(group.headingTimestamp) + : formatDayGroupLabel(group.headingTimestamp) } data-testid="message-timeline-day-group" key={group.key} > {hideDayDividers || group.headingTimestamp === null ? null : ( - + )} {group.items.map((item) => ( @@ -507,7 +507,7 @@ function VirtualizedTimelineRows({ const renderedDividerPillTop = ( divider: (typeof dayDividerItems)[number], ) => { - const label = formatDayHeading(divider.item.headingTimestamp); + const label = formatDayGroupLabel(divider.item.headingTimestamp); const source = [ ...scroller.querySelectorAll( '[data-testid="message-timeline-day-divider"]', @@ -564,11 +564,11 @@ function VirtualizedTimelineRows({ pinnedLabel.style.transform = `translateY(${nextTranslateY}px)`; } const nextLabel = activeDivider - ? formatDayHeading(activeDivider.item.headingTimestamp) + ? formatDayGroupLabel(activeDivider.item.headingTimestamp) : null; const incomingLabel = nextDivider && nextTranslateY < 0 - ? formatDayHeading(nextDivider.item.headingTimestamp) + ? formatDayGroupLabel(nextDivider.item.headingTimestamp) : null; const activeSourcePill = sourcePills.find( (pill) => pill.parentElement?.dataset.dayLabel === nextLabel, @@ -762,7 +762,7 @@ function VirtualizedTimelineRows({ return

{item.content}
; } if (item.kind === "day-divider") { - const dayLabel = formatDayHeading(item.headingTimestamp); + const dayLabel = formatDayGroupLabel(item.headingTimestamp); return (
{ - const timers = makeFakeTimers(); - let submits = 0; - scheduleSettleGatedAutoSubmit({ - isPending: () => false, - submit: () => submits++, - timers, - }); - timers.tick(); // fire the initial setTimeout(0) - assert.equal(submits, 1); - assert.equal(timers.pendingCount(), 0, "no retry should be scheduled"); -}); - -test("waits while settling then submits exactly once (the drop-guard)", () => { +test("submits a restored draft exactly once on the next task", () => { const timers = makeFakeTimers(); let submits = 0; - let pending = true; // still settling at mount scheduleSettleGatedAutoSubmit({ - isPending: () => pending, submit: () => submits++, timers, }); - timers.tick(); // initial attempt: pending → reschedules, does NOT submit - assert.equal(submits, 0, "must not send while a snapshot is still pending"); - assert.equal(timers.pendingCount(), 1, "a retry must be scheduled"); - - timers.tick(); // retry: still pending assert.equal(submits, 0); - - pending = false; // settling finished - timers.tick(); // retry: fires the send - assert.equal(submits, 1, "must send exactly once after settling clears"); + timers.tick(); + assert.equal(submits, 1); assert.equal(timers.pendingCount(), 0); }); -test("cleanup before settling finishes cancels the submit (no orphan send)", () => { +test("cleanup before the next task cancels the submit", () => { const timers = makeFakeTimers(); let submits = 0; const cleanup = scheduleSettleGatedAutoSubmit({ - isPending: () => true, submit: () => submits++, timers, }); - timers.tick(); // initial attempt reschedules a retry - assert.equal(timers.pendingCount(), 1); - cleanup(); // unmount - assert.equal( - timers.pendingCount(), - 0, - "cleanup must clear the pending retry", - ); + cleanup(); + assert.equal(timers.pendingCount(), 0); assert.equal(submits, 0); }); diff --git a/desktop/src/features/messages/ui/messageComposerAutoSubmit.ts b/desktop/src/features/messages/ui/messageComposerAutoSubmit.ts index f15422b93d1..30c676384a1 100644 --- a/desktop/src/features/messages/ui/messageComposerAutoSubmit.ts +++ b/desktop/src/features/messages/ui/messageComposerAutoSubmit.ts @@ -1,45 +1,19 @@ -// Auto-submit scheduler for a confirmed draft that arrived via ?autoSend. A -// draft containing a supported link is normally still settling (350 ms -// debounce + metadata/upload) at mount, so a submit fired immediately bails on -// the pending-snapshot guard. A one-shot `setTimeout(0)` would consume the -// trigger and silently drop the draft; instead poll until settling finishes -// (bounded by the preview hook's own anti-trap cap) then submit exactly once. -// The `didSubmit` guard prevents a double fire, and the initial defer lets the -// draft-persist lifecycle effect load the draft into the editor first. -// -// Extracted from MessageComposer as a pure, timer-injectable helper so the -// retry/one-shot contract is unit-testable without mounting the composer. +// Auto-submit a confirmed draft after the draft-persist lifecycle has restored +// it into the editor. Link-preview preparation is promoted by submit itself, so +// it must never hold this trigger in a polling loop. export function scheduleSettleGatedAutoSubmit({ - isPending, submit, - retryDelayMs = 50, timers = { set: (fn: () => void, ms: number) => window.setTimeout(fn, ms), clear: (id: number) => window.clearTimeout(id), }, }: { - isPending: () => boolean; submit: () => void; - retryDelayMs?: number; timers?: { set: (fn: () => void, ms: number) => number; clear: (id: number) => void; }; }): () => void { - let didSubmit = false; - let retryTimer = 0; - const attempt = () => { - if (didSubmit) return; - if (isPending()) { - retryTimer = timers.set(attempt, retryDelayMs); - return; - } - didSubmit = true; - submit(); - }; - const initialTimer = timers.set(attempt, 0); - return () => { - timers.clear(initialTimer); - timers.clear(retryTimer); - }; + const timer = timers.set(submit, 0); + return () => timers.clear(timer); } diff --git a/desktop/src/features/messages/ui/messageTimestampContract.test.mjs b/desktop/src/features/messages/ui/messageTimestampContract.test.mjs new file mode 100644 index 00000000000..637166440c2 --- /dev/null +++ b/desktop/src/features/messages/ui/messageTimestampContract.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +/** + * MessageTimestamp renders through Radix tooltip primitives, which need a DOM to + * mount. These assertions read the source instead, because the thing worth + * pinning is which formatter each mode reaches for — the labels themselves are + * covered by `shared/lib/datetime.test.mjs`. + */ +const source = readFileSync( + new URL("./MessageTimestamp.tsx", import.meta.url), + "utf8", +).replace(/\s+/g, " "); + +test("a message header shows the relative label, including the time", () => { + // Regression guard: this used to render a bare clock time, so a message from + // last week read "9:05 AM" once its day divider scrolled out of view. + assert.match( + source, + /: formatItemTimestamp\(createdAt, \{ withTime: true \}\)/, + ); +}); + +test("the continuation gutter stays clock-only", () => { + // 36px of width (w-9). A relative label would not fit. + assert.match( + source, + /hideDayPeriod \? formatTimeWithoutDayPeriod\(formatTime\(createdAt\)\)/, + ); +}); + +test("both labels derive from createdAt, not a pre-formatted prop", () => { + // A captured string would keep saying "Today" after midnight. + assert.doesNotMatch(source, /time: string/); + assert.doesNotMatch(source, /\btime\b(?!\w)[^;]*?=\s*\{/); +}); + +test("the tooltip still carries the unabbreviated timestamp", () => { + assert.match(source, /formatFullDateTime\(createdAt\)/); +}); diff --git a/desktop/src/features/messages/ui/useActivePreparedLinkPreviews.ts b/desktop/src/features/messages/ui/useActivePreparedLinkPreviews.ts new file mode 100644 index 00000000000..80a11e11564 --- /dev/null +++ b/desktop/src/features/messages/ui/useActivePreparedLinkPreviews.ts @@ -0,0 +1,14 @@ +import * as React from "react"; +import type { PreparedBackgroundLinkPreviews } from "@/features/messages/lib/linkPreviewPreparationStore"; + +export function useActivePreparedLinkPreviews() { + const preparations = React.useRef(new Set()); + React.useEffect(() => { + const active = preparations.current; + return () => { + for (const preparation of active) preparation.cancel(); + active.clear(); + }; + }, []); + return preparations.current; +} diff --git a/desktop/src/features/messages/ui/useComposerLinkPreviews.test.mjs b/desktop/src/features/messages/ui/useComposerLinkPreviews.test.mjs new file mode 100644 index 00000000000..049c57f70c4 --- /dev/null +++ b/desktop/src/features/messages/ui/useComposerLinkPreviews.test.mjs @@ -0,0 +1,752 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +// ── Composer-hook regression: fast clear + re-paste of the same URL ─────────── +// +// useComposerLinkPreviews feeds useResolvedLinkPreviews from DEBOUNCED content +// (350ms) but tracks URL-presence newness from the LIVE content. Without that +// live-href signal a fast clear-then-repaste of the same URL inside the debounce +// window never commits an empty debounced set, so the resolver never sees the +// URL leave and never refetches — and the stale snapshot tag built from the +// pre-clear metadata stays sendable. This drives the real composer hook through +// that gesture and asserts the stale tag is not sendable and a fresh fetch is +// forced. (PR #5510, second follow-up.) + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +/** @type {Map Promise>} */ +const ipcHandlers = new Map(); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); + dom.window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + const handler = ipcHandlers.get(cmd); + return handler + ? handler(args) + : Promise.reject(new Error(`unmocked Tauri command: ${cmd}`)); + }, + transformCallback: () => Math.random(), + }; +}); + +after(() => dom.window.close()); + +const HREF = "https://example.com/composer-re-entry"; +const DEBOUNCE_WAIT_MS = 400; // > LINK_PREVIEW_DEBOUNCE_MS (350) + +function metadata(overrides = {}) { + return { + title: "A story", + siteName: "Example", + description: "Story description", + imageDataUrl: null, + imageDomain: null, + imageFetchState: "none", + imageRetryAfterMs: null, + faviconDataUrl: null, + ...overrides, + }; +} + +test("composer input versions retain only active hrefs while re-entry advances", async () => { + const { updateComposerLinkPreviewInput } = await import( + "./useComposerLinkPreviews.tsx" + ); + const secondHref = "https://example.com/second"; + let input = { + content: "", + hrefs: new Set(), + hrefVersions: new Map(), + nextHrefVersion: 0, + }; + + input = updateComposerLinkPreviewInput(input, `see ${HREF}`); + const firstVersion = input.hrefVersions.get(HREF); + assert.equal(input.hrefVersions.size, 1); + + input = updateComposerLinkPreviewInput(input, `see ${secondHref}`); + assert.deepEqual( + [...input.hrefVersions.keys()], + [secondHref], + "departed href history is pruned instead of retained for the composer lifetime", + ); + + input = updateComposerLinkPreviewInput(input, `see ${HREF}`); + assert.deepEqual([...input.hrefVersions.keys()], [HREF]); + assert.ok( + input.hrefVersions.get(HREF) > firstVersion, + "a re-entered href receives a new monotonic version after its old entry was pruned", + ); +}); + +test("composer forces a refetch and drops the stale tag on a fast clear+re-paste of the same URL", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { resetLinkPreviewMetadataCache } = await import( + "@/shared/lib/useResolvedLinkPreviews.ts" + ); + const { updateComposerLinkPreviewInput, useComposerLinkPreviews } = + await import("./useComposerLinkPreviews.tsx"); + + resetLinkPreviewMetadataCache(); + ipcHandlers.clear(); + + // Relay origin for media URLs (composer fetches it once on mount). + ipcHandlers.set("get_relay_http_url", () => + Promise.resolve("https://relay.example.com"), + ); + // Media upload always succeeds instantly so a snapshot tag can be built. + ipcHandlers.set("upload_media_bytes", () => + Promise.resolve({ + url: "https://relay.example.com/media/x", + sha256: "deadbeef", + size: 1, + type: "image/png", + uploaded: 0, + }), + ); + // Call 1 (initial paste) resolves to a transient failure -> sendable fallback, + // instantly. Call 2 (the forced re-entry refetch) resolves to a success but + // only when we release `resolveRefetch`, so the test can observe the + // intermediate window where the stale tag is gone and Send is held pending + // BEFORE the fresh result lands (in the app the refetch is a real network + // round-trip; instant resolution would collapse the window under test). + let fetchCalls = 0; + let resolveRefetch; + ipcHandlers.set("fetch_link_preview_metadata", () => { + fetchCalls += 1; + if (fetchCalls === 1) { + return Promise.resolve( + metadata({ + imageFetchState: "transient_failure", + imageRetryAfterMs: 900_000, + }), + ); + } + return new Promise((resolve) => { + resolveRefetch = () => + resolve( + metadata({ + imageDataUrl: "data:image/png;base64,QQ==", + imageDomain: "images.example.com", + imageFetchState: "image", + }), + ); + }); + }); + + const flushDebounceAndSettle = async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_WAIT_MS)); + }); + }; + + try { + let previewInput = updateComposerLinkPreviewInput( + { + content: "", + hrefs: new Set(), + hrefVersions: new Map(), + nextHrefVersion: 0, + }, + `see ${HREF}`, + ); + const { result, rerender, unmount } = renderHook( + ({ content, hrefVersions }) => + useComposerLinkPreviews(content, true, hrefVersions), + { initialProps: previewInput }, + ); + + // 1. Paste settles to a transient-failure fallback with a ready snapshot tag. + await flushDebounceAndSettle(); + assert.equal(fetchCalls, 1); + assert.equal( + result.current.getReadyTags().length, + 1, + "the transient fallback produced a sendable snapshot tag", + ); + assert.equal(result.current.hasPendingSnapshots, false); + + // 2. Fast gesture: clear the URL, then re-paste the SAME URL, with both + // editor updates folded into one React batch. The debounced candidates + // and the committed live href set therefore never observe empty. + // Model two editor onUpdate calls folded into one React batch. The final + // href set equals the previous commit, but the update-boundary version has + // advanced because the URL left and re-entered between those updates. + await act(async () => { + previewInput = updateComposerLinkPreviewInput(previewInput, "see "); + previewInput = updateComposerLinkPreviewInput( + previewInput, + `see ${HREF}`, + ); + rerender(previewInput); + }); + + // 3a. The stale tag must be gone AS SOON AS the same URL re-enters — this is + // the core invariant and it holds synchronously (render-time detection + // drops the tag and excludes the href from sendable output), so no timer + // needs to fire first. Send is held pending until a fresh tag lands. + await act(async () => {}); + assert.equal( + result.current.getReadyTags().length, + 0, + "stale snapshot tag must not be sendable the moment the URL re-enters", + ); + assert.equal( + result.current.hasPendingSnapshots, + true, + "Send must be held pending after a clear+re-paste", + ); + + // 3b. Once the debounce settles, the re-entry has forced a fresh fetch. It is + // still in flight (the deferred refetch has NOT resolved), so the stale + // tag stays gone and Send stays pending. Pre-fix the re-entry is + // invisible, so no refetch starts and this fails fast. + await flushDebounceAndSettle(); + assert.equal( + fetchCalls, + 2, + "the re-entry forced a fresh fetch (still in flight)", + ); + assert.equal( + result.current.getReadyTags().length, + 0, + "stale snapshot tag must not be sendable while the re-entry refetches", + ); + assert.equal( + result.current.hasPendingSnapshots, + true, + "Send must be held pending while the re-entry refetches", + ); + + // 4. The forced refetch resolves (success); a fresh tag becomes sendable and + // its media is the freshly-fetched image, not the pre-clear empty + // fallback (proving the tag was rebuilt from new metadata). + await act(async () => { + resolveRefetch(); + }); + await flushDebounceAndSettle(); + const [freshTag] = result.current.getReadyTags(); + assert.equal( + result.current.getReadyTags().length, + 1, + "a fresh sendable tag lands after the refetch", + ); + assert.ok( + freshTag?.includes("https://relay.example.com/media/x"), + "the sendable tag carries the freshly-fetched snapshot media", + ); + assert.equal(result.current.hasPendingSnapshots, false); + + unmount(); + } finally { + cleanup(); + ipcHandlers.clear(); + } +}); + +// A blocked re-entry can leave again before its forced refetch settles. The +// abandoned phase must not poison a later paste of the now-healthy cached result. +test("a removed blocked re-entry can later use metadata that resolved while absent", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { resetLinkPreviewMetadataCache } = await import( + "@/shared/lib/useResolvedLinkPreviews.ts" + ); + const { updateComposerLinkPreviewInput, useComposerLinkPreviews } = + await import("./useComposerLinkPreviews.tsx"); + + resetLinkPreviewMetadataCache(); + ipcHandlers.clear(); + ipcHandlers.set("get_relay_http_url", () => + Promise.resolve("https://relay.example.com"), + ); + ipcHandlers.set("upload_media_bytes", () => + Promise.resolve({ + url: "https://relay.example.com/media/fresh-after-absence", + sha256: "f8e5", + size: 1, + type: "image/png", + uploaded: 0, + }), + ); + + let fetchCalls = 0; + let resolveRefetch; + ipcHandlers.set("fetch_link_preview_metadata", () => { + fetchCalls += 1; + if (fetchCalls === 1) { + return Promise.resolve( + metadata({ + imageFetchState: "transient_failure", + imageRetryAfterMs: 900_000, + }), + ); + } + return new Promise((resolve) => { + resolveRefetch = () => + resolve( + metadata({ + imageDataUrl: "data:image/png;base64,QQ==", + imageDomain: "images.example.com", + imageFetchState: "image", + }), + ); + }); + }); + + const settle = async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_WAIT_MS)); + }); + }; + + try { + let previewInput = updateComposerLinkPreviewInput( + { + content: "", + hrefs: new Set(), + hrefVersions: new Map(), + nextHrefVersion: 0, + }, + `see ${HREF}`, + ); + const { result, rerender, unmount } = renderHook( + ({ content, hrefVersions }) => + useComposerLinkPreviews(content, true, hrefVersions), + { initialProps: previewInput }, + ); + + await settle(); + assert.equal(result.current.getReadyTags().length, 1); + + // Re-enter the cached negative and wait until its forced refetch is in flight. + await act(async () => { + previewInput = updateComposerLinkPreviewInput(previewInput, "see "); + previewInput = updateComposerLinkPreviewInput( + previewInput, + `see ${HREF}`, + ); + rerender(previewInput); + }); + await settle(); + assert.equal(fetchCalls, 2); + assert.equal(result.current.getReadyTags().length, 0); + assert.equal(result.current.hasPendingSnapshots, true); + + // Remove the blocked href, then let its refetch populate healthy metadata + // while no candidate is active. + await act(async () => { + previewInput = updateComposerLinkPreviewInput(previewInput, "see "); + rerender(previewInput); + }); + await settle(); + await act(async () => resolveRefetch()); + await settle(); + assert.equal(result.current.getReadyTags().length, 0); + + // A later paste should use the healthy result immediately after debounce; + // the abandoned "blocked" phase must not survive and suppress its tag. + await act(async () => { + previewInput = updateComposerLinkPreviewInput( + previewInput, + `see ${HREF}`, + ); + rerender(previewInput); + }); + await settle(); + const [freshTag] = result.current.getReadyTags(); + assert.equal( + fetchCalls, + 2, + "healthy metadata is preserved without a third fetch", + ); + assert.equal(result.current.getReadyTags().length, 1); + assert.ok( + freshTag?.includes("https://relay.example.com/media/fresh-after-absence"), + "the later paste becomes sendable with metadata resolved while absent", + ); + assert.equal(result.current.hasPendingSnapshots, false); + + unmount(); + } finally { + cleanup(); + ipcHandlers.clear(); + } +}); + +// ── Composer-hook regression: stale in-flight upload after re-entry ─────────── +// +// A pre-clear transient-fallback snapshot upload (U1) can still be in flight +// when the URL is cleared and re-pasted. The re-entry forces a refetch to fresh +// metadata, and the upload effect starts a fresh upload (U2). When the stale U1 +// finally settles it must NOT publish a snapshot tag built from the pre-clear +// metadata — even though its re-entry phase marker was already cleared once the +// refetch reached fresh ready. The per-href upload generation fence makes the +// stale completion a no-op, and the generation-aware dedup guard lets U2 start +// even while U1's slot is still occupied. Reverting either the generation fence +// in `.then` or the generation-aware dedup guard makes this fail (U1 publishes +// its stale favicon, or U2 never starts). (PR #5510, third follow-up.) +test("a stale in-flight upload cannot publish after the URL re-enters and a fresh upload wins", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { resetLinkPreviewMetadataCache } = await import( + "@/shared/lib/useResolvedLinkPreviews.ts" + ); + const { updateComposerLinkPreviewInput, useComposerLinkPreviews } = + await import("./useComposerLinkPreviews.tsx"); + + resetLinkPreviewMetadataCache(); + ipcHandlers.clear(); + + ipcHandlers.set("get_relay_http_url", () => + Promise.resolve("https://relay.example.com"), + ); + + // Gate the FIRST media upload (U1's favicon — its image is null in the + // transient fallback, so the favicon is U1's only upload) so it stays in + // flight across the clear + re-paste and the fresh-metadata resolution. Every + // later upload resolves instantly with a "fresh" URL. If a stale tag ever + // reaches the sendable set it will carry the STALE favicon URL, which the + // assertions forbid. + let uploadCalls = 0; + let releaseStaleUpload; + ipcHandlers.set("upload_media_bytes", () => { + uploadCalls += 1; + if (uploadCalls === 1) { + return new Promise((resolve) => { + releaseStaleUpload = () => + resolve({ + url: "https://relay.example.com/media/STALE", + sha256: "5741313", + size: 1, + type: "image/png", + uploaded: 0, + }); + }); + } + return Promise.resolve({ + url: "https://relay.example.com/media/FRESH", + sha256: "f8e5", + size: 1, + type: "image/png", + uploaded: 0, + }); + }); + + // Call 1 (initial paste): transient failure WITH a favicon, so it produces a + // sendable fallback whose upload (the favicon) is the gated U1. Call 2 (the + // forced re-entry refetch): a full success that only resolves when released, + // so the intermediate window (U1 in flight, refetch pending) is observable. + let fetchCalls = 0; + let resolveRefetch; + ipcHandlers.set("fetch_link_preview_metadata", () => { + fetchCalls += 1; + if (fetchCalls === 1) { + return Promise.resolve( + metadata({ + faviconDataUrl: "data:image/png;base64,QQ==", + imageFetchState: "transient_failure", + imageRetryAfterMs: 900_000, + }), + ); + } + return new Promise((resolve) => { + resolveRefetch = () => + resolve( + metadata({ + faviconDataUrl: "data:image/png;base64,Qg==", + imageDataUrl: "data:image/png;base64,Qw==", + imageDomain: "images.example.com", + imageFetchState: "image", + }), + ); + }); + }); + + const flushDebounceAndSettle = async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_WAIT_MS)); + }); + }; + + try { + let previewInput = updateComposerLinkPreviewInput( + { + content: "", + hrefs: new Set(), + hrefVersions: new Map(), + nextHrefVersion: 0, + }, + `see ${HREF}`, + ); + const { result, rerender, unmount } = renderHook( + ({ content, hrefVersions }) => + useComposerLinkPreviews(content, true, hrefVersions), + { initialProps: previewInput }, + ); + + // 1. Paste settles to a transient-failure fallback. Its favicon upload (U1) + // is gated and stays in flight, so no sendable tag exists yet. + await flushDebounceAndSettle(); + assert.equal(fetchCalls, 1); + assert.equal( + uploadCalls, + 1, + "U1 (the stale fallback favicon) is in flight", + ); + assert.equal( + result.current.getReadyTags().length, + 0, + "U1 has not settled, so no snapshot tag is sendable yet", + ); + + // 2. Fast gesture: clear then re-paste the SAME URL inside the debounce. + await act(async () => { + previewInput = updateComposerLinkPreviewInput(previewInput, "see "); + previewInput = updateComposerLinkPreviewInput( + previewInput, + `see ${HREF}`, + ); + rerender(previewInput); + }); + + // 3. The re-entry forces a fresh fetch; resolve it to fresh success. The + // upload effect must start a FRESH upload (U2) even though U1 still holds + // the slot, then produce a fresh sendable tag. + await flushDebounceAndSettle(); + assert.equal(fetchCalls, 2, "the re-entry forced a fresh fetch"); + await act(async () => { + resolveRefetch(); + }); + await flushDebounceAndSettle(); + assert.ok( + uploadCalls >= 2, + "a fresh upload (U2) started despite U1 still holding the slot", + ); + const [freshTag] = result.current.getReadyTags(); + assert.equal( + result.current.getReadyTags().length, + 1, + "the fresh upload produced a sendable tag", + ); + assert.ok( + freshTag?.includes("https://relay.example.com/media/FRESH"), + "the sendable tag carries the freshly-uploaded media", + ); + assert.ok( + !freshTag?.includes("https://relay.example.com/media/STALE"), + "the sendable tag must not carry the stale pre-clear media", + ); + + // 4. Release the stale U1. Its completion must be a no-op: it cannot + // overwrite the fresh tag with one built from pre-clear metadata. + await act(async () => { + releaseStaleUpload(); + }); + await flushDebounceAndSettle(); + const [tagAfterStale] = result.current.getReadyTags(); + assert.equal( + result.current.getReadyTags().length, + 1, + "still exactly one sendable tag after the stale upload settles", + ); + assert.ok( + tagAfterStale?.includes("https://relay.example.com/media/FRESH") && + !tagAfterStale?.includes("https://relay.example.com/media/STALE"), + "the stale upload cannot publish its pre-clear tag after settling", + ); + + unmount(); + } finally { + cleanup(); + ipcHandlers.clear(); + } +}); + +// The ordinary production gesture starts from a mounted empty composer. Live +// href tracking must not mark the pasted href handled before the debounced +// candidate exists, or the eventual resolver pass will reuse a cached negative. +test("composer refetches a cached negative when a link is pasted into an empty draft", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { resetLinkPreviewMetadataCache } = await import( + "@/shared/lib/useResolvedLinkPreviews.ts" + ); + const { useComposerLinkPreviews } = await import( + "./useComposerLinkPreviews.tsx" + ); + + resetLinkPreviewMetadataCache(); + ipcHandlers.clear(); + ipcHandlers.set("get_relay_http_url", () => + Promise.resolve("https://relay.example.com"), + ); + ipcHandlers.set("upload_media_bytes", () => + Promise.resolve({ + url: "https://relay.example.com/media/fresh", + sha256: "f8e5", + size: 1, + type: "image/png", + uploaded: 0, + }), + ); + + let fetchCalls = 0; + ipcHandlers.set("fetch_link_preview_metadata", () => { + fetchCalls += 1; + return Promise.resolve( + fetchCalls === 1 + ? metadata({ + imageFetchState: "transient_failure", + imageRetryAfterMs: 900_000, + }) + : metadata({ + imageDataUrl: "data:image/png;base64,QQ==", + imageDomain: "images.example.com", + imageFetchState: "image", + }), + ); + }); + + try { + // Seed the shared cache with the negative result before the composer sees A. + const { useResolvedLinkPreviews } = await import( + "@/shared/lib/useResolvedLinkPreviews.ts" + ); + const cached = renderHook(() => + useResolvedLinkPreviews([ + { + kind: "generic-link", + href: HREF, + title: HREF, + provider: "example.com", + imageUrl: null, + }, + ]), + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + assert.equal(fetchCalls, 1, "the negative was cached before paste"); + cached.unmount(); + + const { result, rerender, unmount } = renderHook( + ({ content }) => useComposerLinkPreviews(content), + { initialProps: { content: "" } }, + ); + await act(async () => rerender({ content: `see ${HREF}` })); + assert.equal( + fetchCalls, + 1, + "debounce has not resolved the pasted href yet", + ); + assert.equal(result.current.hasPendingSnapshots, true); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_WAIT_MS)); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + assert.equal(fetchCalls, 2, "paste invalidated and refetched the negative"); + assert.equal(result.current.getReadyTags().length, 1); + unmount(); + } finally { + cleanup(); + ipcHandlers.clear(); + } +}); +// A concurrent render may execute the hook and then suspend before commit. No +// href presence, block, generation, or tag mutation from that abandoned render +// may affect the previously committed composer. +test("an abandoned concurrent render cannot invalidate the committed snapshot tag", async () => { + const React = await import("react"); + const { act, cleanup, render } = await import("@testing-library/react"); + const { resetLinkPreviewMetadataCache } = await import( + "@/shared/lib/useResolvedLinkPreviews.ts" + ); + const { useComposerLinkPreviews } = await import( + "./useComposerLinkPreviews.tsx" + ); + + resetLinkPreviewMetadataCache(); + ipcHandlers.clear(); + ipcHandlers.set("get_relay_http_url", () => + Promise.resolve("https://relay.example.com"), + ); + ipcHandlers.set("upload_media_bytes", () => + Promise.resolve({ + url: "https://relay.example.com/media/stable", + sha256: "57ab1e", + size: 1, + type: "image/png", + uploaded: 0, + }), + ); + ipcHandlers.set("fetch_link_preview_metadata", () => + Promise.resolve( + metadata({ + imageFetchState: "transient_failure", + imageRetryAfterMs: 900_000, + }), + ), + ); + + let latest; + const never = new Promise(() => {}); + function Suspender() { + throw never; + } + function Harness({ content, suspend }) { + latest = useComposerLinkPreviews(content); + return suspend ? React.createElement(Suspender) : null; + } + + try { + const view = render( + React.createElement(Harness, { content: `see ${HREF}`, suspend: false }), + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_WAIT_MS)); + }); + assert.equal(latest.getReadyTags().length, 1); + + // Render a clear that executes the hook but suspends before commit, then + // supersede it with the unchanged committed content. + await act(async () => { + React.startTransition(() => + view.rerender( + React.createElement(Harness, { content: "", suspend: true }), + ), + ); + await Promise.resolve(); + }); + await act(async () => { + view.rerender( + React.createElement(Harness, { + content: `see ${HREF}`, + suspend: false, + }), + ); + await Promise.resolve(); + }); + + assert.equal( + latest.getReadyTags().length, + 1, + "the committed tag remains sendable after the abandoned render", + ); + assert.equal(latest.hasPendingSnapshots, false); + view.unmount(); + } finally { + cleanup(); + ipcHandlers.clear(); + } +}); diff --git a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx index 3f251a719d1..3c845d057f7 100644 --- a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx +++ b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx @@ -1,13 +1,13 @@ import * as React from "react"; -import { ImageOff, LoaderCircle, X } from "lucide-react"; -import { toast } from "sonner"; +import { ImageOff, X } from "lucide-react"; -import { getRelayHttpUrl, uploadMediaBytes } from "@/shared/api/tauri"; +import { getRelayHttpUrl } from "@/shared/api/tauri"; import { extractSupportedLinkPreviews } from "@/shared/lib/linkPreview"; +import { isValidLinkPreviewSnapshotCanonicalUrl } from "@/shared/lib/linkPreviewSnapshot"; import { - buildLinkPreviewSnapshotTag, - isValidLinkPreviewSnapshotCanonicalUrl, -} from "@/shared/lib/linkPreviewSnapshot"; + invalidateLinkPreviewPreparation, + prepareLinkPreview, +} from "@/features/messages/lib/linkPreviewPreparationStore"; import { beginRelayOriginFetch, getCachedRelayOrigin, @@ -28,17 +28,17 @@ import { AttachmentTrigger, } from "@/shared/ui/attachment"; import { Button } from "@/shared/ui/button"; +import { Progress } from "@/shared/ui/progress"; +import { Skeleton } from "@/shared/ui/skeleton"; // Idle time after the last keystroke before link-preview resolution runs, so // typing a URL does not flicker a card per character (debounce, not throttle: // throttle would still fire mid-type). const LINK_PREVIEW_DEBOUNCE_MS = 350; -// Upper bound on how long Send stays disabled while a preview is still settling -// (metadata resolving, or snapshot media uploading). Past this the button -// re-enables even if the tag never lands, so a dead or slow link never traps -// the composer — the message then sends as a bare link. -const SNAPSHOT_SETTLE_DISABLE_CAP_MS = 2000; +// A preview stays pending until its metadata and snapshot media settle. The +// visible suppression control is the explicit escape for sending without +// previews; network timing must never silently change the submitted event. function previewHostname(href: string): string { try { @@ -54,8 +54,8 @@ function previewHostname(href: string): string { // debounce drops A, so keying off live hrefs is what stops "delete A, send // replacement text within the window" from leaking A's tag (and media refs) // onto a body that no longer contains A. When `suppressed`, emit only the -// "none" marker. Live hrefs without a ready tag (dead/slow link past the -// anti-trap cap) are omitted and the message sends as a bare link. +// "none" marker. An unsuppressed live href without a ready tag can only reach +// submit after a terminal miss; it is omitted and sends as a bare link. export function selectSubmitTags( liveHrefs: readonly string[], tagsByHref: Record, @@ -69,9 +69,11 @@ export function selectSubmitTags( } function ComposerLinkPreviewCard({ + onSuppress, preview, tagReady, }: { + onSuppress: () => void; preview: ResolvedLinkPreview; tagReady: boolean; }) { @@ -86,120 +88,180 @@ function ComposerLinkPreviewCard({ // are complete as soon as the recognized entity card exists. const snapshotTagReady = Boolean(preview.snapshotReady && tagReady); const done = snapshotTagReady || isBuzzEntityPreview(preview); - let path = ""; - try { - const url = new URL(preview.href); - path = `${url.pathname}${url.search}`; - } catch {} return ( - - - {showImage ? ( - setFailedImageSrc(imageSrc ?? null)} - src={imageSrc ?? undefined} - /> - ) : preview.imageState === "pending" ? ( - - ) : preview.faviconDataUrl ? ( - - ) : ( - - - - {done ? preview.title : hostname} - - - {done - ? preview.provider || hostname - : path && path !== "/" - ? path - : preview.typeLabel} - - - - - Open {preview.title} - - - + {showImage ? ( + setFailedImageSrc(imageSrc ?? null)} + src={imageSrc ?? undefined} + /> + ) : !done ? ( +
+ ) : preview.faviconDataUrl ? ( + + ) : ( +
); } -function dataUrlBytes(dataUrl: string): Uint8Array | null { - const match = /^data:([^;,]+);base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl); - if (!match) return null; - try { - return Uint8Array.from(atob(match[2]), (char) => char.charCodeAt(0)); - } catch { - return null; +export interface ComposerLinkPreviewInput { + content: string; + hrefs: Set; + hrefVersions: Map; + nextHrefVersion: number; +} + +export function updateComposerLinkPreviewInput( + current: ComposerLinkPreviewInput, + content: string, +): ComposerLinkPreviewInput { + const nextHrefs = new Set( + extractSupportedLinkPreviews(content) + .filter((preview) => + preview.href.startsWith("buzz://") + ? true + : isValidLinkPreviewSnapshotCanonicalUrl(preview.href), + ) + .map((preview) => preview.href), + ); + const nextVersions = new Map(); + let nextHrefVersion = current.nextHrefVersion; + for (const href of nextHrefs) { + if (current.hrefs.has(href)) { + const version = current.hrefVersions.get(href); + if (version !== undefined) nextVersions.set(href, version); + continue; + } + nextHrefVersion += 1; + nextVersions.set(href, nextHrefVersion); } + return { + content, + hrefs: nextHrefs, + hrefVersions: nextVersions, + nextHrefVersion, + }; } -async function uploadDataUrl( - dataUrl: string | null | undefined, - filename: string, -) { - if (!dataUrl) return { url: "", sha256: "" }; - const bytes = dataUrlBytes(dataUrl); - if (!bytes) throw new Error("invalid preview media data"); - const uploaded = await uploadMediaBytes([...bytes], filename); - return { url: uploaded.url, sha256: uploaded.sha256 }; +export function useComposerLinkPreviewInput() { + const [input, setInput] = React.useState(() => ({ + content: "", + hrefs: new Set(), + hrefVersions: new Map(), + nextHrefVersion: 0, + })); + const update = React.useCallback( + (content: string) => + setInput((current) => updateComposerLinkPreviewInput(current, content)), + [], + ); + return [input, update] as const; } -// Upload one snapshot media (image or favicon) independently so a single -// failure degrades gracefully instead of dropping the whole preview: on -// failure we return empty url/sha256 (a valid "no media" snapshot field) and -// report which media failed so the caller can toast the user once. -async function uploadSnapshotMedia( - dataUrl: string | null | undefined, - filename: string, - label: "thumbnail" | "favicon", -): Promise<{ url: string; sha256: string; failed: null | typeof label }> { - try { - const { url, sha256 } = await uploadDataUrl(dataUrl, filename); - return { url, sha256, failed: null }; - } catch { - return { url: "", sha256: "", failed: dataUrl ? label : null }; - } +export function useManagedComposerLinkPreviews(enabled = true) { + const [input, updateContent] = useComposerLinkPreviewInput(); + return { + ...useComposerLinkPreviews(input.content, enabled, input.hrefVersions), + updateContent, + }; } -export function useComposerLinkPreviews(content: string, enabled = true) { +export function useComposerLinkPreviews( + content: string, + enabled = true, + liveHrefVersions?: ReadonlyMap, +) { const [suppressed, setSuppressed] = React.useState(false); // Debounce the content that drives resolution so typing a URL character by // character does not churn a new candidate href (and a flickering card) per - // keystroke. `content` is the live editor value; `debounced` is what actually - // resolves. A fast paste-and-Enter before the debounce fires is held by - // `hasUnresolvedLiveCandidates` below, which keeps Send disabled until the - // live candidates resolve — so no synchronous flush is needed at submit. + // keystroke. `content` is the live editor value; `debounced` drives the + // speculative composer card. Submit independently freezes live candidates, + // so a fast paste-and-Enter promotes the exact URL without waiting here. const [debounced, setDebounced] = React.useState(content); const debouncedRef = React.useRef(debounced); debouncedRef.current = debounced; @@ -226,16 +288,27 @@ export function useComposerLinkPreviews(content: string, enabled = true) { () => extractCandidates(debounced), [extractCandidates, debounced], ); - // Supported candidates in the LIVE content. When these differ from what has - // resolved (debounce not yet fired after a paste/keystroke), Send must still - // treat the preview as pending so a fast Enter cannot ship a bare link ahead - // of resolution. - const liveCandidatesRef = React.useRef([]); - liveCandidatesRef.current = extractCandidates(content).map( - (preview) => preview.href, + const liveCandidates = React.useMemo( + () => extractCandidates(content).map((preview) => preview.href), + [content, extractCandidates], ); + const liveCandidatesKey = liveCandidates.join("\n"); + const liveHrefVersionsKey = liveCandidates + .map((href) => `${href}\0${liveHrefVersions?.get(href) ?? ""}`) + .join("\n"); + // Async completion and submit paths may only observe committed editor state. + // A render-time ref write can leak an abandoned concurrent render. + const liveCandidatesRef = React.useRef([]); + React.useLayoutEffect(() => { + liveCandidatesRef.current = liveCandidates; + }, [liveCandidates]); const resolvedPreviews = useResolvedLinkPreviews( suppressed ? [] : candidates, + { + refetchNewNegatives: true, + liveHrefs: liveCandidates, + liveHrefVersions, + }, ); // Entity links resolve to null metadata when the relay lookup has nothing // for them; keep their safe fallback cards rather than dropping them. @@ -246,7 +319,7 @@ export function useComposerLinkPreviews(content: string, enabled = true) { // Clear a "hide previews" suppression as soon as the LIVE draft has no // supported candidates — not the debounced set, whose lag would otherwise let // a clear-then-retype race keep suppression stuck on after the draft changed. - const liveCandidatesEmpty = liveCandidatesRef.current.length === 0; + const liveCandidatesEmpty = liveCandidates.length === 0; React.useEffect(() => { if (liveCandidatesEmpty) setSuppressed(false); }, [liveCandidatesEmpty]); @@ -258,9 +331,77 @@ export function useComposerLinkPreviews(content: string, enabled = true) { readyTagsByHrefRef.current = readyTags; const suppressedRef = React.useRef(suppressed); suppressedRef.current = suppressed; - const uploadsRef = React.useRef(new Set()); + // Track composer incarnations independently from the shared preparation + // store. Re-entry must supersede work built from the previous incarnation, + // even if React batched leave+enter into one commit. + const preparationGenerationRef = React.useRef(new Map()); + const committedLiveHrefsRef = React.useRef>(new Set()); + const committedLiveHrefVersionsRef = React.useRef>( + new Map(), + ); + const reenteringHrefsRef = React.useRef< + Map + >(new Map()); const activeHrefsRef = React.useRef(new Set()); - activeHrefsRef.current = new Set(candidates.map((preview) => preview.href)); + const reenteredLiveHrefs = liveCandidates.filter((href) => { + const version = liveHrefVersions?.get(href); + return version === undefined + ? !committedLiveHrefsRef.current.has(href) + : committedLiveHrefVersionsRef.current.get(href) !== version; + }); + const staleReenteredHrefs = reenteredLiveHrefs.filter( + (href) => + !reenteringHrefsRef.current.has(href) && + previews.some( + (preview) => + preview.href === href && + preview.snapshotReady && + preview.imageState === "fallback", + ), + ); + const staleReenteredKey = staleReenteredHrefs.join("\n"); + + // biome-ignore lint/correctness/useExhaustiveDependencies: stable keys represent the committed live/version/stale sets. + React.useLayoutEffect(() => { + const previous = committedLiveHrefsRef.current; + const active = new Set(liveCandidates); + activeHrefsRef.current = active; + committedLiveHrefsRef.current = active; + committedLiveHrefVersionsRef.current = new Map(liveHrefVersions); + + for (const href of previous) { + if (active.has(href)) continue; + reenteringHrefsRef.current.delete(href); + preparationGenerationRef.current.set( + href, + (preparationGenerationRef.current.get(href) ?? 0) + 1, + ); + } + + if (staleReenteredHrefs.length === 0) return; + for (const href of staleReenteredHrefs) { + reenteringHrefsRef.current.set(href, "blocked"); + preparationGenerationRef.current.set( + href, + (preparationGenerationRef.current.get(href) ?? 0) + 1, + ); + invalidateLinkPreviewPreparation(href); + } + const drop = new Set(staleReenteredHrefs); + setReadyTags((current) => { + let changed = false; + const next = { ...current }; + for (const href of drop) { + if (!(href in next)) continue; + delete next[href]; + changed = true; + } + return changed ? next : current; + }); + }, [liveCandidatesKey, liveHrefVersionsKey, staleReenteredKey]); + + const isHrefReentering = (href: string) => + reenteringHrefsRef.current.has(href) || staleReenteredHrefs.includes(href); React.useEffect(() => { if (getCachedRelayOrigin()) return; @@ -281,123 +422,75 @@ export function useComposerLinkPreviews(content: string, enabled = true) { React.useEffect(() => { for (const preview of previews) { - if ( - !preview.snapshotReady || - readyTags[preview.href] || - uploadsRef.current.has(preview.href) - ) - continue; - uploadsRef.current.add(preview.href); - // Upload image and favicon independently so one failure degrades to the - // surviving media instead of dropping the whole preview. A snapshot tag - // with empty media fields is valid (renders as text + favicon, or - // text-only), so a partial or total media failure still ships a real - // inline preview and the card never spins forever. - const uploadPromise = Promise.all([ - uploadSnapshotMedia( - preview.imageDataUrl, - "link-preview-image.png", - "thumbnail", - ), - uploadSnapshotMedia( - preview.faviconDataUrl, - "link-preview-favicon.png", - "favicon", - ), - ]) - .then(([image, favicon]) => { - if (!activeHrefsRef.current.has(preview.href)) return; - const failedMedia = [image.failed, favicon.failed].filter( - (label): label is "thumbnail" | "favicon" => label !== null, - ); - if (failedMedia.length > 0) { - toast.error( - `Something went wrong with the ${failedMedia.join(" and ")}`, - ); - } - const tag = buildLinkPreviewSnapshotTag({ - canonicalUrl: preview.href, - title: preview.title, - siteName: preview.provider, - description: preview.description ?? "", - imageUrl: image.url, - imageSha256: image.sha256, - faviconUrl: favicon.url, - faviconSha256: favicon.sha256, - }); - if (!tag) return; - // Update the ref alongside state so a submit reading - // `readyTagsByHrefRef` sees the tag before the next render commits. - readyTagsByHrefRef.current = { - ...readyTagsByHrefRef.current, - [preview.href]: tag, - }; - setReadyTags((current) => ({ ...current, [preview.href]: tag })); - }) - .finally(() => { - uploadsRef.current.delete(preview.href); - }); - void uploadPromise; + const phase = reenteringHrefsRef.current.get(preview.href); + if (phase !== undefined) { + if (!preview.snapshotReady) { + reenteringHrefsRef.current.set(preview.href, "refetching"); + continue; + } + if (phase === "blocked") continue; + reenteringHrefsRef.current.delete(preview.href); + } + if (!preview.snapshotReady || readyTags[preview.href]) continue; + const generation = + preparationGenerationRef.current.get(preview.href) ?? 0; + void prepareLinkPreview(preview).then((tag) => { + if ( + !tag || + suppressedRef.current || + !activeHrefsRef.current.has(preview.href) || + reenteringHrefsRef.current.has(preview.href) || + (preparationGenerationRef.current.get(preview.href) ?? 0) !== + generation + ) { + return; + } + readyTagsByHrefRef.current = { + ...readyTagsByHrefRef.current, + [preview.href]: tag, + }; + setReadyTags((current) => ({ ...current, [preview.href]: tag })); + }); } }, [previews, readyTags]); readyTagsRef.current = suppressed ? [["link-preview", "none"]] : candidates.flatMap((candidate) => - readyTags[candidate.href] ? [readyTags[candidate.href]] : [], + readyTags[candidate.href] && !isHrefReentering(candidate.href) + ? [readyTags[candidate.href]] + : [], ); - // A preview is "settling" from paste until its sendable tag exists: metadata - // is still resolving, or it resolved and the snapshot media is uploading. - // Send stays disabled across the whole window so the button never flickers - // ready -> not-ready -> ready (buzz:// links never snapshot, so they never - // report settling). `imageState === "none"` is terminal (no snapshot), so it - // does not block. See the disable cap below for the dead/slow-link escape. + // Expose speculative preparation state for card treatment and tests. It no + // longer gates Submit: the send flow promotes unfinished work into the + // navigation-safe preparation store. const hasResolvingSnapshots = !suppressed && previews.some( (preview) => !preview.href.startsWith("buzz://") && (preview.imageState === "pending" || + isHrefReentering(preview.href) || (preview.snapshotReady && !readyTags[preview.href])), ); - // A supported link in the LIVE content that resolution has not caught up to - // yet (debounce pending, or resolved for an older revision) also counts as - // settling — otherwise a paste-and-immediate-Enter would ship a bare link - // before resolution even starts. buzz:// links never snapshot, so ignore them. + // Include live candidates not reached by the debounce yet so the composer + // accurately reports whether its visible generation is still catching up. const hasUnresolvedLiveCandidates = !suppressed && - liveCandidatesRef.current.some( + liveCandidates.some( (href) => !href.startsWith("buzz://") && !readyTags[href] && !candidates.some((candidate) => candidate.href === href), ); - const hasSettlingSnapshots = + const hasPendingSnapshots = hasResolvingSnapshots || hasUnresolvedLiveCandidates; - // Re-enable Send once the disable cap elapses even if a preview is still - // settling, so a link whose metadata or upload stalls never traps the - // composer. Resets whenever settling ends or the live candidate set changes. - const [settleDisableExpired, setSettleDisableExpired] = React.useState(false); - const liveCandidatesKey = liveCandidatesRef.current.join("\n"); - // biome-ignore lint/correctness/useExhaustiveDependencies: liveCandidatesKey intentionally restarts the anti-trap cap when the link set changes while still settling, so a replaced/added link gets a fresh disable window rather than inheriting the prior link's near-expired timer. - React.useEffect(() => { - if (!hasSettlingSnapshots) { - setSettleDisableExpired(false); - return; - } - setSettleDisableExpired(false); - const timer = window.setTimeout( - () => setSettleDisableExpired(true), - SNAPSHOT_SETTLE_DISABLE_CAP_MS, - ); - return () => window.clearTimeout(timer); - }, [hasSettlingSnapshots, liveCandidatesKey]); - const hasPendingSnapshots = hasSettlingSnapshots && !settleDisableExpired; - // Ref mirror so a synchronous submit guard can read the pending state on any - // entry point (Enter, form, auto-submit), not just the reactive button prop. + // Ref mirror retained for consumers that need an imperative status read. const hasPendingSnapshotsRef = React.useRef(hasPendingSnapshots); hasPendingSnapshotsRef.current = hasPendingSnapshots; - const hideAll = React.useCallback(() => setSuppressed(true), []); + const hideAll = React.useCallback(() => { + setSuppressed(true); + }, []); const previewList = previews.length ? (
-
- - {previews.map((preview) => ( - - ))} - - -
+ + {previews.map((preview) => ( + + ))} +
) : null; - // Snapshot tags for a submit, read synchronously at submit start from the - // LIVE candidate set (liveCandidatesRef) via `selectSubmitTags` — so the tags - // always correspond to the content actually being sent, never a debounced set - // that still holds a just-removed URL. No await: Send is disabled until every - // settling preview has its tag (or the anti-trap cap fires), so at submit time - // the tags that will ever exist already exist. + // Snapshot tags already available at submit, selected from the LIVE candidate + // set so a debounced, just-removed URL can never leak into the event. The send + // flow promotes any missing candidates and ignores this partial set. const getReadyTags = React.useCallback( () => selectSubmitTags( - liveCandidatesRef.current, + liveCandidatesRef.current.filter( + (href) => !reenteringHrefsRef.current.has(href), + ), readyTagsByHrefRef.current, suppressedRef.current, ), [], ); + const getLiveCandidates = React.useCallback( + () => extractCandidates(content), + [content, extractCandidates], + ); return { previewList, + getLiveCandidates, getReadyTags, hasPendingSnapshots, hasPendingSnapshotsRef, diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts index b2eb3893b7f..4bd87c15d64 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -1,6 +1,10 @@ import type { ManagedAgent } from "@/shared/api/types"; -import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import { + type ImetaMedia, + mergeOutgoingTags, +} from "@/features/messages/lib/imetaMediaMarkdown"; import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; +import type { PreparedBackgroundLinkPreviews } from "@/features/messages/lib/linkPreviewPreparationStore"; import type { DraftMentionRef } from "@/features/messages/lib/useDrafts"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { MENTION_REFERENCE_TAG } from "@/shared/lib/resolveMentionNames"; @@ -17,6 +21,7 @@ export type PendingNonMemberMentionSend = { mentionPubkeys: string[]; nonMemberPubkeys: string[]; outgoingTags?: string[][]; + preparedLinkPreviews?: PreparedBackgroundLinkPreviews | null; preparedManagedAgents?: ManagedAgent[]; readyAgentPubkeys?: string[]; savedContent: string; @@ -37,6 +42,7 @@ export type SendMessageWithMentionFlowInput = { pendingImeta: ImetaMedia[]; queuedAttachments?: QueuedMediaAttachment[]; linkPreviewTags?: string[][]; + preparedLinkPreviews?: PreparedBackgroundLinkPreviews | null; sentDraftKey: string | null | undefined; recoveryDraftKey: string | null | undefined; spoileredAttachmentUrls?: ReadonlySet; @@ -45,6 +51,21 @@ export type SendMessageWithMentionFlowInput = { audienceRevision?: number | null; }; +export async function resolvePreviewTags( + draft: Pick, + mediaTags: string[][] | undefined, + outgoingTags: string[][] | undefined, +): Promise { + const result = await draft.preparedLinkPreviews?.promise; + if (result?.status === "cancelled") return null; + return ( + mergeOutgoingTags(mediaTags, [ + ...(outgoingTags ?? []), + ...(result?.tags ?? []), + ]) ?? [] + ); +} + export function mergeOutgoingTagsWithReferenceMentions( outgoingTags: string[][] | undefined, pubkeys: Iterable, diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 369974d4122..e322f91987d 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -27,11 +27,11 @@ import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmoj import { buildOutgoingMessage, type ImetaMedia, - mergeOutgoingTags, } from "@/features/messages/lib/imetaMediaMarkdown"; import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; import type { UseDraftsResult } from "@/features/messages/lib/useDrafts"; +import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews"; import { invokeTauri } from "@/shared/api/tauri"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types"; @@ -45,6 +45,7 @@ import { mergeOutgoingTagsWithReferenceMentions, type PendingNonMemberMentionSend, type SendMessageWithMentionFlowInput, + resolvePreviewTags, uniqueNormalizedPubkeys, } from "./useMentionSendFlow.helpers"; type UseMentionSendFlowOptions = { @@ -56,9 +57,7 @@ type UseMentionSendFlowOptions = { drafts: Pick; emojiAutocomplete: Pick; mentions: UseMentionsResult; - onPrepareSendChannel?: ( - additionalParticipantPubkeys?: string[], - ) => Promise; + onPrepareSendChannel?: (pubkeys?: string[]) => Promise; onSendRef: React.MutableRefObject< ( content: string, @@ -69,6 +68,7 @@ type UseMentionSendFlowOptions = { parentEventId: string | null; threadHeadId: string | null; } | null, + forceRest?: boolean, ) => Promise >; richText: Pick< @@ -125,9 +125,8 @@ export function useMentionSendFlow({ const isMentionSendPendingRef = React.useRef(false); const isCompleteSendPendingRef = React.useRef(false); const isMountedRef = React.useRef(false); + const activePreparedLinkPreviews = useActivePreparedLinkPreviews(); const previousChannelIdRef = React.useRef(channelId); - // Tracks the live channel so completeSend can ask "is the user still here?" - // without being frozen to the compose-time closure. const channelIdRef = React.useRef(channelId); channelIdRef.current = channelId; React.useEffect(() => { @@ -375,6 +374,10 @@ export function useMentionSendFlow({ return; } + const sendSignal = draft.preparedLinkPreviews?.signal; + const isSendCancelled = () => sendSignal?.aborted === true; + if (isSendCancelled()) return draft.preparedLinkPreviews?.release(); + isCompleteSendPendingRef.current = true; setIsCompleteSendPending(true); const preparedUpload = @@ -382,7 +385,7 @@ export function useMentionSendFlow({ ? prepareBackgroundMediaUpload(draft.queuedAttachments) : null; const persistPreflightDraft = () => { - if (!draft.recoveryDraftKey) return; + if (isSendCancelled() || !draft.recoveryDraftKey) return; drafts.persistDraft( draft.recoveryDraftKey, draft.savedContent, @@ -401,6 +404,7 @@ export function useMentionSendFlow({ const admittedMentionPubkeys = uniqueNormalizedPubkeys( await mentions.revalidateMentionPubkeys(mentionPubkeys), ); + if (isSendCancelled()) return; if (!isMountedRef.current) return persistPreflightDraft(); const admittedMentionPubkeySet = new Set(admittedMentionPubkeys); const readyAgentPubkeys = new Set( @@ -409,6 +413,7 @@ export function useMentionSendFlow({ ), ); const managedAgentsByPubkey = await getManagedAgentsByPubkey(); + if (isSendCancelled()) return; if (!isMountedRef.current) { persistPreflightDraft(); return; @@ -431,6 +436,7 @@ export function useMentionSendFlow({ let sendChannelId = draft.capturedChannelId; if (preparedAgentPubkeys.length > 0 && onPrepareSendChannel) { sendChannelId = await onPrepareSendChannel(preparedAgentPubkeys); + if (isSendCancelled()) return; if (!sendChannelId) { return; } @@ -448,6 +454,7 @@ export function useMentionSendFlow({ onPrepareSendChannel ? preparedAgentPubkeys : [], [...managedAgentsByPubkey.values()], ); + if (isSendCancelled()) return; if (!isMountedRef.current) { persistPreflightDraft(); return; @@ -469,7 +476,9 @@ export function useMentionSendFlow({ channelId: sendChannelId, agentPubkeys: preparedAgentPubkeys, }); + if (isSendCancelled()) return; } catch (error) { + if (isSendCancelled()) return; const message = `Could not add mentioned agent to the Huddle: ${getErrorMessage( error, "Huddle enrollment failed.", @@ -486,7 +495,7 @@ export function useMentionSendFlow({ ); const send = onSendRef.current; const persistCanceledDraft = () => { - if (!draft.recoveryDraftKey) return; + if (isSendCancelled() || !draft.recoveryDraftKey) return; const existing = drafts.loadDraft(draft.recoveryDraftKey); if ( existing && @@ -510,6 +519,7 @@ export function useMentionSendFlow({ ); }; const restoreComposerAfterFailure = () => { + if (isSendCancelled()) return; persistCanceledDraft(); const canRestoreCurrentComposer = isMountedRef.current && @@ -552,14 +562,16 @@ export function useMentionSendFlow({ ), ]), ); - const finalOutgoingTags = mergeOutgoingTags( + const finalOutgoingTags = await resolvePreviewTags( + draft, mediaTags, - outgoingTags ?? [], + outgoingTags, ); - if (signal?.aborted) return; + if (!finalOutgoingTags || signal?.aborted || isSendCancelled()) + return; const revalidatedMentionPubkeys = await mentions.revalidateMentionPubkeys(mentionPubkeys); - if (signal?.aborted) return; + if (signal?.aborted || isSendCancelled()) return; const revalidatedExplicitAgentPubkeys = filterEffectiveExplicitAgentPubkeys( draft.explicitAgentPubkeys, @@ -571,8 +583,9 @@ export function useMentionSendFlow({ finalOutgoingTags, sendChannelId, draft.capturedThreadContext, + draft.preparedLinkPreviews != null, ); - if (signal?.aborted) return; + if (signal?.aborted || isSendCancelled()) return; if (revalidatedExplicitAgentPubkeys.length > 0) { onSuccessfulExplicitAgentAudience?.({ channelId: sendChannelId ?? draft.capturedChannelId ?? "", @@ -614,9 +627,6 @@ export function useMentionSendFlow({ return; } } - // Replace the sent body directly with its final post-send state before - // the async network send starts. This avoids an intermediate blank frame - // for persistent audiences while preserving the ordinary empty state. if ( draft.capturedChannelId === channelIdRef.current || channelIdRef.current === null @@ -634,6 +644,10 @@ export function useMentionSendFlow({ } } } finally { + if (draft.preparedLinkPreviews) { + activePreparedLinkPreviews.delete(draft.preparedLinkPreviews); + } + draft.preparedLinkPreviews?.release(); if (!uploadStarted) preparedUpload?.cancel(); isCompleteSendPendingRef.current = false; if (isMountedRef.current) { @@ -660,51 +674,9 @@ export function useMentionSendFlow({ setSpoileredAttachmentUrls, hasUnsavedMedia, mentions.restoreDraftMentionRefs, + activePreparedLinkPreviews, ], ); - - const getNonMemberMentionPubkeys = React.useCallback( - (pubkeys: string[]) => { - if ( - channelType === null || - channelType === "dm" || - !mentions.hasResolvedMembers - ) { - return []; - } - - return uniqueNormalizedPubkeys(pubkeys).filter( - (pubkey) => !mentions.memberPubkeys.has(pubkey), - ); - }, - [channelType, mentions.hasResolvedMembers, mentions.memberPubkeys], - ); - - const getDmThreadAgentMentionError = React.useCallback( - ( - trimmed: string, - capturedThreadContext: SendMessageWithMentionFlowInput["capturedThreadContext"], - ) => - dmThreadAgentMentionError({ - trimmed, - isThreadReply: capturedThreadContext != null, - channelType, - extractMentionPersonas: mentions.extractMentionPersonas, - extractMentionPubkeys: mentions.extractMentionPubkeys, - isAgentPubkey: mentions.isAgentPubkey, - hasResolvedMembers: mentions.hasResolvedMembers, - memberPubkeys: mentions.memberPubkeys, - }), - [ - channelType, - mentions.extractMentionPersonas, - mentions.extractMentionPubkeys, - mentions.hasResolvedMembers, - mentions.isAgentPubkey, - mentions.memberPubkeys, - ], - ); - const sendMessageWithMentionFlow = React.useCallback( async ({ capturedChannelId, @@ -712,6 +684,7 @@ export function useMentionSendFlow({ pendingImeta, queuedAttachments = [], linkPreviewTags = [], + preparedLinkPreviews = null, sentDraftKey, recoveryDraftKey, spoileredAttachmentUrls = new Set(), @@ -725,20 +698,34 @@ export function useMentionSendFlow({ isMentionSendPendingRef.current = true; setIsMentionSendPending(true); + const isSendCancelled = () => + preparedLinkPreviews?.signal.aborted === true; + let sendPromoted = false; + if (preparedLinkPreviews) { + activePreparedLinkPreviews.add(preparedLinkPreviews); + } try { - const dmThreadAgentMentionError = getDmThreadAgentMentionError( + if (isSendCancelled()) return; + const dmThreadAgentMentionErrorMessage = dmThreadAgentMentionError({ trimmed, - capturedThreadContext, - ); - if (dmThreadAgentMentionError) { - setNonMemberPromptError(dmThreadAgentMentionError); - toast.error(dmThreadAgentMentionError); + isThreadReply: capturedThreadContext != null, + channelType, + extractMentionPersonas: mentions.extractMentionPersonas, + extractMentionPubkeys: mentions.extractMentionPubkeys, + isAgentPubkey: mentions.isAgentPubkey, + hasResolvedMembers: mentions.hasResolvedMembers, + memberPubkeys: mentions.memberPubkeys, + }); + if (dmThreadAgentMentionErrorMessage) { + setNonMemberPromptError(dmThreadAgentMentionErrorMessage); + toast.error(dmThreadAgentMentionErrorMessage); return; } let effectiveChannelId = capturedChannelId; if (!effectiveChannelId && onPrepareSendChannel) { effectiveChannelId = await onPrepareSendChannel(); + if (isSendCancelled()) return; if (!effectiveChannelId) { return; } @@ -748,6 +735,7 @@ export function useMentionSendFlow({ trimmed, effectiveChannelId ?? "", ); + if (isSendCancelled()) return; if (personaMentionResult.errors.length > 0) { const message = personaMentionResult.errors.length === 1 @@ -778,7 +766,14 @@ export function useMentionSendFlow({ ...buildCustomEmojiTags(trimmed, customEmoji), ...linkPreviewTags, ]; - const nonMemberPubkeys = getNonMemberMentionPubkeys(pubkeys); + const nonMemberPubkeys = + channelType === null || + channelType === "dm" || + !mentions.hasResolvedMembers + ? [] + : uniqueNormalizedPubkeys(pubkeys).filter( + (pubkey) => !mentions.memberPubkeys.has(pubkey), + ); let promptNonMemberPubkeys = nonMemberPubkeys.filter( (pubkey) => !mentions.isManagedAgentPubkey(pubkey) && @@ -788,13 +783,11 @@ export function useMentionSendFlow({ if (promptNonMemberPubkeys.length > 0) { try { const managedAgentsByPubkey = await getManagedAgentsByPubkey(); + if (isSendCancelled()) return; promptNonMemberPubkeys = promptNonMemberPubkeys.filter( (pubkey) => !managedAgentsByPubkey.has(normalizePubkey(pubkey)), ); - } catch { - // Keep the hook-based managed-agent filtering even if the query - // fallback misses; ordinary non-members still get prompted. - } + } catch {} } const pendingDraft: PendingNonMemberMentionSend = { @@ -804,6 +797,7 @@ export function useMentionSendFlow({ mentionPubkeys: pubkeys, nonMemberPubkeys: promptNonMemberPubkeys, outgoingTags, + preparedLinkPreviews, preparedManagedAgents: personaMentionResult.agents, readyAgentPubkeys: channelType === "dm" && onPrepareSendChannel @@ -827,8 +821,15 @@ export function useMentionSendFlow({ return; } + sendPromoted = true; await completeSend(pendingDraft, pubkeys); } finally { + if (!sendPromoted) { + if (preparedLinkPreviews) { + activePreparedLinkPreviews.delete(preparedLinkPreviews); + } + preparedLinkPreviews?.release(); + } isMentionSendPendingRef.current = false; setIsMentionSendPending(false); } @@ -839,16 +840,17 @@ export function useMentionSendFlow({ createMentionedPersonaAgents, customEmoji, getManagedAgentsByPubkey, - getNonMemberMentionPubkeys, - getDmThreadAgentMentionError, + mentions.extractMentionPersonas, mentions.extractMentionPubkeys, + mentions.hasResolvedMembers, mentions.isAgentPubkey, mentions.isManagedAgentPubkey, + mentions.memberPubkeys, mentions.getDraftMentionRefs, onPrepareSendChannel, + activePreparedLinkPreviews, ], ); - const pendingNonMemberNames = React.useMemo(() => { if (!pendingNonMemberSend) return []; @@ -969,7 +971,6 @@ export function useMentionSendFlow({ setPendingNonMemberSend(null); setNonMemberPromptError(null); }, []); - return { isPreparingMentionSend: isMentionSendPending || @@ -977,7 +978,6 @@ export function useMentionSendFlow({ attachAgentMutation.isPending || createPersonaAgentMutation.isPending || startAgentMutation.isPending, - /** Spread straight into `NonMemberMentionDialog`. */ nonMemberPromptProps: { canInvite: canInviteNonMembers, error: nonMemberPromptError, diff --git a/desktop/src/features/presence/hooks.ts b/desktop/src/features/presence/hooks.ts index b1acacafbc5..f6718a6a5c4 100644 --- a/desktop/src/features/presence/hooks.ts +++ b/desktop/src/features/presence/hooks.ts @@ -9,6 +9,7 @@ import { getPresence } from "@/shared/api/tauri"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible"; import { + activePresencePubkeys, mergePresenceUpdate, parseLivePresenceEvent, presenceQueryWantsPubkey, @@ -16,6 +17,8 @@ import { PRESENCE_TTL_SECONDS, resolveAutomaticPresenceStatus, } from "@/features/presence/lib/presence"; +import { PresenceSubscriptionReconciler } from "@/features/presence/lib/presenceSubscriptionReconciler"; +import { openPresenceSubscription } from "@/shared/api/presenceRelaySubscription"; import type { PresenceLookup, PresenceStatus } from "@/shared/api/types"; const PRESENCE_STATUS_TICK_INTERVAL_MS = 30_000; @@ -113,18 +116,16 @@ export function usePresenceQuery( } /** - * Subscribe to kind:20001 presence events over WebSocket and update the - * TanStack Query presence cache in-place when updates arrive. Call once - * in AppShell. Uses setQueriesData for targeted per-pubkey updates without - * triggering refetches. Retries with exponential backoff on failure. + * Keep one live presence subscription scoped to pubkeys requested by active + * TanStack queries. Replacement subscriptions open before the old one closes, + * avoiding a live-update gap while query observers change. */ export function usePresenceSubscription() { const queryClient = useQueryClient(); React.useEffect(() => { - let unsub: (() => Promise) | null = null; let isCancelled = false; - let retryTimer: ReturnType | null = null; + let reconcileTimer: ReturnType | null = null; function handlePresenceEvent(event: { pubkey: string; content: string }) { if (isCancelled) return; @@ -141,28 +142,37 @@ export function usePresenceSubscription() { ); } - function subscribeWithRetry(attempt = 0) { - if (isCancelled) return; - void relayClient - .subscribeToPresenceUpdates(handlePresenceEvent) - .then((unsubFn) => { - if (isCancelled) { - void unsubFn(); - return; - } - unsub = unsubFn; - }) - .catch(() => { - if (!isCancelled) { - const delay = Math.min(1000 * 2 ** attempt, 30_000); - retryTimer = setTimeout( - () => subscribeWithRetry(attempt + 1), - delay, - ); - } - }); + const reconciler = new PresenceSubscriptionReconciler({ + open: (authors) => + openPresenceSubscription(authors, handlePresenceEvent, (...args) => + relayClient.subscribeLive(...args), + ), + }); + + function reconcileActiveQueries() { + reconciler.setAuthors( + activePresencePubkeys(queryClient.getQueryCache().getAll()), + ); } - subscribeWithRetry(); + + function scheduleReconcile() { + if (reconcileTimer) return; + reconcileTimer = setTimeout(() => { + reconcileTimer = null; + reconcileActiveQueries(); + }, 100); + } + + const unsubQueryCache = queryClient.getQueryCache().subscribe((event) => { + if ( + event.type === "observerAdded" || + event.type === "observerRemoved" || + event.type === "observerOptionsUpdated" + ) { + scheduleReconcile(); + } + }); + reconcileActiveQueries(); const unsubReconnect = relayClient.subscribeToReconnects(() => { if (!isCancelled) @@ -171,9 +181,10 @@ export function usePresenceSubscription() { return () => { isCancelled = true; + unsubQueryCache(); unsubReconnect(); - if (retryTimer) clearTimeout(retryTimer); - if (unsub) void unsub(); + if (reconcileTimer) clearTimeout(reconcileTimer); + reconciler.dispose(); }; }, [queryClient]); } diff --git a/desktop/src/features/presence/lib/presence.test.mjs b/desktop/src/features/presence/lib/presence.test.mjs index 90a6ac524ba..951d280b098 100644 --- a/desktop/src/features/presence/lib/presence.test.mjs +++ b/desktop/src/features/presence/lib/presence.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + activePresencePubkeys, mergePresenceUpdate, parseLivePresenceEvent, presenceQueryWantsPubkey, @@ -15,6 +16,22 @@ const WILL = "8e39cba681211b3782d0e4483e9343719b9b7be66515252da5491f26421896b1"; const OTHER = "44b8e82baaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +test("active presence authors are normalized, deduplicated, and sorted", () => { + const queries = [ + { queryKey: ["presence", WILL.toUpperCase(), OTHER], isActive: () => true }, + { queryKey: ["presence", WILL], isActive: () => true }, + ]; + assert.deepEqual(activePresencePubkeys(queries), [OTHER, WILL].sort()); +}); + +test("inactive and non-presence queries do not retain presence authors", () => { + const queries = [ + { queryKey: ["presence", WILL], isActive: () => false }, + { queryKey: ["profiles", OTHER], isActive: () => true }, + ]; + assert.deepEqual(activePresencePubkeys(queries), []); +}); + test("presence heartbeat is one minute with a three-window TTL", () => { assert.equal(PRESENCE_HEARTBEAT_INTERVAL_MS, 60_000); assert.equal(PRESENCE_TTL_SECONDS, 180); diff --git a/desktop/src/features/presence/lib/presence.ts b/desktop/src/features/presence/lib/presence.ts index 5b1fdc21c50..4f33a91a328 100644 --- a/desktop/src/features/presence/lib/presence.ts +++ b/desktop/src/features/presence/lib/presence.ts @@ -15,6 +15,19 @@ export function parseLivePresenceEvent(event: { return { pubkey: event.pubkey.toLowerCase(), status }; } +export function activePresencePubkeys( + queries: Array<{ queryKey: readonly unknown[]; isActive: () => boolean }>, +): string[] { + const pubkeys = new Set(); + for (const query of queries) { + if (!query.isActive() || query.queryKey[0] !== "presence") continue; + for (const value of query.queryKey.slice(1)) { + if (typeof value === "string" && value) pubkeys.add(value.toLowerCase()); + } + } + return [...pubkeys].sort(); +} + // Presence query keys are ["presence", ...normalizedSortedPubkeys]; a query // "wants" an update only for a pubkey it actually requested. export function presenceQueryWantsPubkey( diff --git a/desktop/src/features/presence/lib/presenceSubscriptionReconciler.test.mjs b/desktop/src/features/presence/lib/presenceSubscriptionReconciler.test.mjs new file mode 100644 index 00000000000..f86000dfbf1 --- /dev/null +++ b/desktop/src/features/presence/lib/presenceSubscriptionReconciler.test.mjs @@ -0,0 +1,251 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { PresenceSubscriptionReconciler } from "./presenceSubscriptionReconciler.ts"; + +const A = "a".repeat(64); +const B = "b".repeat(64); +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function deferred() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +test("normalizes demand and refuses to open an empty subscription", async () => { + const opened = []; + const reconciler = new PresenceSubscriptionReconciler({ + open: async (authors) => { + opened.push(authors); + return async () => {}; + }, + }); + + reconciler.setAuthors([]); + reconciler.setAuthors([B, A.toUpperCase(), A]); + await flush(); + assert.deepEqual(opened, [[A, B]]); + reconciler.dispose(); +}); + +test("opens replacement before closing the previous subscription", async () => { + const actions = []; + const reconciler = new PresenceSubscriptionReconciler({ + open: async (authors) => { + const key = authors.join(""); + actions.push(`open:${key}`); + return async () => actions.push(`close:${key}`); + }, + }); + + reconciler.setAuthors([A]); + await flush(); + reconciler.setAuthors([B]); + await flush(); + assert.deepEqual(actions, [`open:${A}`, `open:${B}`, `close:${A}`]); + reconciler.dispose(); +}); + +test("a stale async open is closed and never installed", async () => { + const first = deferred(); + const actions = []; + let opens = 0; + const reconciler = new PresenceSubscriptionReconciler({ + open: async (authors) => { + opens += 1; + const key = authors.join(""); + actions.push(`open:${key}`); + if (opens === 1) await first.promise; + return async () => actions.push(`close:${key}`); + }, + }); + + reconciler.setAuthors([A]); + await flush(); + reconciler.setAuthors([B]); + first.resolve(); + await flush(); + await flush(); + assert.deepEqual(actions, [`open:${A}`, `close:${A}`, `open:${B}`]); + reconciler.dispose(); +}); + +test("failed replacement preserves the previous subscription and retries", async () => { + const timers = []; + const actions = []; + let failB = true; + const reconciler = new PresenceSubscriptionReconciler({ + open: async (authors) => { + const key = authors.join(""); + actions.push(`open:${key}`); + if (key === B && failB) throw new Error("relay unavailable"); + return async () => actions.push(`close:${key}`); + }, + retryDelay: () => 1, + setTimer: (callback) => { + timers.push(callback); + return timers.length; + }, + clearTimer: () => {}, + }); + + reconciler.setAuthors([A]); + await flush(); + reconciler.setAuthors([B]); + await flush(); + assert.deepEqual(actions, [`open:${A}`, `open:${B}`]); + assert.equal(timers.length, 1); + + failB = false; + timers.shift()(); + await flush(); + assert.deepEqual(actions, [ + `open:${A}`, + `open:${B}`, + `open:${B}`, + `close:${A}`, + ]); + reconciler.dispose(); +}); + +test("rapid A to B to C keeps A until C is confirmed", async () => { + const openingB = deferred(); + const actions = []; + const reconciler = new PresenceSubscriptionReconciler({ + open: async (authors) => { + const key = authors.join(""); + actions.push(`open:${key}`); + if (key === B) await openingB.promise; + return async () => actions.push(`close:${key}`); + }, + }); + + reconciler.setAuthors([A]); + await flush(); + reconciler.setAuthors([B]); + await flush(); + reconciler.setAuthors(["c".repeat(64)]); + openingB.resolve(); + await flush(); + await flush(); + assert.deepEqual(actions, [ + `open:${A}`, + `open:${B}`, + `close:${B}`, + `open:${"c".repeat(64)}`, + `close:${A}`, + ]); + reconciler.dispose(); +}); + +test("rapid A to B to A closes stale B but retains current A", async () => { + const openingB = deferred(); + const actions = []; + const reconciler = new PresenceSubscriptionReconciler({ + open: async (authors) => { + const key = authors.join(""); + actions.push(`open:${key}`); + if (key === B) await openingB.promise; + return async () => actions.push(`close:${key}`); + }, + }); + + reconciler.setAuthors([A]); + await flush(); + reconciler.setAuthors([B]); + await flush(); + reconciler.setAuthors([A]); + openingB.resolve(); + await flush(); + assert.deepEqual(actions, [`open:${A}`, `open:${B}`, `close:${B}`]); + reconciler.dispose(); +}); + +test("prior close failure does not unseat a confirmed replacement", async () => { + const actions = []; + const reconciler = new PresenceSubscriptionReconciler({ + open: async (authors) => { + const key = authors.join(""); + actions.push(`open:${key}`); + return async () => { + actions.push(`close:${key}`); + if (key === A) throw new Error("close failed"); + }; + }, + }); + + reconciler.setAuthors([A]); + await flush(); + reconciler.setAuthors([B]); + await flush(); + reconciler.setAuthors([B]); + await flush(); + assert.deepEqual(actions, [`open:${A}`, `open:${B}`, `close:${A}`]); + reconciler.dispose(); +}); + +test("dispose closes current and a late replacement without double-closing current", async () => { + const openingB = deferred(); + const closes = { [A]: 0, [B]: 0 }; + const reconciler = new PresenceSubscriptionReconciler({ + open: async (authors) => { + const key = authors.join(""); + if (key === B) await openingB.promise; + return async () => { + closes[key] += 1; + }; + }, + }); + + reconciler.setAuthors([A]); + await flush(); + reconciler.setAuthors([B]); + await flush(); + reconciler.dispose(); + openingB.resolve(); + await flush(); + assert.deepEqual(closes, { [A]: 1, [B]: 1 }); +}); + +test("dispose during an in-flight open closes the late subscription", async () => { + const opening = deferred(); + let closeCount = 0; + const reconciler = new PresenceSubscriptionReconciler({ + open: async () => { + await opening.promise; + return async () => { + closeCount += 1; + }; + }, + }); + + reconciler.setAuthors([A]); + await flush(); + reconciler.dispose(); + opening.resolve(); + await flush(); + assert.equal(closeCount, 1); +}); + +test("clearing demand closes current without opening a replacement", async () => { + const actions = []; + const reconciler = new PresenceSubscriptionReconciler({ + open: async (authors) => { + const key = authors.join(""); + actions.push(`open:${key}`); + return async () => actions.push(`close:${key}`); + }, + }); + + reconciler.setAuthors([A]); + await flush(); + reconciler.setAuthors([]); + await flush(); + assert.deepEqual(actions, [`open:${A}`, `close:${A}`]); + reconciler.dispose(); +}); diff --git a/desktop/src/features/presence/lib/presenceSubscriptionReconciler.ts b/desktop/src/features/presence/lib/presenceSubscriptionReconciler.ts new file mode 100644 index 00000000000..2831785ccfb --- /dev/null +++ b/desktop/src/features/presence/lib/presenceSubscriptionReconciler.ts @@ -0,0 +1,138 @@ +export type PresenceSubscriptionClose = () => Promise; + +export interface PresenceSubscriptionReconcilerOptions { + open: (authors: string[]) => Promise; + retryDelay?: (attempt: number) => number; + setTimer?: ( + callback: () => void, + delayMs: number, + ) => ReturnType; + clearTimer?: (timer: ReturnType) => void; +} + +/** + * Reconciles a changing author set onto one live relay subscription. + * + * A replacement opens before the prior subscription closes, so demand changes + * never create a live-update gap. Opens that finish after demand changes are + * closed without becoming current. A failed replacement leaves the last good + * subscription serving its old author set while retrying with bounded backoff. + */ +export class PresenceSubscriptionReconciler { + private readonly open: PresenceSubscriptionReconcilerOptions["open"]; + private readonly retryDelay: (attempt: number) => number; + private readonly setTimer: NonNullable< + PresenceSubscriptionReconcilerOptions["setTimer"] + >; + private readonly clearTimer: NonNullable< + PresenceSubscriptionReconcilerOptions["clearTimer"] + >; + private desiredAuthors: string[] = []; + private desiredKey = ""; + private current: { key: string; close: PresenceSubscriptionClose } | null = + null; + private running = false; + private disposed = false; + private retryAttempt = 0; + private retryTimer: ReturnType | null = null; + + constructor(options: PresenceSubscriptionReconcilerOptions) { + this.open = options.open; + this.retryDelay = + options.retryDelay ?? + ((attempt) => Math.min(1000 * 2 ** attempt, 30_000)); + this.setTimer = options.setTimer ?? setTimeout; + this.clearTimer = options.clearTimer ?? clearTimeout; + } + + setAuthors(authors: string[]) { + if (this.disposed) return; + const normalized = [ + ...new Set(authors.map((author) => author.toLowerCase())), + ] + .filter(Boolean) + .sort(); + const key = normalized.join(","); + if (key === this.desiredKey) return; + + this.desiredAuthors = normalized; + this.desiredKey = key; + this.retryAttempt = 0; + this.cancelRetry(); + void this.reconcile(); + } + + dispose() { + if (this.disposed) return; + this.disposed = true; + this.cancelRetry(); + const current = this.current; + this.current = null; + if (current) void current.close().catch(() => {}); + } + + private currentKey() { + return this.current?.key ?? ""; + } + + private cancelRetry() { + if (this.retryTimer === null) return; + this.clearTimer(this.retryTimer); + this.retryTimer = null; + } + + private scheduleRetry() { + if (this.retryTimer !== null || this.disposed) return; + const delay = this.retryDelay(this.retryAttempt); + this.retryAttempt += 1; + this.retryTimer = this.setTimer(() => { + this.retryTimer = null; + void this.reconcile(); + }, delay); + } + + private async reconcile() { + if (this.running || this.disposed) return; + this.running = true; + try { + while (!this.disposed && this.currentKey() !== this.desiredKey) { + const nextAuthors = this.desiredAuthors; + const nextKey = this.desiredKey; + + if (nextAuthors.length === 0) { + const previous = this.current; + this.current = null; + if (previous) await previous.close().catch(() => {}); + continue; + } + + let nextClose: PresenceSubscriptionClose; + try { + nextClose = await this.open(nextAuthors); + } catch { + if (nextKey === this.desiredKey) this.scheduleRetry(); + return; + } + + if (this.disposed || nextKey !== this.desiredKey) { + await nextClose().catch(() => {}); + continue; + } + + const previous = this.current; + this.current = { key: nextKey, close: nextClose }; + this.retryAttempt = 0; + if (previous) await previous.close().catch(() => {}); + } + } finally { + this.running = false; + if ( + !this.disposed && + this.retryTimer === null && + this.currentKey() !== this.desiredKey + ) { + void this.reconcile(); + } + } + } +} diff --git a/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts b/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts new file mode 100644 index 00000000000..e900e123f52 --- /dev/null +++ b/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts @@ -0,0 +1,60 @@ +import * as React from "react"; + +import { pickProfileAgent } from "@/features/agents/lib/pickProfileAgent"; +import { useUserProfileQuery } from "@/features/profile/hooks"; +import { ownsAuthorAgent } from "@/features/profile/lib/identity"; +import { useOwnedManagedAgentPersonaId } from "@/features/profile/lib/useOwnedManagedAgentPersonaId"; +import type { ManagedAgent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export function useCanonicalManagedAgentProfile(input: { + currentPubkey: string | undefined; + managedAgents: readonly ManagedAgent[] | undefined; + personaId: string | undefined; + preserveRequestedInstance?: boolean; + pubkey: string | undefined; +}) { + const { + currentPubkey, + managedAgents, + personaId, + preserveRequestedInstance = false, + pubkey, + } = input; + const directManagedAgent = React.useMemo(() => { + if (!pubkey) return undefined; + const target = normalizePubkey(pubkey); + return managedAgents?.find( + (agent) => normalizePubkey(agent.pubkey) === target, + ); + }, [managedAgents, pubkey]); + const requestedProfileQuery = useUserProfileQuery(pubkey); + const historicalPersonaId = useOwnedManagedAgentPersonaId({ + agentPubkey: pubkey, + enabled: Boolean( + pubkey && + !directManagedAgent && + ownsAuthorAgent(requestedProfileQuery.data, currentPubkey), + ), + ownerPubkey: currentPubkey, + }); + const linkedPersonaId = + personaId ?? directManagedAgent?.personaId ?? historicalPersonaId; + const personaInstances = React.useMemo(() => { + if (!linkedPersonaId) { + return directManagedAgent ? [directManagedAgent] : []; + } + return (managedAgents ?? []).filter( + (agent) => agent.personaId === linkedPersonaId, + ); + }, [directManagedAgent, linkedPersonaId, managedAgents]); + const managedAgent = React.useMemo( + () => + preserveRequestedInstance && directManagedAgent + ? directManagedAgent + : (pickProfileAgent(personaInstances) ?? directManagedAgent), + [directManagedAgent, personaInstances, preserveRequestedInstance], + ); + + return { linkedPersonaId, managedAgent, personaInstances }; +} diff --git a/desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.test.mjs b/desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.test.mjs new file mode 100644 index 00000000000..4c6ec58f212 --- /dev/null +++ b/desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.test.mjs @@ -0,0 +1,218 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; +import { JSDOM } from "jsdom"; + +import { relayClient } from "@/shared/api/relayClient"; +import { + KIND_IA_ARCHIVE_REQUEST, + KIND_MANAGED_AGENT, +} from "@/shared/constants/kinds"; +import { + personaIdFromOwnedManagedAgentArchive, + personaIdFromOwnedManagedAgentEvent, + useOwnedManagedAgentPersonaId, +} from "./useOwnedManagedAgentPersonaId.ts"; + +const OWNER_SECRET = new Uint8Array(32); +OWNER_SECRET[31] = 1; +const OTHER_SECRET = new Uint8Array(32); +OTHER_SECRET[31] = 2; +const OWNER = getPublicKey(OWNER_SECRET); +const AGENT = "a".repeat(64); + +function managedAgentEvent({ + agentPubkey = AGENT, + content = JSON.stringify({ persona_id: "persona-reviewer" }), + secret = OWNER_SECRET, +} = {}) { + return finalizeEvent( + { + created_at: 1, + kind: KIND_MANAGED_AGENT, + tags: [["d", agentPubkey]], + content, + }, + secret, + ); +} + +function managedAgentArchive({ + agentPubkey = AGENT, + personaId = "persona-reviewer", + secret = OWNER_SECRET, +} = {}) { + return finalizeEvent( + { + created_at: 2, + kind: KIND_IA_ARCHIVE_REQUEST, + tags: [["p", agentPubkey]], + content: JSON.stringify({ persona_id: personaId }), + }, + secret, + ); +} + +test("resolves an owner-signed historical agent key to its persona", () => { + assert.equal( + personaIdFromOwnedManagedAgentEvent(managedAgentEvent(), OWNER, AGENT), + "persona-reviewer", + ); +}); + +test("resolves a deleted historical agent key from its owner-signed archive request", () => { + assert.equal( + personaIdFromOwnedManagedAgentArchive(managedAgentArchive(), OWNER, AGENT), + "persona-reviewer", + ); +}); + +test("rejects archive aliases with the wrong owner or target", () => { + assert.equal( + personaIdFromOwnedManagedAgentArchive( + managedAgentArchive({ secret: OTHER_SECRET }), + OWNER, + AGENT, + ), + null, + ); + assert.equal( + personaIdFromOwnedManagedAgentArchive( + managedAgentArchive({ agentPubkey: "b".repeat(64) }), + OWNER, + AGENT, + ), + null, + ); +}); + +test("rejects a managed-agent event from a different owner", () => { + assert.equal( + personaIdFromOwnedManagedAgentEvent( + managedAgentEvent({ secret: OTHER_SECRET }), + OWNER, + AGENT, + ), + null, + ); +}); + +test("rejects a managed-agent event for a different agent key", () => { + assert.equal( + personaIdFromOwnedManagedAgentEvent( + managedAgentEvent({ agentPubkey: "b".repeat(64) }), + OWNER, + AGENT, + ), + null, + ); +}); + +test("rejects empty or malformed persona ids", () => { + assert.equal( + personaIdFromOwnedManagedAgentEvent( + managedAgentEvent({ content: JSON.stringify({ persona_id: "" }) }), + OWNER, + AGENT, + ), + null, + ); + assert.equal( + personaIdFromOwnedManagedAgentEvent( + managedAgentEvent({ content: "not-json" }), + OWNER, + AGENT, + ), + null, + ); +}); + +test("still resolves the live record when the archive lookup fails", async () => { + const dom = new JSDOM("", { + url: "http://localhost", + }); + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const originalFetchFirstEvent = relayClient.fetchFirstEvent; + relayClient.fetchFirstEvent = async (filter) => { + if (filter.kinds?.includes(KIND_IA_ARCHIVE_REQUEST)) { + throw new Error("archive lookup unavailable"); + } + return managedAgentEvent(); + }; + + try { + const { result } = renderHook(() => + useOwnedManagedAgentPersonaId({ + agentPubkey: AGENT, + enabled: true, + ownerPubkey: OWNER, + }), + ); + await act(async () => { + await Promise.resolve(); + }); + assert.equal(result.current, "persona-reviewer"); + } finally { + cleanup(); + relayClient.fetchFirstEvent = originalFetchFirstEvent; + dom.window.close(); + } +}); + +test("does not expose a persona result for stale lookup inputs", async () => { + const dom = new JSDOM("", { + url: "http://localhost", + }); + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const originalFetchFirstEvent = relayClient.fetchFirstEvent; + relayClient.fetchFirstEvent = async (filter) => + filter.kinds?.includes(KIND_MANAGED_AGENT) ? managedAgentEvent() : null; + const observed = []; + + try { + const { result, rerender, unmount } = renderHook( + (props) => { + const personaId = useOwnedManagedAgentPersonaId(props); + observed.push(personaId); + return personaId; + }, + { + initialProps: { + agentPubkey: AGENT, + enabled: true, + ownerPubkey: OWNER, + }, + }, + ); + await act(async () => { + await Promise.resolve(); + }); + assert.equal(result.current, "persona-reviewer"); + + const switchIndex = observed.length; + rerender({ + agentPubkey: "b".repeat(64), + enabled: false, + ownerPubkey: OWNER, + }); + assert.deepEqual(observed.slice(switchIndex), [null]); + assert.equal(result.current, null); + unmount(); + } finally { + cleanup(); + relayClient.fetchFirstEvent = originalFetchFirstEvent; + dom.window.close(); + } +}); diff --git a/desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.ts b/desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.ts new file mode 100644 index 00000000000..7eef5b21e05 --- /dev/null +++ b/desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.ts @@ -0,0 +1,164 @@ +import * as React from "react"; +import { verifyEvent } from "nostr-tools/pure"; + +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import { + KIND_IA_ARCHIVE_REQUEST, + KIND_MANAGED_AGENT, +} from "@/shared/constants/kinds"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +type ManagedAgentEventContent = { + persona_id?: unknown; +}; + +function eventHasValidSignature(event: RelayEvent): boolean { + try { + return verifyEvent({ + id: event.id, + pubkey: event.pubkey, + created_at: event.created_at, + kind: event.kind, + tags: event.tags, + content: event.content, + sig: event.sig, + }); + } catch { + return false; + } +} + +function personaIdFromEventContent(event: RelayEvent): string | null { + try { + const content = JSON.parse(event.content) as ManagedAgentEventContent; + return typeof content.persona_id === "string" && + content.persona_id.trim().length > 0 + ? content.persona_id + : null; + } catch { + return null; + } +} + +export function personaIdFromOwnedManagedAgentEvent( + event: RelayEvent | null, + ownerPubkey: string, + agentPubkey: string, +): string | null { + if (!event || event.kind !== KIND_MANAGED_AGENT) return null; + + const owner = normalizePubkey(ownerPubkey); + const agent = normalizePubkey(agentPubkey); + if (!owner || !agent || normalizePubkey(event.pubkey) !== owner) return null; + if ( + !event.tags.some( + (tag) => tag[0] === "d" && normalizePubkey(tag[1] ?? "") === agent, + ) + ) { + return null; + } + if (!eventHasValidSignature(event)) return null; + + return personaIdFromEventContent(event); +} + +export function personaIdFromOwnedManagedAgentArchive( + event: RelayEvent | null, + ownerPubkey: string, + agentPubkey: string, +): string | null { + if (!event || event.kind !== KIND_IA_ARCHIVE_REQUEST) return null; + + const owner = normalizePubkey(ownerPubkey); + const agent = normalizePubkey(agentPubkey); + if (!owner || !agent || normalizePubkey(event.pubkey) !== owner) return null; + if ( + !event.tags.some( + (tag) => tag[0] === "p" && normalizePubkey(tag[1] ?? "") === agent, + ) + ) { + return null; + } + if (!eventHasValidSignature(event)) return null; + + return personaIdFromEventContent(event); +} + +type PersonaLookupResult = { + key: string; + personaId: string | null; +}; + +/** + * Resolve an owned historical agent key back to its persona. The live + * kind:30177 projection provides the alias before deletion; the owner-signed + * NIP-IA archive request preserves it after the projection is tombstoned. + */ +export function useOwnedManagedAgentPersonaId(input: { + agentPubkey: string | undefined; + enabled: boolean; + ownerPubkey: string | undefined; +}): string | null { + const { agentPubkey, enabled, ownerPubkey } = input; + const [result, setResult] = React.useState(null); + const normalizedOwner = normalizePubkey(ownerPubkey ?? ""); + const normalizedAgent = normalizePubkey(agentPubkey ?? ""); + const lookupKey = + enabled && normalizedOwner && normalizedAgent + ? `${normalizedOwner}:${normalizedAgent}` + : null; + + React.useEffect(() => { + let cancelled = false; + + if (!lookupKey) { + return () => { + cancelled = true; + }; + } + + void Promise.allSettled([ + relayClient.fetchFirstEvent({ + kinds: [KIND_MANAGED_AGENT], + authors: [normalizedOwner], + "#d": [normalizedAgent], + limit: 1, + }), + relayClient.fetchFirstEvent({ + kinds: [KIND_IA_ARCHIVE_REQUEST], + authors: [normalizedOwner], + "#p": [normalizedAgent], + limit: 1, + }), + ]).then(([managedAgentResult, archiveResult]) => { + if (cancelled) return; + const managedAgentEvent = + managedAgentResult.status === "fulfilled" + ? managedAgentResult.value + : null; + const archiveEvent = + archiveResult.status === "fulfilled" ? archiveResult.value : null; + setResult({ + key: lookupKey, + personaId: + personaIdFromOwnedManagedAgentEvent( + managedAgentEvent, + normalizedOwner, + normalizedAgent, + ) ?? + personaIdFromOwnedManagedAgentArchive( + archiveEvent, + normalizedOwner, + normalizedAgent, + ), + }); + }); + + return () => { + cancelled = true; + }; + }, [lookupKey, normalizedAgent, normalizedOwner]); + + return result?.key === lookupKey ? result.personaId : null; +} diff --git a/desktop/src/features/profile/ui/UserProfileAgentManagementRows.tsx b/desktop/src/features/profile/ui/UserProfileAgentManagementRows.tsx index 8c6b4138cd7..7b8a586224e 100644 --- a/desktop/src/features/profile/ui/UserProfileAgentManagementRows.tsx +++ b/desktop/src/features/profile/ui/UserProfileAgentManagementRows.tsx @@ -4,6 +4,7 @@ import { ArchiveRestore, CopyPlus, Download, + Sparkles, Trash2, type LucideIcon, } from "lucide-react"; @@ -30,6 +31,7 @@ export function UserProfileAgentManagementRows({ canDeleteAgent, isDeletePending, managedAgent, + onCreateCard, onDeleteAgent, onDuplicateAgent, onExportAgent, @@ -39,11 +41,14 @@ export function UserProfileAgentManagementRows({ canDeleteAgent: boolean; isDeletePending: boolean; managedAgent?: ManagedAgent; + /** Mint an agent trading card. Present only for owner-managed personas. */ + onCreateCard?: () => void; onDeleteAgent: () => void; onDuplicateAgent?: () => void; onExportAgent?: () => void; }) { if ( + !onCreateCard && !onDuplicateAgent && !onExportAgent && !canArchiveAgent && @@ -72,6 +77,15 @@ export function UserProfileAgentManagementRows({ testId="user-profile-export-agent-row" /> ) : null} + {onCreateCard ? ( + + ) : null} {canArchiveAgent ? ( ) : null} diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 91040a28e24..998ea3232a8 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -49,6 +49,7 @@ import { } from "@/features/profile/hooks"; import { ownsAuthorAgent } from "@/features/profile/lib/identity"; import { resolveProfileActivityAgent } from "@/features/profile/lib/profileActivityAgent"; +import { useCanonicalManagedAgentProfile } from "@/features/profile/lib/useCanonicalManagedAgentProfile"; import { AgentInstructionsFocusedView, ProfileSummaryView, @@ -65,13 +66,14 @@ import { useProfileAgentDeletion } from "@/features/profile/ui/UserProfilePanelD import { useProfileFieldBuckets } from "@/features/profile/ui/UserProfilePanelFields"; import { submitProfilePersonaDialog } from "@/features/profile/ui/UserProfilePanelPersonaSubmit"; import { - type CardMintTarget, + useCardMint, UserProfilePersonaDialogs, } from "@/features/profile/ui/UserProfilePersonaDialogs"; import { deriveProfileChannels, type ProfilePanelTab, type ProfilePanelView, + profilePanelTargetKey, resolveAgentInstruction, resolvePanelProfile, resolveProfileDisplayName, @@ -86,6 +88,7 @@ import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { AuxiliaryPanelBody } from "@/shared/layout/AuxiliaryPanel"; import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import type { AgentPersona, Channel, @@ -99,7 +102,6 @@ import { useProfileEditAgentRequest } from "@/features/profile/ui/useProfileEdit export type { ProfilePanelTab, ProfilePanelView }; export function UserProfilePanel({ - callerChannelId = null, canResetWidth, currentPubkey, isSinglePanelView = false, @@ -177,30 +179,27 @@ export function UserProfilePanel({ React.useState(null); const [personaToExportSnapshot, setPersonaToExportSnapshot] = React.useState(null); - const [cardMintTarget, setCardMintTarget] = - React.useState(null); + const [requestedInstancePubkey, setRequestedInstancePubkey] = React.useState< + string | null + >(null); + const preserveRequestedInstance = Boolean( + pubkey && + requestedInstancePubkey && + normalizePubkey(pubkey) === normalizePubkey(requestedInstancePubkey), + ); const personasQuery = usePersonasQuery(); const managedAgentsQuery = useManagedAgentsQuery({ enabled: true }); - const managedAgent = React.useMemo(() => { - const agents = managedAgentsQuery.data ?? []; - if (pubkey) { - const pubkeyLower = pubkey.toLowerCase(); - return agents.find((agent) => agent.pubkey.toLowerCase() === pubkeyLower); - } - if (persona) { - return agents.find((agent) => agent.personaId === persona.id); - } - return undefined; - }, [managedAgentsQuery.data, persona, pubkey]); - const personaInstances = React.useMemo(() => { - if (!managedAgent?.personaId) return managedAgent ? [managedAgent] : []; - return (managedAgentsQuery.data ?? []).filter( - (agent) => agent.personaId === managedAgent.personaId, - ); - }, [managedAgent, managedAgentsQuery.data]); + const { linkedPersonaId, managedAgent, personaInstances } = + useCanonicalManagedAgentProfile({ + currentPubkey, + managedAgents: managedAgentsQuery.data, + personaId: persona?.id, + preserveRequestedInstance, + pubkey, + }); const resolvedPersonaFromSource = React.useMemo(() => { - const personaId = persona?.id ?? managedAgent?.personaId; + const personaId = linkedPersonaId ?? managedAgent?.personaId; if (personaId) { const refreshedPersona = personasQuery.data?.find( (candidate) => candidate.id === personaId, @@ -218,14 +217,14 @@ export function UserProfilePanel({ return personasQuery.data?.find( (candidate) => candidate.id === managedAgent.personaId, ); - }, [managedAgent?.personaId, persona, personasQuery.data]); + }, [linkedPersonaId, managedAgent?.personaId, persona, personasQuery.data]); const profileIdentityKey = - pubkey ?? managedAgent?.pubkey ?? `persona:${persona?.id ?? "unknown"}`; + managedAgent?.pubkey ?? pubkey ?? `persona:${persona?.id ?? "unknown"}`; const resolvedPersona = useRetainedPersona( resolvedPersonaFromSource, profileIdentityKey, ); - const effectivePubkey = pubkey ?? managedAgent?.pubkey ?? null; + const effectivePubkey = managedAgent?.pubkey ?? pubkey ?? null; const pubkeyLower = effectivePubkey?.toLowerCase() ?? ""; const profileQuery = useUserProfileQuery(effectivePubkey ?? undefined); @@ -372,16 +371,16 @@ export function UserProfilePanel({ } return map; }, [channelsQuery.data]); - - const targetKey = - effectivePubkey ?? `persona:${resolvedPersona?.id ?? "unknown"}`; + const targetKey = profilePanelTargetKey(pubkey, persona?.id); const prevTargetKeyRef = React.useRef(targetKey); React.useEffect(() => { if (prevTargetKeyRef.current === targetKey) return; prevTargetKeyRef.current = targetKey; + if (preserveRequestedInstance) return; + setRequestedInstancePubkey(null); setView("summary", { replace: true }); setTab("info", { replace: true }); - }, [setTab, setView, targetKey]); + }, [preserveRequestedInstance, setTab, setView, targetKey]); const { canHuddle, canMessage, @@ -711,6 +710,7 @@ export function UserProfilePanel({ resolvedPersona, ); const canManagePersona = isOwner === true && resolvedPersona !== undefined; + const cardMint = useCardMint(resolvedPersona, managedAgent); const canDeletePersona = canManagePersona && !resolvedPersona?.sourceTeam; const canDeleteProfileAgent = isBot && @@ -788,7 +788,6 @@ export function UserProfilePanel({ canInstantiateAgent={canInstantiateAgent} canOpenAgentLogs={canOpenAgentLogs} canViewActivity={canViewActivity} - callerChannelId={callerChannelId} channelCount={profileChannels.length} channelIdToName={channelIdToName} channels={profileChannels} @@ -822,6 +821,7 @@ export function UserProfilePanel({ agentSettingsFields={agentSettingsFields} diagnosticsFields={diagnosticsFields} onAddToChannel={() => setAddToChannelOpen(true)} + onCreateCard={isBot && canManagePersona ? cardMint.create : undefined} onDeleteAgent={handleDeleteProfileAgent} onDuplicateAgent={ isBot && canManagePersona ? handleDuplicatePersona : undefined @@ -829,7 +829,11 @@ export function UserProfilePanel({ onExportAgent={ isBot && canManagePersona ? handleExportPersona : undefined } - onOpenInstance={(instancePubkey) => onOpenProfile?.(instancePubkey)} + onOpenInstance={(instancePubkey) => { + setRequestedInstancePubkey(instancePubkey); + onOpenProfile?.(instancePubkey); + setTab("runtime"); + }} onOpenActivity={handleOpenActivity} onOpenChannel={handleOpenChannel} onOpenDiagnostics={() => setView("diagnostics")} @@ -931,7 +935,7 @@ export function UserProfilePanel({ const personaDialogs = ( <> setCardMintTarget(null)} + onCloseCardMint={cardMint.close} onCloseDelete={() => setPersonaToDelete(null)} onCloseDialog={() => setPersonaDialogState(null)} onCloseExportSnapshot={() => setPersonaToExportSnapshot(null)} diff --git a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx index 06b3d16a965..9ecdc476a1e 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx @@ -4,6 +4,7 @@ import { ArrowUpRight, Cpu, Ear, + Fingerprint, Server, Terminal, UserRound, @@ -173,6 +174,7 @@ export function buildPublicFields({ testId="user-profile-copy-pubkey" /> ), + icon: Fingerprint, label: "Public key", testId: "user-profile-public-key", }); @@ -266,6 +268,7 @@ export function buildOwnerFields({ : (ownerProfilePubkey ?? ownerPubkey ?? ownerHandle ?? undefined), displayValue: ownerDisplayName, displayNode: {ownerDisplayName}, + icon: UserRound, label: "Managed by", onClick: ownerClickable && ownerProfilePubkey @@ -501,7 +504,7 @@ function ProfileFieldRow({ const content = ( <> - {variant === "default" && Icon ? ( + {Icon ? ( void; + /** Mint an agent trading card. Present only for owner-managed personas. */ + onCreateCard?: () => void; onDeleteAgent: () => void; onDuplicateAgent?: () => void; onExportAgent?: () => void; @@ -137,7 +132,6 @@ const PROFILE_HERO_PRESENCE_BADGE = { export function ProfileSummaryView({ activityAgent, - callerChannelId, canAddToChannel, canDeleteAgent, canEditAgent, @@ -176,6 +170,7 @@ export function ProfileSummaryView({ agentSettingsFields, diagnosticsFields, onAddToChannel, + onCreateCard, onDeleteAgent, onDuplicateAgent, onExportAgent, @@ -228,11 +223,10 @@ export function ProfileSummaryView({ const runtimeSettingsFields = agentSettingsFields.filter( (field) => !AGENT_DETAILS_FIELD_LABELS.has(field.label), ); - const showRuntimePreview = - import.meta.env.DEV && - isOwner === true && - isBot && - managedAgent === undefined; + const runtimeFields = [ + ...runtimeConfigurationFields, + ...runtimeSettingsFields, + ]; const showRuntimeTab = isOwner === true && isBot && @@ -241,29 +235,18 @@ export function ProfileSummaryView({ runtimeSettingsFields.length > 0 || instances.length > 0 || diagnosticsFields.length > 0 || - canOpenAgentLogs || - showRuntimePreview); - const displayedRuntimeFields = showRuntimePreview - ? fillRuntimePreviewFields([ - ...runtimeConfigurationFields, - ...runtimeSettingsFields, - ]) - : [...runtimeConfigurationFields, ...runtimeSettingsFields]; - const displayedDiagnosticsFields = showRuntimePreview - ? fillRuntimePreviewDiagnostics(diagnosticsFields) - : diagnosticsFields; + canOpenAgentLogs); const showDiagnosticsIngress = diagnosticsFields.some((field) => field.label !== "Status") || canOpenAgentLogs; const showActivityIngress = canViewActivity; const showInfoTab = agentInfoFields.length > 0 || - displayedRuntimeFields.length > 0 || + runtimeFields.length > 0 || isArchived || showActivityIngress || showInstructionBlock || managedAgent !== undefined || - showRuntimePreview || !showRuntimeTab; const diagnosticsErrorField = diagnosticsFields.find( @@ -527,12 +510,12 @@ export function ProfileSummaryView({ archiveActions={archiveActions} canArchiveAgent={isBot && archiveActions.canArchive} canDeleteAgent={canDeleteAgent} - callerChannelId={callerChannelId} channelIdToName={channelIdToName} isArchived={isArchived} isDeleteAgentPending={isAgentActionPending} managedAgent={managedAgent} onEditAgent={handleEditAgent} + onCreateCard={onCreateCard} onDeleteAgent={onDeleteAgent} onDuplicateAgent={onDuplicateAgent} onExportAgent={onExportAgent} @@ -544,17 +527,14 @@ export function ProfileSummaryView({ ) : null} {activeTab === "runtime" ? (
- {showRuntimePreview ? ( - - ) : null} - ) : showRuntimePreview ? ( - ) : undefined } needsRestart={managedAgent?.needsRestart ?? false} @@ -583,9 +558,6 @@ export function ProfileSummaryView({ onOpenDiagnostics={onOpenDiagnostics} onOpenInstance={onOpenInstance} showDiagnosticsIngress={showDiagnosticsIngress} - showPreviewHarnessLog={ - showRuntimePreview && !showDiagnosticsIngress - } /> {isOwner === true && managedAgent !== undefined ? ( ) : null} - {showRuntimePreview ? ( - - ) : null}
) : null} {activeTab === "channels" ? ( diff --git a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx index 0ab9d8067c9..0db96a022da 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx @@ -1,6 +1,14 @@ import * as React from "react"; import type { LucideIcon } from "lucide-react"; -import { Archive, ChevronRight, Info, RefreshCw, Wrench } from "lucide-react"; +import { + Archive, + ChevronRight, + Info, + MessageSquare, + RefreshCw, + ScrollText, + Wrench, +} from "lucide-react"; import type { IdentityArchiveActions } from "@/features/identity-archive/hooks"; import type { ManagedAgent, RestartDiffEntry } from "@/shared/api/types"; @@ -62,7 +70,10 @@ export function ProfileIngressRow({ const content = ( <> {Icon ? ( - + ) : null} {label} @@ -191,11 +202,11 @@ export function ProfileInfoTabContent({ archiveActions, canArchiveAgent, canDeleteAgent, - callerChannelId, channelIdToName, isArchived, isDeleteAgentPending, managedAgent, + onCreateCard, onDeleteAgent, onDuplicateAgent, onExportAgent, @@ -211,11 +222,12 @@ export function ProfileInfoTabContent({ archiveActions: IdentityArchiveActions; canArchiveAgent: boolean; canDeleteAgent: boolean; - callerChannelId: string | null; channelIdToName: Record; isArchived: boolean; isDeleteAgentPending: boolean; managedAgent?: ManagedAgent; + /** Mint an agent trading card. Present only for owner-managed personas. */ + onCreateCard?: () => void; onDeleteAgent: () => void; onDuplicateAgent?: () => void; onExportAgent?: () => void; @@ -248,6 +260,7 @@ export function ProfileInfoTabContent({ !hasInfoFields && !showArchiveAction && !canDeleteAgent && + !onCreateCard && !onDuplicateAgent && !onExportAgent && !showActivityIngress && @@ -263,7 +276,6 @@ export function ProfileInfoTabContent({ ; feedScope: ProfileActivityFeedScope; onOpenActivity: (channelId?: string | null) => void; @@ -398,7 +410,7 @@ function ProfileLiveActivityEmbed({ const activeChannelId = resolveActivityChannelId( slides, selectedChannelId, - callerChannelId ?? feedScope.preferredChannelId, + feedScope.preferredChannelId, ); const selectedIndex = activeChannelId ? slides.indexOf(activeChannelId) : 0; @@ -499,7 +511,7 @@ function ProfileLiveActivityEmbed({ void; onToggleStartOnLaunch?: () => void; showDiagnosticsIngress: boolean; - showPreviewHarnessLog?: boolean; }) { const startOnLaunchFieldIndex = configurationFields.findIndex( (field) => field.label === "Start on launch", ); const startOnLaunchField = configurationFields[startOnLaunchFieldIndex]; - const configurationFieldsBeforeStartOnLaunch = - startOnLaunchFieldIndex >= 0 - ? configurationFields.slice(0, startOnLaunchFieldIndex) - : configurationFields; - const configurationFieldsAfterStartOnLaunch = - startOnLaunchFieldIndex >= 0 - ? configurationFields.slice(startOnLaunchFieldIndex + 1) - : []; - const [previewStartOnLaunchEnabled, setPreviewStartOnLaunchEnabled] = - React.useState(startOnLaunchField?.displayValue === "Yes"); - const isRuntimePreview = - startOnLaunchField !== undefined && startOnLaunchEnabled === undefined; + const StartOnLaunchIcon = startOnLaunchField?.icon; + const remainingConfigurationFields = configurationFields.filter( + (_, index) => index !== startOnLaunchFieldIndex, + ); const resolvedStartOnLaunchEnabled = - startOnLaunchEnabled ?? previewStartOnLaunchEnabled; - const canToggleStartOnLaunch = - isRuntimePreview || onToggleStartOnLaunch !== undefined; + startOnLaunchEnabled ?? startOnLaunchField?.displayValue === "Yes"; + const canToggleStartOnLaunch = onToggleStartOnLaunch !== undefined; const handleStartOnLaunchToggle = React.useCallback(() => { if (startOnLaunchPending) return; - if (isRuntimePreview) { - setPreviewStartOnLaunchEnabled((enabled) => !enabled); - return; - } onToggleStartOnLaunch?.(); - }, [isRuntimePreview, onToggleStartOnLaunch, startOnLaunchPending]); + }, [onToggleStartOnLaunch, startOnLaunchPending]); const statusDiagnosticsFields = diagnosticsFields.filter( (field) => field.label === "Status", ); const hasActivityRows = statusDiagnosticsFields.length > 0 || - showDiagnosticsIngress || - showPreviewHarnessLog; - const hasConfigurationRows = configurationFields.length > 0; + startOnLaunchField !== undefined || + showDiagnosticsIngress; + const hasConfigurationRows = remainingConfigurationFields.length > 0; const hasInstances = instances.length > 0; if ( @@ -872,32 +869,6 @@ export function ProfileRuntimeTabContent({ variant="runtime" /> ) : null} - {showDiagnosticsIngress ? ( - - ) : showPreviewHarnessLog ? ( - - ) : null} - - ) : null} - {hasConfigurationRows ? ( - - {startOnLaunchField ? (
+ {StartOnLaunchIcon ? ( + + ) : null} {startOnLaunchField.label} @@ -932,8 +909,25 @@ export function ProfileRuntimeTabContent({ />
) : null} + {showDiagnosticsIngress ? ( + + ) : null} +
+ ) : null} + {hasConfigurationRows ? ( + diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs index 89837f6017c..c3ff723375c 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs @@ -6,6 +6,7 @@ import { parseProfilePanelView, personaManagedAgentUpdate, profilePanelTabFromSearch, + profilePanelTargetKey, profilePanelViewFromSearch, } from "./UserProfilePanelUtils.ts"; @@ -189,3 +190,19 @@ test("profilePanelTabFromSearch falls back to info for invalid values", () => { assert.equal(profilePanelTabFromSearch("missing"), "info"); assert.equal(profilePanelTabFromSearch(null), "info"); }); + +test("profile target identity stays stable while a requested pubkey is canonicalized", () => { + const historicalPubkey = "a".repeat(64); + assert.equal( + profilePanelTargetKey(historicalPubkey, undefined), + historicalPubkey, + ); + assert.equal( + profilePanelTargetKey(historicalPubkey, "resolved-persona"), + historicalPubkey, + ); + assert.equal( + profilePanelTargetKey(undefined, "requested-persona"), + "persona:requested-persona", + ); +}); diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index df09726fecb..16999816c36 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -88,8 +88,14 @@ export function profilePanelTabFromSearch(value: unknown): ProfilePanelTab { return parseProfilePanelTab(value) ?? "info"; } +export function profilePanelTargetKey( + pubkey: string | undefined, + personaId: string | undefined, +): string { + return pubkey ?? `persona:${personaId ?? "unknown"}`; +} + export type UserProfilePanelProps = { - callerChannelId?: string | null; canResetWidth?: boolean; currentPubkey?: string; isSinglePanelView?: boolean; diff --git a/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx b/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx index 9fae1bd889c..5038c060086 100644 --- a/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx @@ -1,7 +1,10 @@ +import * as React from "react"; + import type { AcpRuntimeCatalogEntry, AgentPersona, CreatePersonaInput, + ManagedAgent, UpdatePersonaInput, } from "@/shared/api/types"; import { AgentCardMintDialog } from "@/features/agents/ui/AgentCardMintDialog"; @@ -17,6 +20,30 @@ export type CardMintTarget = { canLock: boolean; }; +/** + * Card-mint dialog state plus the callback that opens it. `create` is + * undefined when no persona resolves; owner gating is the caller's job. + */ +export function useCardMint( + persona: AgentPersona | undefined, + managedAgent: ManagedAgent | undefined, +) { + const [target, setTarget] = React.useState(null); + const close = React.useCallback(() => setTarget(null), []); + const create = persona + ? () => + setTarget({ + // Prefer the live instance pubkey; fall back to the + // persona/definition id (same resolution as export). + id: managedAgent?.pubkey ?? persona.id, + name: persona.displayName, + // Locking needs an instance keypair to encrypt to. + canLock: Boolean(managedAgent?.pubkey), + }) + : undefined; + return { close, create, target }; +} + export function UserProfilePersonaDialogs({ cardMintTarget, createError, diff --git a/desktop/src/features/profile/ui/UserProfileRuntimeContent.test.mjs b/desktop/src/features/profile/ui/UserProfileRuntimeContent.test.mjs new file mode 100644 index 00000000000..5937622f297 --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfileRuntimeContent.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const panelSectionsSource = await readFile( + new URL("./UserProfilePanelSections.tsx", import.meta.url), + "utf8", +); +const panelTabsSource = await readFile( + new URL("./UserProfilePanelTabs.tsx", import.meta.url), + "utf8", +); + +test("profile runtime surfaces never synthesize preview agent data", () => { + for (const forbiddenPattern of [ + /UserProfileRuntimePreview/, + /fillRuntimePreview/, + /showRuntimePreview/, + /UserProfileConfigPreview/, + /import\.meta\.env\.DEV/, + ]) { + assert.doesNotMatch(panelSectionsSource, forbiddenPattern); + } +}); + +test("runtime rows do not add interactive preview-only controls", () => { + for (const forbiddenPattern of [ + /previewStartOnLaunchEnabled/, + /isRuntimePreview/, + /showPreviewHarnessLog/, + /diagnostics-ingress-preview/, + ]) { + assert.doesNotMatch(panelTabsSource, forbiddenPattern); + } +}); diff --git a/desktop/src/features/profile/ui/UserProfileRuntimePreview.tsx b/desktop/src/features/profile/ui/UserProfileRuntimePreview.tsx deleted file mode 100644 index 2c7f7840059..00000000000 --- a/desktop/src/features/profile/ui/UserProfileRuntimePreview.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { - AgentConfigSurfaceRows, - type AgentConfigPanelSection, -} from "@/features/agents/ui/AgentConfigPanel"; -import type { ProfileField } from "@/features/profile/ui/UserProfilePanelFields"; -import { Badge } from "@/shared/ui/badge"; -import type { NormalizedField, RuntimeConfigSurface } from "@/shared/api/types"; - -function previewField(value: string): NormalizedField { - return { - isRequired: false, - origin: "harnessDefault", - overriddenOrigin: null, - overriddenValue: null, - value, - writeVia: { type: "readOnly" }, - }; -} - -const PROFILE_RUNTIME_PREVIEW: RuntimeConfigSurface = { - advanced: [ - { - key: "workingDirectory", - label: "Working directory", - origin: "buzzExplicit", - schemaType: { type: "string" }, - value: "~/Development/buzz", - writeVia: { type: "readOnly" }, - }, - ], - extensions: [{ enabled: true, kind: "stdio", name: "Buzz developer tools" }], - isPreSpawn: false, - normalized: { - contextLimit: previewField("200,000"), - maxOutputTokens: previewField("8,192"), - mode: previewField("Auto"), - model: previewField("claude-sonnet-4-20250514"), - provider: previewField("anthropic"), - systemPrompt: null, - thinkingEffort: previewField("High"), - }, - runtimeId: "goose", - runtimeLabel: "Goose", - sources: { - acpConfigOptions: "available", - acpNative: "available", - configFile: "available", - configFilePath: "~/.config/goose/config.yaml", - envVars: "notApplicable", - mcpConfigFilePath: null, - }, -}; - -const PROFILE_RUNTIME_PREVIEW_FIELDS: ProfileField[] = [ - { - copyValue: "goose", - displayValue: "Goose", - label: "Runtime", - testId: "user-profile-runtime", - }, - { - copyValue: "goose acp", - displayValue: "goose acp", - label: "ACP command", - testId: "user-profile-acp", - }, - { - copyValue: "goose mcp", - displayValue: "goose mcp", - label: "MCP command", - testId: "user-profile-mcp", - }, - { - displayValue: "Yes", - label: "Start on launch", - testId: "user-profile-start-on-launch", - }, - { - displayValue: "Only the owner", - label: "Who can send instructions", - testId: "user-profile-respond-to", - }, -]; - -const PROFILE_RUNTIME_PREVIEW_DIAGNOSTICS: ProfileField[] = [ - { - displayNode: ( - - Running - - ), - displayValue: "Running", - label: "Status", - testId: "user-profile-agent-status", - }, -]; - -function appendMissingPreviewFields( - fields: ProfileField[], - previewFields: ProfileField[], -) { - const existingLabels = new Set(fields.map((field) => field.label)); - return [ - ...fields, - ...previewFields.filter((field) => !existingLabels.has(field.label)), - ]; -} - -export function fillRuntimePreviewFields(fields: ProfileField[]) { - return appendMissingPreviewFields(fields, PROFILE_RUNTIME_PREVIEW_FIELDS); -} - -export function fillRuntimePreviewDiagnostics(fields: ProfileField[]) { - return appendMissingPreviewFields( - fields, - PROFILE_RUNTIME_PREVIEW_DIAGNOSTICS, - ); -} - -export function UserProfileRuntimePreviewNotice() { - return ( -
-

- Preview runtime data -

-

- Staging doesn’t include every production runtime detail. Missing values - below use examples. -

-
- ); -} - -export function UserProfileConfigPreview({ - onEdit, - sections, -}: { - onEdit?: () => void; - sections: readonly AgentConfigPanelSection[]; -}) { - return ( -
- -
- ); -} diff --git a/desktop/src/features/projects/assignmentOperationFetch.test.mjs b/desktop/src/features/projects/assignmentOperationFetch.test.mjs new file mode 100644 index 00000000000..3882d1807e0 --- /dev/null +++ b/desktop/src/features/projects/assignmentOperationFetch.test.mjs @@ -0,0 +1,287 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + fetchAssignmentOperationEvents, + mergeEventsById, +} from "./assignmentOperationFetch.ts"; +import { fetchProjectsWorkItems } from "./projectWorkItems.ts"; + +const REPO_OWNER = "a".repeat(64); +const REPO_ADDRESS = `30617:${REPO_OWNER}:relay`; +const ISSUE_ID = "1".repeat(64); +const ASSIGNEE = "b".repeat(64); + +function makeIssue() { + return { + id: ISSUE_ID, + kind: 1621, + pubkey: REPO_OWNER, + created_at: 100, + content: "An issue", + tags: [ + ["a", REPO_ADDRESS], + ["subject", "Fix the thing"], + ], + }; +} + +/** Owner-signed assignment operation (kind:1, `t: assignment`). */ +function makeAssignment(id, createdAt, issueId = ISSUE_ID) { + return { + id, + kind: 1, + pubkey: REPO_OWNER, + created_at: createdAt, + content: "Assigned", + tags: [ + ["e", issueId, "", "root"], + ["a", REPO_ADDRESS], + ["p", ASSIGNEE], + ["t", "assignment"], + ], + }; +} + +function makeComment(id, createdAt, issueId = ISSUE_ID) { + return { + id, + kind: 1, + pubkey: REPO_OWNER, + created_at: createdAt, + content: `Comment ${id.slice(0, 4)}`, + tags: [ + ["e", issueId, "", "root"], + ["a", REPO_ADDRESS], + ], + }; +} + +function eventId(index) { + return index.toString(16).padStart(64, "0"); +} + +/** + * Relay model matching production semantics (`filter_to_query_params` + + * `filter_fully_pushable` in `crates/buzz-relay/src/handlers/req.rs`): + * + * 1. SQL applies kinds / `#e` / `since` / `until` (inclusive), orders + * `(created_at DESC, id ASC)`, and cuts to `LIMIT` — clamped to 1,000. + * 2. `#t` / `#a` are applied in Rust AFTER the SQL LIMIT. + * + * Step 2 is the trap the round-3 review caught: a filter that carries `#t` + * gets the newest N kind-1 candidates first, then loses tag mismatches, so a + * short page does NOT mean exhaustion. Any regression back to `#t`-reliant + * fetching fails these tests. + */ +function makeRelayModel(events) { + const calls = []; + const fetchEvents = async (filter) => { + calls.push(filter); + const limit = Math.min(filter.limit ?? 1_000, 1_000); + // SQL phase: pushed constraints only. + const candidates = events + .filter((event) => { + if (filter.kinds && !filter.kinds.includes(event.kind)) return false; + if ( + filter["#e"] && + !event.tags.some( + (tag) => tag[0] === "e" && filter["#e"].includes(tag[1]), + ) + ) { + return false; + } + if (filter.until !== undefined && event.created_at > filter.until) { + return false; + } + if (filter.since !== undefined && event.created_at < filter.since) { + return false; + } + return true; + }) + .sort((left, right) => + right.created_at !== left.created_at + ? right.created_at - left.created_at + : left.id < right.id + ? -1 + : 1, + ) + .slice(0, limit); + // Rust post-filter phase: tag constraints applied AFTER the LIMIT. + return candidates.filter((event) => { + if ( + filter["#t"] && + !event.tags.some( + (tag) => tag[0] === "t" && filter["#t"].includes(tag[1]), + ) + ) { + return false; + } + if ( + filter["#a"] && + !event.tags.some( + (tag) => tag[0] === "a" && filter["#a"].includes(tag[1]), + ) + ) { + return false; + } + return true; + }); + }; + return { calls, fetchEvents }; +} + +// ── fetchAssignmentOperationEvents vs. real relay query semantics ──────────── + +test("finds an assignment buried behind 600 newer comments on the real relay model", async () => { + // The round-3 adversarial case: one old assignment, then 600 newer comments + // on the same issue. A `#t`-carrying filter sees the newest 500 candidates + // post-filtered to zero and falsely declares exhaustion (0/1). The + // `#e`-keyed walk must return 1/1. + const assignment = makeAssignment(eventId(9_999), 200); + const comments = Array.from({ length: 600 }, (_, index) => + makeComment(eventId(index + 1), 1_000 + index), + ); + const { calls, fetchEvents } = makeRelayModel([assignment, ...comments]); + + const events = await fetchAssignmentOperationEvents([ISSUE_ID], fetchEvents); + + assert.deepEqual( + events.map((event) => event.id), + [eventId(9_999)], + "the buried assignment must survive the bounded page", + ); + assert.ok(calls.length >= 2, "must page past the first bounded window"); + for (const filter of calls) { + assert.equal( + filter["#t"], + undefined, + "the filter must carry only SQL-pushed constraints — `#t` is post-filtered after LIMIT", + ); + assert.equal(filter["#a"], undefined, "`#a` is post-filtered after LIMIT"); + } +}); + +test("paginates to exhaustion across several full pages", async () => { + // 1,203 operations spread across distinct seconds — three pages at the + // 500-event window. + const operations = Array.from({ length: 1_203 }, (_, index) => + makeAssignment(eventId(index + 1), 1_000_000 + index), + ); + const { calls, fetchEvents } = makeRelayModel(operations); + + const events = await fetchAssignmentOperationEvents([ISSUE_ID], fetchEvents); + + assert.equal(events.length, 1_203, "every operation must be loaded"); + assert.ok(calls.length >= 3, "must page past the 500-event window"); +}); + +test("escapes a second denser than one page by widening to the relay clamp", async () => { + // 700 externally signed operations sharing one created_at second: an + // inclusive `until` cursor alone can never advance past the first 500. + // The loop must widen to the relay's 1,000-row clamp and load all 700. + const operations = Array.from({ length: 700 }, (_, index) => + makeAssignment(eventId(index + 1), 1_000), + ); + const { fetchEvents } = makeRelayModel(operations); + + const events = await fetchAssignmentOperationEvents([ISSUE_ID], fetchEvents); + + assert.equal(events.length, 700, "same-second density must not drop events"); +}); + +test("reports a second denser than the relay clamp instead of silently dropping", async () => { + // 1,100 events in one second exceeds the relay's 1,000-row page clamp — + // unreachable through NIP-01 filters. That must surface as an error (the + // caller shows a failed assignments section), never as silent loss. + const operations = Array.from({ length: 1_100 }, (_, index) => + makeAssignment(eventId(index + 1), 1_000), + ); + const { fetchEvents } = makeRelayModel(operations); + + await assert.rejects( + fetchAssignmentOperationEvents([ISSUE_ID], fetchEvents), + /full relay page/, + ); +}); + +test("skips the relay for zero issues", async () => { + const events = await fetchAssignmentOperationEvents([], async () => { + throw new Error("must not query the relay"); + }); + assert.deepEqual(events, []); +}); + +test("chunks large issue sets and dedupes operations across chunks", async () => { + // 150 issues → two `#e` chunks at the 100-id chunk size. + const issueIds = Array.from({ length: 150 }, (_, index) => + eventId(5_000 + index), + ); + const operations = issueIds.map((issueId, index) => + makeAssignment(eventId(index + 1), 2_000 + index, issueId), + ); + const { calls, fetchEvents } = makeRelayModel(operations); + + const events = await fetchAssignmentOperationEvents(issueIds, fetchEvents); + + assert.equal(events.length, 150); + assert.equal(calls.length, 2, "150 issues must fan out as two #e chunks"); + assert.ok(calls.every((filter) => filter["#e"].length <= 100)); +}); + +test("mergeEventsById drops duplicates and keeps both sources", () => { + const shared = makeAssignment(eventId(1), 10); + const merged = mergeEventsById( + [shared, makeComment(eventId(2), 11)], + [shared, makeAssignment(eventId(3), 12)], + ); + assert.deepEqual( + merged.map((event) => event.id), + [eventId(1), eventId(2), eventId(3)], + ); +}); + +// ── Regression: assignment predating a full comment window ───────────────── +// +// The reduction in projectIssues.mjs is only as complete as the events it is +// handed. The general comment fetch is bounded (2,000 shared across repos in +// fetchProjectsWorkItems), so an old assignment operation can be evicted by +// newer unrelated comments. The dedicated issue-keyed exhaustive query must +// restore it — against the real relay's query semantics. + +test("an assignment older than 600 newer comments still reduces to an assignee", async () => { + const issue = makeIssue(); + const assignment = makeAssignment(eventId(9_999), 200); + const comments = Array.from({ length: 600 }, (_, index) => + makeComment(eventId(index + 1), 1_000 + index), + ); + const { fetchEvents } = makeRelayModel([issue, assignment, ...comments]); + + const result = await fetchProjectsWorkItems( + [{ repositories: [{ repoAddress: REPO_ADDRESS }] }], + fetchEvents, + ); + + assert.equal(result.issues.items.length, 1); + assert.deepEqual( + result.issues.items[0].issue.assignees, + [ASSIGNEE], + "assignee evicted from the comment window must be restored by the dedicated assignment query", + ); +}); + +test("a failed assignment query surfaces as a failed section instead of silent loss", async () => { + const issue = makeIssue(); + const fetchEvents = async (filter) => { + if (filter.kinds?.includes(1621)) return [issue]; + if (filter["#e"]) throw new Error("relay hiccup"); + return []; + }; + + const result = await fetchProjectsWorkItems( + [{ repositories: [{ repoAddress: REPO_ADDRESS }] }], + fetchEvents, + ); + + assert.ok(result.issues.failedSections.includes("assignments")); +}); diff --git a/desktop/src/features/projects/assignmentOperationFetch.ts b/desktop/src/features/projects/assignmentOperationFetch.ts new file mode 100644 index 00000000000..fbdc90b2186 --- /dev/null +++ b/desktop/src/features/projects/assignmentOperationFetch.ts @@ -0,0 +1,138 @@ +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_TEXT_NOTE } from "@/shared/constants/kinds"; +import { + ISSUE_ASSIGNMENT_LABEL, + ISSUE_UNASSIGNMENT_LABEL, +} from "./projectIssues.mjs"; + +type FetchEventsInput = Parameters<(typeof relayClient)["fetchEvents"]>[0]; + +const ASSIGNMENT_PAGE_LIMIT = 500; + +/** + * The relay clamps every REQ page to this many rows regardless of the + * requested `limit` (`DEFAULT_MAX_PAGE_LIMIT` in `crates/buzz-db/src/event.rs`). + * A single second denser than this is unreachable through NIP-01 pagination, + * so the loop below reports it as an error instead of silently dropping + * operations. + */ +const RELAY_MAX_PAGE_LIMIT = 1_000; + +/** Issue ids per relay query. Each id adds one JSONB containment clause to the + * relay's SQL, so batches are kept small enough to stay cheap while still + * collapsing typical projects into a single query. */ +const ISSUE_ID_CHUNK_SIZE = 100; + +function isAssignmentOperation(event: RelayEvent): boolean { + return event.tags.some( + (tag) => + tag[0] === "t" && + (tag[1] === ISSUE_ASSIGNMENT_LABEL || + tag[1] === ISSUE_UNASSIGNMENT_LABEL), + ); +} + +/** + * Loads every assignment/unassignment operation for the given issues, + * paginating to exhaustion instead of trusting a bounded comment window. + * + * Why: assignment state is reduced from kind:1 operations (`t: assignment` / + * `t: unassignment`), but the general comment fetches are bounded (500 per + * repo in `hooks.ts`, 2,000 shared in `projectWorkItems.ts`). Once newer + * comments push an older operation out of that window, its assignee silently + * vanishes from the issue — and a later self-service operation can reduce + * against the wrong `prior` head. + * + * The filter deliberately carries ONLY constraints the relay pushes into SQL + * before applying `LIMIT`: kinds, `#e`, `until`, `limit` (see + * `filter_fully_pushable` in `crates/buzz-relay/src/handlers/req.rs`). Tag + * filters like `#t`/`#a` are post-filtered in Rust AFTER the SQL `LIMIT`, so + * including them would make a short page meaningless — the newest N candidate + * rows could all be post-filtered away while older matches remain, and the + * loop would declare exhaustion having seen nothing. Instead the query walks + * the full comment stream of the given issues (`#e` is pushed via JSONB + * containment) and the assignment labels are filtered locally. + * + * Pagination uses an inclusive `until` cursor with id-level dedupe. The relay + * orders `(created_at DESC, id ASC)`, so a full page whose oldest timestamp + * equals the cursor means a single second denser than the page: the loop + * escalates `limit` to the relay's hard page clamp once, and if the second is + * denser than even that, throws — the caller surfaces a failed assignments + * section instead of silently losing operations. NIP-01 filters cannot + * express the relay's composite `(created_at, id)` keyset cursor, so this is + * the strongest client-only guarantee available. + */ +export async function fetchAssignmentOperationEvents( + issueIds: string[], + fetchEvents: ( + filter: FetchEventsInput, + ) => Promise = relayClient.fetchEvents.bind(relayClient), +): Promise { + if (issueIds.length === 0) return []; + const chunks: string[][] = []; + for (let i = 0; i < issueIds.length; i += ISSUE_ID_CHUNK_SIZE) { + chunks.push(issueIds.slice(i, i + ISSUE_ID_CHUNK_SIZE)); + } + const pages = await Promise.all( + chunks.map((chunk) => fetchIssueCommentsExhaustively(chunk, fetchEvents)), + ); + const seen = new Map(); + for (const page of pages) { + for (const event of page) { + if (isAssignmentOperation(event) && !seen.has(event.id)) { + seen.set(event.id, event); + } + } + } + return [...seen.values()]; +} + +async function fetchIssueCommentsExhaustively( + issueIds: string[], + fetchEvents: (filter: FetchEventsInput) => Promise, +): Promise { + const seen = new Map(); + let limit = ASSIGNMENT_PAGE_LIMIT; + let until: number | undefined; + for (;;) { + const page = await fetchEvents({ + kinds: [KIND_TEXT_NOTE], + "#e": issueIds, + limit, + ...(until === undefined ? {} : { until }), + }); + for (const event of page) { + if (!seen.has(event.id)) seen.set(event.id, event); + } + // Only SQL-pushed constraints are in the filter, so a short page is a + // true end-of-results signal. + if (page.length < limit) break; + const oldest = Math.min(...page.map((event) => event.created_at)); + if (until === undefined || oldest < until) { + until = oldest; + continue; + } + // Full page and the inclusive cursor cannot advance: every row shares + // the cursor second. Widen to the relay's hard clamp so the whole second + // fits in one page; beyond that, no NIP-01 filter can reach the rest. + if (limit < RELAY_MAX_PAGE_LIMIT) { + limit = RELAY_MAX_PAGE_LIMIT; + continue; + } + throw new Error( + "Could not load assignment history: more than a full relay page of " + + "issue comments share one timestamp.", + ); + } + return [...seen.values()]; +} + +/** Merge two event lists, dropping duplicates by event id. */ +export function mergeEventsById( + base: RelayEvent[], + extra: RelayEvent[], +): RelayEvent[] { + const ids = new Set(base.map((event) => event.id)); + return [...base, ...extra.filter((event) => !ids.has(event.id))]; +} diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index 8591430cc22..7f3f55ff027 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -38,6 +38,10 @@ import type { RelayEvent, } from "@/shared/api/types"; import { summarizeProjectActivityEvents } from "./projectActivity.mjs"; +import { + fetchAssignmentOperationEvents, + mergeEventsById, +} from "./assignmentOperationFetch"; import type { ProjectIssue } from "./projectIssues.mjs"; import { nextProjectIssueCommentCreatedAt, @@ -224,30 +228,43 @@ async function fetchRepoState(project: Repository): Promise { async function fetchProjectIssues( project: Repository, ): Promise { - const [issueEvents, statusEvents, commentEvents] = await Promise.all([ - relayClient.fetchEvents({ - kinds: [KIND_GIT_ISSUE], - "#a": [project.repoAddress], - limit: 200, - }), - relayClient.fetchEvents({ - kinds: [ - KIND_GIT_STATUS_OPEN, - KIND_GIT_STATUS_MERGED, - KIND_GIT_STATUS_CLOSED, - KIND_GIT_STATUS_DRAFT, - ], - "#a": [project.repoAddress], - limit: 500, - }), - relayClient.fetchEvents({ - kinds: [KIND_TEXT_NOTE], - "#a": [project.repoAddress], - limit: 500, - }), - ]); + const issuePromise = relayClient.fetchEvents({ + kinds: [KIND_GIT_ISSUE], + "#a": [project.repoAddress], + limit: 200, + }); + const [issueEvents, statusEvents, commentEvents, assignmentEvents] = + await Promise.all([ + issuePromise, + relayClient.fetchEvents({ + kinds: [ + KIND_GIT_STATUS_OPEN, + KIND_GIT_STATUS_MERGED, + KIND_GIT_STATUS_CLOSED, + KIND_GIT_STATUS_DRAFT, + ], + "#a": [project.repoAddress], + limit: 500, + }), + relayClient.fetchEvents({ + kinds: [KIND_TEXT_NOTE], + "#a": [project.repoAddress], + limit: 500, + }), + // Assignment state must reduce over the complete operation history, not + // whatever survives the bounded comment window above. Keyed by issue id + // (`#e`) because that is the only tag constraint the relay applies + // before its SQL LIMIT — see fetchAssignmentOperationEvents. + issuePromise.then((events) => + fetchAssignmentOperationEvents(events.map((event) => event.id)), + ), + ]); - return projectIssueEventsToIssues(issueEvents, statusEvents, commentEvents); + return projectIssueEventsToIssues( + issueEvents, + statusEvents, + mergeEventsById(commentEvents, assignmentEvents), + ); } async function fetchProjectPullRequests( diff --git a/desktop/src/features/projects/issueAssignments.ts b/desktop/src/features/projects/issueAssignments.ts new file mode 100644 index 00000000000..aa0810dea7b --- /dev/null +++ b/desktop/src/features/projects/issueAssignments.ts @@ -0,0 +1,167 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import * as React from "react"; + +import { + signProjectIssueAssignment, + signProjectIssueUnassignment, +} from "@/shared/api/projectGit"; +import { relayClient } from "@/shared/api/relayClient"; +import { signRelayEvent } from "@/shared/api/tauri"; +import { KIND_TEXT_NOTE } from "@/shared/constants/kinds"; +import type { Repository as Project } from "./hooks"; +import { + ISSUE_ASSIGNMENT_LABEL, + ISSUE_UNASSIGNMENT_LABEL, + nextProjectIssueCommentCreatedAt, + type ProjectIssue, +} from "./projectIssues.mjs"; + +function nextAssignmentOperationCreatedAt( + issue: ProjectIssue, + project: Project, + signAsManagedOwner: boolean, + signerPubkey: string, +) { + const signer = signAsManagedOwner ? project.owner : signerPubkey; + return nextProjectIssueCommentCreatedAt( + issue, + Math.floor(Date.now() / 1_000), + signer, + ); +} + +function normalizedAssigneeLabel(label: string) { + const normalized = label.trim(); + if (normalized.length === 0 || Array.from(normalized).length > 128) { + throw new Error("Assignee label must be between 1 and 128 characters."); + } + return normalized; +} + +type IssueAssignmentOperation = "assign" | "unassign"; +type IssueAssignmentMutationInput = { + assignees: string[]; + assigneeLabel: string; + issue: ProjectIssue; + signerPubkey: string; + signAsManagedOwner: boolean; +}; + +async function writeProjectIssueAssignment({ + assignees, + assigneeLabel, + issue, + operation, + project, + signerPubkey, + signAsManagedOwner, +}: IssueAssignmentMutationInput & { + operation: IssueAssignmentOperation; + project: Project; +}): Promise { + if (assignees.length === 0) { + throw new Error("Select at least one assignee."); + } + const normalizedLabel = normalizedAssigneeLabel(assigneeLabel); + const assigneePubkeys = [ + ...new Set(assignees.map((pubkey) => pubkey.toLowerCase())), + ]; + const createdAt = nextAssignmentOperationCreatedAt( + issue, + project, + signAsManagedOwner, + signerPubkey, + ); + const isAssignment = operation === "assign"; + const content = isAssignment + ? `Assigned this issue to ${normalizedLabel}` + : `Unassigned ${normalizedLabel} from this issue`; + const label = isAssignment + ? ISSUE_ASSIGNMENT_LABEL + : ISSUE_UNASSIGNMENT_LABEL; + if (signAsManagedOwner) { + const signManagedOperation = isAssignment + ? signProjectIssueAssignment + : signProjectIssueUnassignment; + await signManagedOperation({ + targetOwner: project.owner, + repoAddress: project.repoAddress, + issueId: issue.id, + assignees: assigneePubkeys, + assigneeLabel: normalizedLabel, + createdAt, + }); + return; + } + const normalizedSigner = signerPubkey.toLowerCase(); + const prior = + assigneePubkeys.length === 1 && assigneePubkeys[0] === normalizedSigner + ? issue.assigneeOperationHeads[normalizedSigner] + : undefined; + const event = await signRelayEvent({ + kind: KIND_TEXT_NOTE, + content, + createdAt, + tags: [ + ["e", issue.id, "", "root"], + ["a", project.repoAddress], + ...assigneePubkeys.map((pubkey) => ["p", pubkey]), + ["t", label], + ...(prior ? [["prior", prior]] : []), + ], + }); + + await relayClient.publishEvent( + event, + `Timed out ${operation}ing issue.`, + `Failed to ${operation} issue.`, + ); +} + +export function useProjectIssueWriteInvalidation( + project: Project | null | undefined, +) { + const queryClient = useQueryClient(); + return React.useCallback(() => { + void queryClient.invalidateQueries({ + queryKey: ["project", project?.id ?? "none", "issues"], + }); + void queryClient.invalidateQueries({ + queryKey: ["projects", "work-items"], + }); + void queryClient.invalidateQueries({ + queryKey: ["projects", "activity-summaries"], + }); + }, [project?.id, queryClient]); +} + +function useProjectIssueAssignmentMutation( + project: Project | null | undefined, + operation: IssueAssignmentOperation, +) { + const invalidate = useProjectIssueWriteInvalidation(project); + + return useMutation({ + mutationFn: (input: IssueAssignmentMutationInput) => { + if (!project) throw new Error("No project selected."); + return writeProjectIssueAssignment({ + ...input, + operation, + project, + }); + }, + onSuccess: invalidate, + }); +} + +export function useAssignProjectIssueMutation( + project: Project | null | undefined, +) { + return useProjectIssueAssignmentMutation(project, "assign"); +} + +export function useUnassignProjectIssueMutation( + project: Project | null | undefined, +) { + return useProjectIssueAssignmentMutation(project, "unassign"); +} diff --git a/desktop/src/features/projects/lib/discussionChannels.test.mjs b/desktop/src/features/projects/lib/discussionChannels.test.mjs new file mode 100644 index 00000000000..95f73f6373b --- /dev/null +++ b/desktop/src/features/projects/lib/discussionChannels.test.mjs @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + commitDiscussionQuery, + discussionSnippet, + entityDiscussionQuery, + formatNameList, + groupDiscussionChannels, + repositoryDiscussionQuery, +} from "./discussionChannels.ts"; + +const OWNER = "a".repeat(64); +const EVENT_ID = "b".repeat(64); +const ALICE = "c".repeat(64); +const BOB = "d".repeat(64); + +test("query builders emit the tokens FTS needs, nothing else", () => { + assert.equal(entityDiscussionQuery(EVENT_ID), EVENT_ID); + assert.equal( + repositoryDiscussionQuery({ owner: OWNER, dtag: "buzz-world" }), + `${OWNER} buzz-world`, + ); +}); + +test("commit queries match full or short hash citations", () => { + const hash = "0123456789abcdef0123456789abcdef01234567"; + assert.equal( + commitDiscussionQuery({ hash, shortHash: "0123456" }), + `${hash} OR 0123456`, + ); + // Short hash derives from the full hash when the snapshot omitted it. + assert.equal(commitDiscussionQuery({ hash }), `${hash} OR 0123456`); + // Degenerate case: already-short hashes search as a single token. + assert.equal(commitDiscussionQuery({ hash: "0123456" }), "0123456"); +}); + +test("hits group into channels ordered by count then recency", () => { + const channels = groupDiscussionChannels([ + { channelId: "c1", channelName: "general", createdAt: 100, pubkey: ALICE }, + { channelId: "c2", channelName: "design", createdAt: 300, pubkey: BOB }, + { channelId: "c1", channelName: "general", createdAt: 200, pubkey: BOB }, + { channelId: "c3", channelName: "random", createdAt: 300, pubkey: ALICE }, + ]); + assert.deepEqual(channels, [ + { + id: "c1", + name: "general", + messageCount: 2, + lastActivityAt: 200, + // Most recent speaker first. + participants: [BOB, ALICE], + }, + // c2 and c3 tie on count; newer activity first (stable tie broken by time). + { + id: "c2", + name: "design", + messageCount: 1, + lastActivityAt: 300, + participants: [BOB], + }, + { + id: "c3", + name: "random", + messageCount: 1, + lastActivityAt: 300, + participants: [ALICE], + }, + ]); +}); + +test("channel-less hits are dropped, names backfill, participants dedupe", () => { + const channels = groupDiscussionChannels([ + { channelId: null, channelName: null, createdAt: 100, pubkey: ALICE }, + { channelId: "c1", channelName: null, createdAt: 100, pubkey: ALICE }, + { + channelId: "c1", + channelName: "general", + createdAt: 50, + pubkey: ALICE.toUpperCase(), + }, + ]); + assert.deepEqual(channels, [ + { + id: "c1", + name: "general", + messageCount: 2, + lastActivityAt: 100, + participants: [ALICE], + }, + ]); +}); + +test("formatNameList reads naturally at every size", () => { + assert.equal(formatNameList([]), ""); + assert.equal(formatNameList(["Alice"]), "Alice"); + assert.equal(formatNameList(["Alice", "Bob"]), "Alice and Bob"); + assert.equal( + formatNameList(["Alice", "Bob", "Carol"]), + "Alice, Bob and Carol", + ); + assert.equal( + formatNameList(["Alice", "Bob", "Carol", "Dan"]), + "Alice, Bob and 2 others", + ); +}); + +test("discussionSnippet strips entity links and coordinates", () => { + assert.equal( + discussionSnippet( + `Can someone review buzz://pr?id=${EVENT_ID}&owner=${OWNER}&d=buzz before Friday?`, + ), + "Can someone review before Friday?", + ); + assert.equal( + discussionSnippet(`Deploying 30617:${OWNER}:relay-tools tonight`), + "Deploying tonight", + ); + assert.equal( + discussionSnippet(`buzz://repo?owner=${OWNER}&d=buzz`), + "Shared a link to this.", + ); +}); + +test("discussionSnippet truncates long content on an ellipsis", () => { + // The cap only bounds DOM size — the row's CSS truncation does the + // visual cut — so it just needs to hold for arbitrarily long content. + const long = "word ".repeat(200); + const snippet = discussionSnippet(long); + assert.ok(snippet.length <= 400); + assert.ok(snippet.endsWith("…")); +}); diff --git a/desktop/src/features/projects/lib/discussionChannels.ts b/desktop/src/features/projects/lib/discussionChannels.ts new file mode 100644 index 00000000000..3776467248d --- /dev/null +++ b/desktop/src/features/projects/lib/discussionChannels.ts @@ -0,0 +1,152 @@ +/** + * "Discussed in" channel discovery for Buzz git entities. + * + * Chat messages that reference a PR, issue, or repository do so only through + * `buzz://` links in their *content* — they carry no entity tags (see + * `useMessageLinkPreviews.ts`). So discovery runs the relay's NIP-50 + * full-text search over message content and groups the hits by channel. + * + * Query construction leans on the FTS tokenizer: the relay ANDs every token + * of the search text, and `buzz://` links tokenize into their query-param + * values. A PR/issue link contains the entity's 64-hex event id (globally + * unique token), and every repo/PR/issue link contains the repository + * coordinate's `owner` pubkey and `d`-tag — so searching those tokens finds + * exactly the messages linking the entity, across all channels the viewer + * can read (the relay re-authorizes each hit). + */ + +import type { SearchHit } from "@/shared/api/searchTypes"; + +export type DiscussionChannel = { + id: string; + /** Channel display name from the search hit; null when the relay omitted it. */ + name: string | null; + messageCount: number; + /** Unix seconds of the newest matching message. */ + lastActivityAt: number; + /** Unique author pubkeys, most recent speaker first. */ + participants: string[]; +}; + +/** + * Search text matching messages that link a specific PR or issue: the event + * id is a single 64-hex token unique to the entity, present in every + * `buzz://pr|issue?id=…` link. + */ +export function entityDiscussionQuery(eventId: string): string { + return eventId; +} + +/** + * Search text matching messages that link a repository or any of its PRs + * and issues: all those links carry `owner=&d=`, so the owner + * pubkey and d-tag tokens together identify the repository coordinate. + */ +export function repositoryDiscussionQuery(repository: { + owner: string; + dtag: string; +}): string { + return `${repository.owner} ${repository.dtag}`; +} + +/** + * Chat cites commits by either the full or the abbreviated hash, so match + * both. `websearch_to_tsquery` (the relay's NIP-50 parser) treats a literal + * `OR` between words as a disjunction. + */ +export function commitDiscussionQuery(commit: { + hash: string; + shortHash?: string | null; +}): string { + const short = commit.shortHash ?? commit.hash.slice(0, 7); + if (!short || short === commit.hash) { + return commit.hash; + } + return `${commit.hash} OR ${short}`; +} + +/** + * Group search hits into unique channels, ordered by message count then + * recency. Channel-less hits (no `h` tag) are dropped. + */ +export function groupDiscussionChannels( + hits: readonly Pick< + SearchHit, + "channelId" | "channelName" | "createdAt" | "pubkey" + >[], +): DiscussionChannel[] { + const byChannel = new Map(); + // Newest first so each channel's participant list leads with the most + // recent speaker. + const ordered = [...hits].sort((a, b) => b.createdAt - a.createdAt); + for (const hit of ordered) { + if (!hit.channelId) continue; + const pubkey = hit.pubkey.toLowerCase(); + const existing = byChannel.get(hit.channelId); + if (existing) { + existing.messageCount += 1; + existing.lastActivityAt = Math.max( + existing.lastActivityAt, + hit.createdAt, + ); + if (existing.name === null && hit.channelName) { + existing.name = hit.channelName; + } + if (!existing.participants.includes(pubkey)) { + existing.participants.push(pubkey); + } + } else { + byChannel.set(hit.channelId, { + id: hit.channelId, + name: hit.channelName ?? null, + messageCount: 1, + lastActivityAt: hit.createdAt, + participants: [pubkey], + }); + } + } + return [...byChannel.values()].sort( + (a, b) => + b.messageCount - a.messageCount || b.lastActivityAt - a.lastActivityAt, + ); +} + +/** + * Human list of discussing names: "Alice", "Alice and Bob", + * "Alice, Bob and Carol", "Alice, Bob and 3 others". + */ +export function formatNameList(names: readonly string[], maxNames = 3): string { + if (names.length === 0) return ""; + if (names.length === 1) return names[0]; + if (names.length <= maxNames) { + return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`; + } + const shown = names.slice(0, maxNames - 1); + const others = names.length - shown.length; + return `${shown.join(", ")} and ${others} others`; +} + +// Generous cap: the row's CSS `truncate` does the visual cut at the card +// edge, so this only bounds DOM size against very long messages. Keep it +// comfortably above what an ultrawide screen can show on one line. +const SNIPPET_MAX_CHARS = 400; + +/** + * One-line preview of a discussing message: entity links and coordinates are + * dropped (the reader is already looking at the entity), whitespace collapses, + * and long content truncates on an ellipsis. + */ +export function discussionSnippet(content: string): string { + const cleaned = content + .replace(/buzz:\/\/\S+/g, "") + .replace(/\b\d{5}:[0-9a-f]{64}:\S+/gi, "") + .replace(/\s+/g, " ") + .trim(); + if (cleaned.length === 0) { + return "Shared a link to this."; + } + if (cleaned.length <= SNIPPET_MAX_CHARS) { + return cleaned; + } + return `${cleaned.slice(0, SNIPPET_MAX_CHARS - 1).trimEnd()}…`; +} diff --git a/desktop/src/features/projects/lib/projectShareLinks.test.mjs b/desktop/src/features/projects/lib/projectShareLinks.test.mjs new file mode 100644 index 00000000000..23325c578a9 --- /dev/null +++ b/desktop/src/features/projects/lib/projectShareLinks.test.mjs @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + issueShareLink, + parseAddressableCoordinate, + projectShareLink, + pullRequestShareLink, + repositoryShareLink, + shareTabForWorkspaceTab, + workspaceTabForShareTab, +} from "./projectShareLinks.ts"; + +const OWNER = "a".repeat(64); +const EVENT_ID = "b".repeat(64); +const REPO_ADDRESS = `30617:${OWNER}:flappy-bee`; +const PROJECT_ADDRESS = `30621:${OWNER}:pollinator`; + +test("parseAddressableCoordinate splits only the two structural separators", () => { + assert.deepEqual(parseAddressableCoordinate(`30617:${OWNER}:a:b`), { + kind: 30617, + owner: OWNER, + dtag: "a:b", + }); + assert.deepEqual( + parseAddressableCoordinate(`30617:${OWNER.toUpperCase()}:repo`)?.owner, + OWNER, + ); +}); + +test("parseAddressableCoordinate rejects malformed coordinates", () => { + for (const address of [ + null, + undefined, + "", + OWNER, + `30617:${OWNER}`, + `30617:not-a-pubkey:repo`, + `30617:${OWNER}:`, + `:${OWNER}:repo`, + `notakind:${OWNER}:repo`, + ]) { + assert.equal(parseAddressableCoordinate(address), null, String(address)); + } +}); + +test("projectShareLink links explicit projects by their 30621 coordinate", () => { + assert.equal( + projectShareLink({ projectAddress: PROJECT_ADDRESS }), + `buzz://project?owner=${OWNER}&d=pollinator`, + ); +}); + +test("projectShareLink carries the active workspace tab for both link kinds", () => { + assert.equal( + projectShareLink({ projectAddress: PROJECT_ADDRESS }, "prs"), + `buzz://project?owner=${OWNER}&d=pollinator&tab=prs`, + ); + // Legacy projects share as buzz://repo and keep the tab too. + assert.equal( + projectShareLink({ projectAddress: REPO_ADDRESS }, "issues"), + `buzz://repo?owner=${OWNER}&d=flappy-bee&tab=issues`, + ); +}); + +test("workspace tab ids map onto link tabs and back", () => { + assert.equal(shareTabForWorkspaceTab("prs"), "prs"); + assert.equal(shareTabForWorkspaceTab("issues"), "issues"); + assert.equal(shareTabForWorkspaceTab("files"), "files"); + assert.equal(shareTabForWorkspaceTab("contributors"), "contributors"); + // "activity" is the workspace's name for the commit list. + assert.equal(shareTabForWorkspaceTab("activity"), "commits"); + assert.equal(workspaceTabForShareTab("commits"), "activity"); + assert.equal(workspaceTabForShareTab("prs"), "prs"); + // Overview and PR-detail sub-tabs have no link spelling. + assert.equal(shareTabForWorkspaceTab("overview"), undefined); + assert.equal(shareTabForWorkspaceTab("pr-conversation"), undefined); +}); + +test("projectShareLink links legacy projects as their backing repository", () => { + assert.equal( + projectShareLink({ projectAddress: REPO_ADDRESS }), + `buzz://repo?owner=${OWNER}&d=flappy-bee`, + ); +}); + +test("projectShareLink declines coordinates the link format cannot express", () => { + for (const dtag of [ + "has space", + "..", + ".hidden", + "x".repeat(65), + "emoji🐝", + ]) { + assert.equal( + projectShareLink({ projectAddress: `30621:${OWNER}:${dtag}` }), + null, + dtag, + ); + } + // Some other addressable kind is not a project or repository. + assert.equal(projectShareLink({ projectAddress: `30000:${OWNER}:x` }), null); +}); + +test("repositoryShareLink links the repository coordinate", () => { + assert.equal( + repositoryShareLink({ repoAddress: REPO_ADDRESS }), + `buzz://repo?owner=${OWNER}&d=flappy-bee`, + ); + assert.equal( + repositoryShareLink({ repoAddress: PROJECT_ADDRESS }), + null, + "a project coordinate is not a repository", + ); +}); + +test("issue and pull request links carry the event id and repo coordinate", () => { + assert.equal( + issueShareLink({ id: EVENT_ID, repoAddress: REPO_ADDRESS }), + `buzz://issue?id=${EVENT_ID}&owner=${OWNER}&d=flappy-bee`, + ); + assert.equal( + pullRequestShareLink({ id: EVENT_ID, repoAddress: REPO_ADDRESS }), + `buzz://pr?id=${EVENT_ID}&owner=${OWNER}&d=flappy-bee`, + ); +}); + +test("issue and pull request links require a repo coordinate and hex id", () => { + assert.equal(issueShareLink({ id: EVENT_ID, repoAddress: null }), null); + assert.equal(pullRequestShareLink({ id: EVENT_ID, repoAddress: null }), null); + assert.equal( + issueShareLink({ id: "short", repoAddress: REPO_ADDRESS }), + null, + ); + assert.equal( + pullRequestShareLink({ id: "short", repoAddress: REPO_ADDRESS }), + null, + ); +}); diff --git a/desktop/src/features/projects/lib/projectShareLinks.ts b/desktop/src/features/projects/lib/projectShareLinks.ts new file mode 100644 index 00000000000..7598539f002 --- /dev/null +++ b/desktop/src/features/projects/lib/projectShareLinks.ts @@ -0,0 +1,139 @@ +/** + * Share links for the Projects read models. + * + * Every builder returns `null` instead of throwing when the entity cannot be + * addressed by a `buzz://` link — addressable d-tags accept a wider charset + * (and 1024 bytes) than the link format's `[a-zA-Z0-9._-]{1,64}`, and issues + * and pull requests loaded outside a repository have no coordinate at all. + * Callers hide the share affordance on `null` rather than copying a link that + * would not parse on the receiving side. + */ + +import { + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; +import { + buildIssueLink, + buildProjectLink, + buildPullRequestLink, + buildRepoLink, + type EntityLinkTab, + isLinkableCoordinate, +} from "@/shared/lib/entityLink"; + +import type { ProjectIssue } from "../projectIssues.mjs"; +import type { Project, Repository } from "../projectModels"; +import type { ProjectPullRequest } from "../projectPullRequests.mjs"; + +type Coordinate = { kind: number; owner: string; dtag: string }; + +const HEX64_RE = /^[a-fA-F0-9]{64}$/; + +/** + * Split an addressable coordinate (`::`). Only the first two + * separators are structural — d-tags may themselves contain colons, so the + * remainder is taken verbatim. + */ +export function parseAddressableCoordinate( + address: string | null | undefined, +): Coordinate | null { + if (!address) return null; + + const kindEnd = address.indexOf(":"); + if (kindEnd < 1) return null; + const ownerEnd = address.indexOf(":", kindEnd + 1); + if (ownerEnd < 0) return null; + + const kind = Number(address.slice(0, kindEnd)); + const owner = address.slice(kindEnd + 1, ownerEnd); + const dtag = address.slice(ownerEnd + 1); + if (!Number.isInteger(kind) || !HEX64_RE.test(owner) || dtag.length === 0) { + return null; + } + + return { kind, owner: owner.toLowerCase(), dtag }; +} + +function repositoryCoordinate( + repoAddress: string | null | undefined, +): Coordinate | null { + const coordinate = parseAddressableCoordinate(repoAddress); + return coordinate?.kind === KIND_REPO_ANNOUNCEMENT ? coordinate : null; +} + +/** + * Map a workspace tab id (`WorkspaceTabs` vocabulary) onto the link format's + * tab value. The overview tab is the link's default and PR-detail sub-tabs + * have their own `buzz://pr` links, so both map to `undefined` (no tab). + */ +export function shareTabForWorkspaceTab( + workspaceTab: string, +): EntityLinkTab | undefined { + switch (workspaceTab) { + case "files": + case "issues": + case "prs": + case "contributors": + case "channels": + return workspaceTab; + case "activity": + return "commits"; + default: + return undefined; + } +} + +/** Inverse of `shareTabForWorkspaceTab`, for the receiving side. */ +export function workspaceTabForShareTab(tab: EntityLinkTab): string { + return tab === "commits" ? "activity" : tab; +} + +/** + * Link to a project. Legacy (implicit) projects are backed by a repository + * announcement rather than a kind:30621 event, so they share as `buzz://repo` + * — which resolves to the same project route on the receiving side. + */ +export function projectShareLink( + project: Project, + tab?: EntityLinkTab, +): string | null { + const coordinate = parseAddressableCoordinate(project.projectAddress); + if (!coordinate || !isLinkableCoordinate(coordinate.owner, coordinate.dtag)) { + return null; + } + + if (coordinate.kind === KIND_PROJECT_ANNOUNCEMENT) { + return buildProjectLink({ ...coordinate, tab }); + } + return coordinate.kind === KIND_REPO_ANNOUNCEMENT + ? buildRepoLink({ ...coordinate, tab }) + : null; +} + +export function repositoryShareLink(repository: Repository): string | null { + const coordinate = repositoryCoordinate(repository.repoAddress); + return coordinate && isLinkableCoordinate(coordinate.owner, coordinate.dtag) + ? buildRepoLink(coordinate) + : null; +} + +export function issueShareLink(issue: ProjectIssue): string | null { + const coordinate = repositoryCoordinate(issue.repoAddress); + return coordinate && + HEX64_RE.test(issue.id) && + isLinkableCoordinate(coordinate.owner, coordinate.dtag) + ? buildIssueLink({ ...coordinate, id: issue.id }) + : null; +} + +export function pullRequestShareLink( + pullRequest: ProjectPullRequest, +): string | null { + const coordinate = repositoryCoordinate(pullRequest.repoAddress); + return coordinate && + HEX64_RE.test(pullRequest.id) && + isLinkableCoordinate(coordinate.owner, coordinate.dtag) + ? buildPullRequestLink({ ...coordinate, id: pullRequest.id }) + : null; +} diff --git a/desktop/src/features/projects/lib/projectsViewHelpers.ts b/desktop/src/features/projects/lib/projectsViewHelpers.ts index 45bb3e3254d..87fc34e64ed 100644 --- a/desktop/src/features/projects/lib/projectsViewHelpers.ts +++ b/desktop/src/features/projects/lib/projectsViewHelpers.ts @@ -17,7 +17,7 @@ export type ProjectsRepositoryScope = | "local" | "buzz" | "linked"; -export type ProjectsWorkItemScope = "all" | "mine"; +export type ProjectsWorkItemScope = "all" | "mine" | "assigned"; export type ProjectsFilter = | "all" | "mine" @@ -119,9 +119,13 @@ export function writeStoredRepositoryScope(scope: ProjectsRepositoryScope) { } } -function readStoredWorkItemScope(key: string): ProjectsWorkItemScope { +function readStoredWorkItemScope( + key: string, + allowed: ProjectsWorkItemScope[], +): ProjectsWorkItemScope { try { - return globalThis.localStorage?.getItem(key) === "mine" ? "mine" : "all"; + const value = globalThis.localStorage?.getItem(key); + return allowed.find((scope) => scope === value) ?? "all"; } catch { return "all"; } @@ -136,7 +140,9 @@ function writeStoredWorkItemScope(key: string, scope: ProjectsWorkItemScope) { } export function readStoredPullRequestScope(): ProjectsWorkItemScope { - return readStoredWorkItemScope(PROJECTS_PULL_REQUEST_SCOPE_STORAGE_KEY); + return readStoredWorkItemScope(PROJECTS_PULL_REQUEST_SCOPE_STORAGE_KEY, [ + "mine", + ]); } export function writeStoredPullRequestScope(scope: ProjectsWorkItemScope) { @@ -144,7 +150,10 @@ export function writeStoredPullRequestScope(scope: ProjectsWorkItemScope) { } export function readStoredIssueScope(): ProjectsWorkItemScope { - return readStoredWorkItemScope(PROJECTS_ISSUE_SCOPE_STORAGE_KEY); + return readStoredWorkItemScope(PROJECTS_ISSUE_SCOPE_STORAGE_KEY, [ + "mine", + "assigned", + ]); } export function writeStoredIssueScope(scope: ProjectsWorkItemScope) { diff --git a/desktop/src/features/projects/projectIssues.d.mts b/desktop/src/features/projects/projectIssues.d.mts index 4b0420602cf..8abf67b4fae 100644 --- a/desktop/src/features/projects/projectIssues.d.mts +++ b/desktop/src/features/projects/projectIssues.d.mts @@ -28,12 +28,17 @@ export type ProjectIssue = { originAgentName: string | null; labels: string[]; recipients: string[]; + assignees: string[]; + assigneeOperationHeads: Record; status: ProjectIssueStatus; statusEventId: string | null; updatedAt: number; comments: ProjectIssueComment[]; }; +export const ISSUE_ASSIGNMENT_LABEL: "assignment"; +export const ISSUE_UNASSIGNMENT_LABEL: "unassignment"; + export const PROJECT_ISSUE_STATUS: { TRIAGE: "Triage"; BACKLOG: "Backlog"; diff --git a/desktop/src/features/projects/projectIssues.mjs b/desktop/src/features/projects/projectIssues.mjs index 331837ac5ba..49363d6551f 100644 --- a/desktop/src/features/projects/projectIssues.mjs +++ b/desktop/src/features/projects/projectIssues.mjs @@ -1,3 +1,13 @@ +import { sortEvents } from "../../shared/api/relayClientShared.ts"; + +// Issue assignment mirrors PR review requests (projectPullRequests.mjs): +// a kind:1 comment labeled with this `t` tag whose `p` tags are the +// assignees. Labeled text notes stay readable for any client that treats +// them as plain comments, and the `p` tags route the assignment into the +// assignee's mention feed (inbox) for free. +export const ISSUE_ASSIGNMENT_LABEL = "assignment"; +export const ISSUE_UNASSIGNMENT_LABEL = "unassignment"; + export const PROJECT_ISSUE_STATUS = { TRIAGE: "Triage", BACKLOG: "Backlog", @@ -73,21 +83,98 @@ function statusFromEvent(issue, statusEvent) { return PROJECT_ISSUE_STATUS.BACKLOG; } -function commentsForIssue(issueId, commentEvents) { - return commentEvents - .filter((event) => - event.tags.some( - (tag) => (tag[0] === "e" || tag[0] === "E") && tag[1] === issueId, - ), - ) - .sort((left, right) => left.created_at - right.created_at) - .map((event) => ({ - id: event.id, - content: event.content, - tags: getImetaTags(event), - author: event.pubkey, - createdAt: event.created_at, - })); +/** + * Assignment state is reduced from trusted kind:1 operations. `t: assignment` + * adds each `p` tag and `t: unassignment` removes it. The issue root's `p` + * tags are notification routing only. + * + * Trusted signers are the issue author and repo owner (who may change anyone), + * plus any community member whose operation names only themselves. Uncaused + * self-service operations are applied first, authoritative operations second, + * and self-service operations that causally reference the current per-assignee + * operation head last. This prevents signer-controlled timestamps from + * overriding authority while allowing a later observed owner/author decision + * to be superseded by the affected assignee. + */ +function assignmentStateForIssue(issue, issueCommentEvents) { + const allowedActors = allowedActorsForRoot(issue); + const assignees = new Set(); + const operationHeads = new Map(); + const uncausedSelfServiceOperations = []; + const authoritativeOperations = []; + const causalSelfServiceOperations = []; + const events = sortEvents( + issueCommentEvents.filter( + (event) => + event.kind === 1 && + event.tags.some((tag) => tag[0] === "e" && tag[1] === issue.id), + ), + ); + for (const event of events) { + const labels = getAllTags(event, "t"); + const isAssignment = labels.includes(ISSUE_ASSIGNMENT_LABEL); + const isUnassignment = labels.includes(ISSUE_UNASSIGNMENT_LABEL); + if (isAssignment === isUnassignment) continue; + const signer = event.pubkey.toLowerCase(); + const pubkeys = getAllTags(event, "p").map((pubkey) => + pubkey.toLowerCase(), + ); + const isSelfOperation = pubkeys.length === 1 && pubkeys[0] === signer; + if (!allowedActors.has(signer) && !isSelfOperation) continue; + const operation = { + id: event.id.toLowerCase(), + isAssignment, + pubkeys, + }; + if (allowedActors.has(signer)) { + authoritativeOperations.push(operation); + } else { + const priorTags = event.tags.filter((tag) => tag[0] === "prior"); + if (priorTags.length === 0) { + uncausedSelfServiceOperations.push(operation); + continue; + } + if ( + priorTags.length !== 1 || + !/^[a-fA-F0-9]{64}$/.test(priorTags[0]?.[1] ?? "") + ) { + continue; + } + causalSelfServiceOperations.push({ + ...operation, + prior: priorTags[0][1].toLowerCase(), + }); + } + } + for (const { id, isAssignment, pubkeys, prior } of [ + ...uncausedSelfServiceOperations, + ...authoritativeOperations, + ...causalSelfServiceOperations, + ]) { + if (prior && operationHeads.get(pubkeys[0]) !== prior) continue; + for (const pubkey of pubkeys) { + if (isAssignment) { + assignees.add(pubkey); + } else { + assignees.delete(pubkey); + } + operationHeads.set(pubkey, id); + } + } + return { + assignees: [...assignees], + heads: Object.fromEntries(operationHeads), + }; +} + +function commentsForIssue(issueCommentEvents) { + return sortEvents(issueCommentEvents).map((event) => ({ + id: event.id, + content: event.content, + tags: getImetaTags(event), + author: event.pubkey, + createdAt: event.created_at, + })); } export function eventToProjectIssue( @@ -96,7 +183,13 @@ export function eventToProjectIssue( commentEvents = [], ) { const latestStatus = latestStatusForIssue(issue, statusEvents); - const comments = commentsForIssue(issue.id, commentEvents); + const issueCommentEvents = commentEvents.filter((event) => + event.tags.some( + (tag) => (tag[0] === "e" || tag[0] === "E") && tag[1] === issue.id, + ), + ); + const comments = commentsForIssue(issueCommentEvents); + const assignmentState = assignmentStateForIssue(issue, issueCommentEvents); const title = getTag(issue, "subject") || issue.content.split("\n")[0] || @@ -114,6 +207,8 @@ export function eventToProjectIssue( originAgentName: getTag(issue, "buzz-origin-agent") ?? null, labels: getAllTags(issue, "t"), recipients: getAllTags(issue, "p"), + assignees: assignmentState.assignees, + assigneeOperationHeads: assignmentState.heads, status: statusFromEvent(issue, latestStatus), statusEventId: latestStatus?.id ?? null, updatedAt: diff --git a/desktop/src/features/projects/projectIssues.test.mjs b/desktop/src/features/projects/projectIssues.test.mjs index 3275412149e..4c670213b5d 100644 --- a/desktop/src/features/projects/projectIssues.test.mjs +++ b/desktop/src/features/projects/projectIssues.test.mjs @@ -6,6 +6,8 @@ import { eventToProjectIssue, getAllTags, getTag, + ISSUE_ASSIGNMENT_LABEL, + ISSUE_UNASSIGNMENT_LABEL, nextProjectIssueCommentCreatedAt, PROJECT_ISSUE_STATUS, } from "./projectIssues.mjs"; @@ -44,6 +46,33 @@ function statusEvent({ kind, pubkey, createdAt }) { }; } +function assignmentComment( + pubkey, + assignees, + id, + label = ISSUE_ASSIGNMENT_LABEL, + createdAt = 200, + prior, +) { + return { + id, + kind: 1, + pubkey, + created_at: createdAt, + content: + label === ISSUE_ASSIGNMENT_LABEL + ? "Assigned this issue" + : "Unassigned this issue", + tags: [ + ["e", "e".repeat(64), "", "root"], + ["a", REPO_ADDRESS], + ...assignees.map((value) => ["p", value]), + ["t", label], + ...(prior ? [["prior", prior]] : []), + ], + }; +} + test("ignores status events from a different pubkey", () => { const attackerClosed = statusEvent({ kind: 1632, @@ -151,6 +180,227 @@ test("parses public and private-safe issue provenance", () => { assert.equal(privateIssue.originAgentName, "Builder"); }); +test("assignees follow trusted assignment operations in deterministic order", () => { + const assignee = "d".repeat(64); + const otherAssignee = "f".repeat(64); + const volunteer = "5".repeat(64); + + const issue = eventToProjectIssue( + issueEvent(), + [], + [ + // Author assigns (self-assignment included) — trusted. + assignmentComment(AUTHOR, [assignee.toUpperCase(), AUTHOR], "assign-1"), + // Repo owner assigns — trusted; duplicate assignee dedupes. + assignmentComment(OWNER, [assignee, otherAssignee], "assign-2"), + // Any member self-assigning (sole p tag is the signer) — trusted. + assignmentComment(volunteer, [volunteer], "assign-3"), + // Untrusted signer assigning someone else — ignored. + assignmentComment(ATTACKER, ["a".repeat(64)], "assign-4"), + // Untrusted signer sneaking themselves in alongside others — ignored. + assignmentComment(ATTACKER, [ATTACKER, "b".repeat(64)], "assign-5"), + // A volunteer may remove only themselves. + assignmentComment( + volunteer, + [volunteer], + "unassign-1", + ISSUE_UNASSIGNMENT_LABEL, + 201, + ), + // An untrusted signer cannot remove somebody else. + assignmentComment( + ATTACKER, + [otherAssignee], + "unassign-2", + ISSUE_UNASSIGNMENT_LABEL, + 202, + ), + // Repo owner may remove any assignee. + assignmentComment( + OWNER, + [otherAssignee], + "unassign-3", + ISSUE_UNASSIGNMENT_LABEL, + 203, + ), + // Same-second operations use event id as a stable tie-breaker: + // assign sorts before unassign here, leaving the assignee removed. + assignmentComment(OWNER, [otherAssignee], "a-assign", undefined, 204), + assignmentComment( + OWNER, + [otherAssignee], + "z-unassign", + ISSUE_UNASSIGNMENT_LABEL, + 204, + ), + // Trusted plain comment without the label adds nothing. + { + id: "plain-comment", + kind: 1, + pubkey: AUTHOR, + created_at: 201, + content: "Just a comment", + tags: [ + ["e", "e".repeat(64), "", "root"], + ["p", ATTACKER], + ], + }, + ], + ); + + assert.deepEqual(issue.assignees.sort(), [AUTHOR, assignee].sort()); +}); + +test("owner unassignment overrides a future-dated self-assignment", () => { + const volunteer = "5".repeat(64); + const issue = eventToProjectIssue( + issueEvent(), + [], + [ + assignmentComment( + volunteer, + [volunteer], + "future-self-assign", + undefined, + 1_000, + ), + assignmentComment( + OWNER, + [volunteer], + "owner-unassign", + ISSUE_UNASSIGNMENT_LABEL, + 200, + ), + ], + ); + + assert.deepEqual(issue.assignees, []); +}); + +test("owner assignment overrides a future-dated self-unassignment", () => { + const volunteer = "5".repeat(64); + const issue = eventToProjectIssue( + issueEvent(), + [], + [ + assignmentComment( + volunteer, + [volunteer], + "future-self-unassign", + ISSUE_UNASSIGNMENT_LABEL, + 1_000, + ), + assignmentComment(OWNER, [volunteer], "owner-assign", undefined, 200), + ], + ); + + assert.deepEqual(issue.assignees, [volunteer]); +}); + +test("causal self-unassignment can follow an owner assignment", () => { + const volunteer = "5".repeat(64); + const ownerAssignmentId = "1".repeat(64); + const selfUnassignmentId = "2".repeat(64); + const issue = eventToProjectIssue( + issueEvent(), + [], + [ + assignmentComment(OWNER, [volunteer], ownerAssignmentId), + assignmentComment( + volunteer, + [volunteer], + selfUnassignmentId, + ISSUE_UNASSIGNMENT_LABEL, + 300, + ownerAssignmentId, + ), + ], + ); + + assert.deepEqual(issue.assignees, []); + assert.equal(issue.assigneeOperationHeads[volunteer], selfUnassignmentId); +}); + +test("causal self-assignment can follow an owner unassignment", () => { + const volunteer = "5".repeat(64); + const ownerUnassignmentId = "3".repeat(64); + const selfAssignmentId = "4".repeat(64); + const issue = eventToProjectIssue( + issueEvent(), + [], + [ + assignmentComment( + OWNER, + [volunteer], + ownerUnassignmentId, + ISSUE_UNASSIGNMENT_LABEL, + ), + assignmentComment( + volunteer, + [volunteer], + selfAssignmentId, + ISSUE_ASSIGNMENT_LABEL, + 300, + ownerUnassignmentId, + ), + ], + ); + + assert.deepEqual(issue.assignees, [volunteer]); + assert.equal(issue.assigneeOperationHeads[volunteer], selfAssignmentId); +}); + +test("ignores a causal self-operation with a stale prior", () => { + const volunteer = "5".repeat(64); + const initialAssignmentId = "6".repeat(64); + const ownerUnassignmentId = "7".repeat(64); + const staleSelfAssignmentId = "8".repeat(64); + const issue = eventToProjectIssue( + issueEvent(), + [], + [ + assignmentComment(OWNER, [volunteer], initialAssignmentId), + assignmentComment( + OWNER, + [volunteer], + ownerUnassignmentId, + ISSUE_UNASSIGNMENT_LABEL, + 250, + ), + assignmentComment( + volunteer, + [volunteer], + staleSelfAssignmentId, + ISSUE_ASSIGNMENT_LABEL, + 300, + initialAssignmentId, + ), + ], + ); + + assert.deepEqual(issue.assignees, []); + assert.equal(issue.assigneeOperationHeads[volunteer], ownerUnassignmentId); +}); + +test("issue recipients remain notification routing, not assignments", () => { + const recipient = "d".repeat(64); + const otherRecipient = "f".repeat(64); + const issue = eventToProjectIssue( + issueEvent({ + tags: [ + ["a", REPO_ADDRESS], + ["subject", "Something is broken"], + // Routing tag every issue carries — not an assignment. + ["p", OWNER], + ["p", recipient.toUpperCase()], + ["p", otherRecipient], + ], + }), + ); + + assert.deepEqual(issue.assignees, []); +}); + test("builds repository-scoped issue creation tags", () => { assert.deepEqual( buildGitIssueTags({ diff --git a/desktop/src/features/projects/projectOwnerControl.test.mjs b/desktop/src/features/projects/projectOwnerControl.test.mjs new file mode 100644 index 00000000000..51eeaf4e75f --- /dev/null +++ b/desktop/src/features/projects/projectOwnerControl.test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isDanglingProjectMemberPublish, + PartialAnnouncementPublishError, +} from "./projectOwnerControl.ts"; + +const OWNER = "a".repeat(64); + +function event(kind, id) { + return { + id, + kind, + pubkey: OWNER, + created_at: 100, + content: "", + tags: [["d", "platform"]], + }; +} + +// ── isDanglingProjectMemberPublish ────────────────────────────────────────── +// +// The ACP side publishes [project, repository] announcements sequentially and +// reports already-live events alongside a failure. addRepo may resume only +// from the exact "project landed, repository did not" state. + +test("project-landed/repository-missing partial publish is resumable", () => { + const error = new PartialAnnouncementPublishError("publish failed", [ + event(30621, "1".repeat(64)), + ]); + assert.equal(isDanglingProjectMemberPublish(error), true); +}); + +test("a clean failure (nothing published) is not resumable", () => { + const error = new PartialAnnouncementPublishError("publish failed", []); + assert.equal(isDanglingProjectMemberPublish(error), false); + assert.equal(isDanglingProjectMemberPublish(new Error("boom")), false); +}); + +test("a failure after both events landed is not resumable", () => { + const error = new PartialAnnouncementPublishError("publish failed", [ + event(30621, "1".repeat(64)), + event(30617, "2".repeat(64)), + ]); + assert.equal(isDanglingProjectMemberPublish(error), false); +}); diff --git a/desktop/src/features/projects/projectOwnerControl.ts b/desktop/src/features/projects/projectOwnerControl.ts new file mode 100644 index 00000000000..1d0f0c7fb4a --- /dev/null +++ b/desktop/src/features/projects/projectOwnerControl.ts @@ -0,0 +1,128 @@ +import { subscribeControlResults } from "@/features/agents/observerRelayStore"; +import { sendAgentObserverControl } from "@/shared/api/observerRelay"; +import type { RelayEvent } from "@/shared/api/types"; +import { + KIND_PROJECT_ANNOUNCEMENT, + KIND_REPO_ANNOUNCEMENT, +} from "@/shared/constants/kinds"; + +const OWNER_CONTROL_TIMEOUT_MS = 20_000; + +export type ProjectOwnerAnnouncementTemplate = { + kind: number; + content: string; + createdAt?: number; + tags: string[][]; +}; + +type ProjectOwnerControlResult = { + type: "publish_project_owner_announcements"; + status: string; + requestId: string; + events?: RelayEvent[]; + error?: string | null; +}; + +/** + * A remote-agent publish that failed after some announcements already landed. + * `publishedEvents` holds the events the ACP side reported as live before the + * failure, so callers can tell a clean failure (retry republishes everything) + * from a partial one (retry must resume from where publication stopped). + */ +export class PartialAnnouncementPublishError extends Error { + readonly publishedEvents: RelayEvent[]; + + constructor(message: string, publishedEvents: RelayEvent[]) { + super(message); + this.name = "PartialAnnouncementPublishError"; + this.publishedEvents = publishedEvents; + } +} + +/** + * True when a failed [project, repository] announcement publish stopped + * exactly between its two events — the project head landed, the repository + * event did not. That is the only partial state addRepo can resume from by + * republishing just the repository event; anything else must surface. + */ +export function isDanglingProjectMemberPublish( + error: unknown, +): error is PartialAnnouncementPublishError { + return ( + error instanceof PartialAnnouncementPublishError && + error.publishedEvents.some( + (event) => event.kind === KIND_PROJECT_ANNOUNCEMENT, + ) && + !error.publishedEvents.some( + (event) => event.kind === KIND_REPO_ANNOUNCEMENT, + ) + ); +} + +/** Ask a remotely managed agent to publish project events under its own key. */ +export function publishOwnedAgentProjectAnnouncements( + agentPubkey: string, + announcements: ProjectOwnerAnnouncementTemplate[], +): Promise { + const requestId = crypto.randomUUID(); + + return new Promise((resolve, reject) => { + let settled = false; + const finish = ( + result: { events: RelayEvent[] } | { error: Error }, + ): void => { + if (settled) return; + settled = true; + window.clearTimeout(timeout); + unsubscribe(); + if ("error" in result) reject(result.error); + else resolve(result.events); + }; + const unsubscribe = subscribeControlResults(agentPubkey, (frame) => { + const projectFrame = frame as unknown as ProjectOwnerControlResult; + if ( + projectFrame.type !== "publish_project_owner_announcements" || + projectFrame.requestId !== requestId + ) { + return; + } + if (projectFrame.status === "ok" && projectFrame.events) { + finish({ events: projectFrame.events }); + } else { + const message = + projectFrame.error || "The agent could not update this project."; + // The ACP side publishes announcements sequentially and reports the + // ones that were already live when a later one failed. Preserve that + // partial-success metadata instead of discarding it, so callers can + // resume publication rather than treating the state as unrecoverable. + const published = projectFrame.events ?? []; + finish({ + error: + published.length > 0 + ? new PartialAnnouncementPublishError(message, published) + : new Error(message), + }); + } + }); + const timeout = window.setTimeout(() => { + finish({ + error: new Error( + "The project owner agent did not respond. Make sure it is running and try again.", + ), + }); + }, OWNER_CONTROL_TIMEOUT_MS); + + void sendAgentObserverControl(agentPubkey, { + type: "publish_project_owner_announcements", + requestId, + announcements, + }).catch((error: unknown) => { + finish({ + error: + error instanceof Error + ? error + : new Error("Failed to contact the project owner agent."), + }); + }); + }); +} diff --git a/desktop/src/features/projects/projectRepositoryCreation.test.mjs b/desktop/src/features/projects/projectRepositoryCreation.test.mjs index 0a80e382c98..a7bcc5bccca 100644 --- a/desktop/src/features/projects/projectRepositoryCreation.test.mjs +++ b/desktop/src/features/projects/projectRepositoryCreation.test.mjs @@ -345,3 +345,104 @@ test("buildProjectPatchTemplate catches duplicate d in live head via full-envelo /NIP-MP.*'d'/, ); }); + +// ── buildAddedRepositoryEventTemplatesFromHead: partial-publish recovery ──── +// +// addRepo publishes two events sequentially (project head, then repository). +// If the repository publish fails after the project head lands, the head +// references a coordinate with no repository event — a dangling member. +// Retry must heal it: when the live head already lists the coordinate but no +// kind-30617 head exists there, the builder returns resume templates instead +// of throwing the "already contains" race error. + +test("retry after event 2 fails (event 1 succeeded) returns resume templates that heal the dangling member", () => { + const OWNER = "a".repeat(64); + const existingAddress = `30617:${OWNER}:desktop`; + const newAddress = `30617:${OWNER}:mobile`; + const accessChannelId = "11111111-1111-4111-8111-111111111111"; + const preAddHead = { + id: "e".repeat(64), + kind: 30621, + pubkey: OWNER, + created_at: 100, + content: "", + tags: [ + ["d", "platform"], + ["buzz-channel", accessChannelId], + ["a", existingAddress], + ], + }; + + // Attempt 1: fresh add. Project template gains the address; the repository + // head at the coordinate does not exist yet. + const attempt1 = buildAddedRepositoryEventTemplatesFromHead({ + accessChannelId, + existingRepositoryAddresses: [existingAddress], + liveHead: preAddHead, + name: "Mobile", + ownerPubkey: OWNER, + repositoryHeadExists: false, + }); + assert.equal(attempt1.resume, false); + assert.deepEqual( + attempt1.project.tags.filter((tag) => tag[0] === "a").map((tag) => tag[1]), + [existingAddress, newAddress], + ); + + // Event 1 (project head) lands; event 2 (repository) fails. The live head + // now references the coordinate, but no repository head exists. + const danglingHead = { + ...preAddHead, + id: "f".repeat(64), + created_at: 101, + tags: [...preAddHead.tags, ["a", newAddress]], + }; + + // Attempt 2 (retry): must not throw "already contains" — it must resume. + const attempt2 = buildAddedRepositoryEventTemplatesFromHead({ + accessChannelId, + existingRepositoryAddresses: [existingAddress, newAddress], + liveHead: danglingHead, + name: "Mobile", + ownerPubkey: OWNER, + repositoryHeadExists: false, + }); + assert.equal(attempt2.resume, true); + // The project template must not double-add the coordinate. + assert.deepEqual( + attempt2.project.tags.filter((tag) => tag[0] === "a").map((tag) => tag[1]), + [existingAddress, newAddress], + ); + // The repository template is the same missing event the first attempt + // failed to publish. + assert.deepEqual(attempt2.repository, attempt1.repository); + assert.equal(attempt2.repositoryAddress, newAddress); +}); + +test("a coordinate in the live head WITH a live repository head is still a concurrent-add conflict", () => { + const OWNER = "a".repeat(64); + const newAddress = `30617:${OWNER}:mobile`; + const liveHead = { + id: "e".repeat(64), + kind: 30621, + pubkey: OWNER, + created_at: 100, + content: "", + tags: [ + ["d", "platform"], + ["a", newAddress], + ], + }; + assert.throws( + () => + buildAddedRepositoryEventTemplatesFromHead({ + accessChannelId: "11111111-1111-4111-8111-111111111111", + existingRepositoryAddresses: [], + liveHead, + name: "Mobile", + ownerPubkey: OWNER, + repositoryHeadExists: true, + }), + /already contains.*mobile.*another session/, + ); +}); diff --git a/desktop/src/features/projects/projectRepositoryCreation.ts b/desktop/src/features/projects/projectRepositoryCreation.ts index 832350f9016..08da3a41186 100644 --- a/desktop/src/features/projects/projectRepositoryCreation.ts +++ b/desktop/src/features/projects/projectRepositoryCreation.ts @@ -18,6 +18,8 @@ function repositoryDtagFromName(name: string): string { .replace(/^-+|-+$/g, ""); } +export { repositoryDtagFromName }; + /** * Creates a project-replacement event template from a live, signed raw head * (fetched immediately before the mutation). Only the `a` membership tags are @@ -132,6 +134,13 @@ export type AddedRepositoryEventTemplatesFromHead = { repository: ProjectEventTemplate; repositoryAddress: string; repositoryDtag: string; + /** + * True when the live head already references the coordinate but the caller + * indicated no repository head exists there (a dangling member from an + * earlier partial publish). The project head is already correct — publish + * only the repository event to heal. + */ + resume: boolean; }; /** @@ -152,6 +161,7 @@ export function buildAddedRepositoryEventTemplatesFromHead({ liveHead, name, ownerPubkey, + repositoryHeadExists = true, webUrl, }: { accessChannelId?: string; @@ -161,6 +171,14 @@ export function buildAddedRepositoryEventTemplatesFromHead({ liveHead: RelayEvent; name: string; ownerPubkey: string; + /** + * Whether a kind-30617 head already exists at the new coordinate. When the + * live project head references the coordinate but no repository head exists + * there, an earlier add-repository publish failed between its two events — + * return resume templates instead of throwing so retry can heal the + * dangling member. + */ + repositoryHeadExists?: boolean; webUrl?: string; }): AddedRepositoryEventTemplatesFromHead { const normalizedOwner = ownerPubkey.trim().toLowerCase(); @@ -179,9 +197,13 @@ export function buildAddedRepositoryEventTemplatesFromHead({ .filter((tag) => tag[0] === "a" && tag[1]) .map((tag) => tag[1] as string); - // If the repo is already in the live head (race: another session added it), - // surface that to the caller. - if (liveAddresses.includes(repositoryAddress)) { + // If the repo is already in the live head with a live repository head at + // the coordinate (race: another session added it), surface that to the + // caller. Without a repository head the membership is a dangling member + // from a partial publish — resume by publishing only the repository event. + const resume = + liveAddresses.includes(repositoryAddress) && !repositoryHeadExists; + if (liveAddresses.includes(repositoryAddress) && repositoryHeadExists) { throw new Error( `This project already contains "${repositoryDtag}" (it was added by another session).`, ); @@ -216,9 +238,12 @@ export function buildAddedRepositoryEventTemplatesFromHead({ const normalizedWebUrl = webUrl?.trim(); if (normalizedWebUrl) repositoryTags.push(["web", normalizedWebUrl]); - const newAddresses = isUnavailableMember - ? [...liveAddresses] - : [...liveAddresses, repositoryAddress]; + // In resume mode the live head already lists the coordinate; the project + // template is a no-op republish guard and must not double-add the address. + const newAddresses = + isUnavailableMember || resume + ? [...liveAddresses] + : [...liveAddresses, repositoryAddress]; const projectTemplate = buildProjectPatchTemplate({ liveHead, @@ -235,5 +260,6 @@ export function buildAddedRepositoryEventTemplatesFromHead({ }, repositoryAddress, repositoryDtag, + resume, }; } diff --git a/desktop/src/features/projects/projectWorkItems.ts b/desktop/src/features/projects/projectWorkItems.ts index c2170e687a5..75ba6789084 100644 --- a/desktop/src/features/projects/projectWorkItems.ts +++ b/desktop/src/features/projects/projectWorkItems.ts @@ -1,5 +1,9 @@ import { relayClient } from "@/shared/api/relayClient"; import type { RelayEvent } from "@/shared/api/types"; +import { + fetchAssignmentOperationEvents, + mergeEventsById, +} from "./assignmentOperationFetch"; import { KIND_GIT_ISSUE, KIND_GIT_PR_UPDATE, @@ -33,6 +37,7 @@ type ProjectRepository = /** Optional event groups that can fail without discarding root work items. */ export type ProjectWorkItemSection = + | "assignments" | "comments" | "pull-request-updates" | "statuses"; @@ -85,13 +90,14 @@ export async function fetchProjectsWorkItems( ), ), ]; - const [rootResult, updateResult, commentResult, statusResult] = + const rootPromise = fetchEvents({ + kinds: [KIND_GIT_ISSUE, KIND_GIT_PULL_REQUEST], + "#a": repoAddresses, + limit: 2_000, + }); + const [rootResult, updateResult, commentResult, statusResult, assignResult] = await Promise.allSettled([ - fetchEvents({ - kinds: [KIND_GIT_ISSUE, KIND_GIT_PULL_REQUEST], - "#a": repoAddresses, - limit: 2_000, - }), + rootPromise, fetchEvents({ kinds: [KIND_GIT_PR_UPDATE], "#a": repoAddresses, @@ -112,6 +118,19 @@ export async function fetchProjectsWorkItems( "#a": repoAddresses, limit: 2_000, }), + // Assignment state must reduce over the complete operation history — + // the 2,000-comment window above is shared across every loaded repo + // and can evict older assignment operations. Keyed by issue id (`#e`) + // because that is the only tag constraint the relay applies before its + // SQL LIMIT; see fetchAssignmentOperationEvents. + rootPromise.then((rootEvents) => + fetchAssignmentOperationEvents( + rootEvents + .filter((event) => event.kind === KIND_GIT_ISSUE) + .map((event) => event.id), + fetchEvents, + ), + ), ]); if (rootResult.status === "rejected") { @@ -122,8 +141,10 @@ export async function fetchProjectsWorkItems( const updateEvents = updateResult.status === "fulfilled" ? updateResult.value : []; - const commentEvents = - commentResult.status === "fulfilled" ? commentResult.value : []; + const commentEvents = mergeEventsById( + commentResult.status === "fulfilled" ? commentResult.value : [], + assignResult.status === "fulfilled" ? assignResult.value : [], + ); const statusEvents = statusResult.status === "fulfilled" ? statusResult.value : []; const rootsByRepo = groupByRepoAddress(rootResult.value); @@ -194,6 +215,9 @@ export async function fetchProjectsWorkItems( ) .sort((left, right) => right.issue.updatedAt - left.issue.updatedAt); const sharedFailedSections: ProjectWorkItemSection[] = []; + if (assignResult.status === "rejected") { + sharedFailedSections.push("assignments"); + } if (commentResult.status === "rejected") { sharedFailedSections.push("comments"); } diff --git a/desktop/src/features/projects/ui/CopyShareLinkMenuItem.tsx b/desktop/src/features/projects/ui/CopyShareLinkMenuItem.tsx new file mode 100644 index 00000000000..53d564dba88 --- /dev/null +++ b/desktop/src/features/projects/ui/CopyShareLinkMenuItem.tsx @@ -0,0 +1,37 @@ +import { Link2 } from "lucide-react"; + +import { copyTextToClipboard } from "@/shared/lib/clipboard"; +import { DropdownMenuItem } from "@/shared/ui/dropdown-menu"; + +/** + * "Copy link" row for the Projects action menus. Renders nothing when the + * entity has no shareable coordinate (see `lib/projectShareLinks`) so we never + * offer a link that would fail to parse for the recipient. + */ +export function CopyShareLinkMenuItem({ + label = "Copy link", + link, + successMessage = "Link copied to clipboard", + testId, +}: { + label?: string; + link: string | null; + successMessage?: string; + testId?: string; +}) { + if (!link) return null; + + return ( + { + event.preventDefault(); + event.stopPropagation(); + copyTextToClipboard(link, successMessage); + }} + > + + {label} + + ); +} diff --git a/desktop/src/features/projects/ui/DiscussionChannels.tsx b/desktop/src/features/projects/ui/DiscussionChannels.tsx new file mode 100644 index 00000000000..008b85f858f --- /dev/null +++ b/desktop/src/features/projects/ui/DiscussionChannels.tsx @@ -0,0 +1,405 @@ +import { Hash } from "lucide-react"; +import * as React from "react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; +import { + type DiscussionChannel, + discussionSnippet, + groupDiscussionChannels, +} from "@/features/projects/lib/discussionChannels"; +import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; +import { useSearchMessagesQuery } from "@/features/search/hooks"; +import type { SearchHit } from "@/shared/api/searchTypes"; +import { cn } from "@/shared/lib/cn"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +// Relay search caps a page at 500. Use the full page and surface a lower-bound +// marker when it fills rather than silently presenting partial totals as exact. +const DISCUSSION_SEARCH_LIMIT = 500; +const COLLAPSED_MENTION_ROWS = 3; + +/** + * Messages (and the channels containing them) that link the entity matched + * by `query` (see `discussionChannels.ts` for how queries are built). + * Results cover only channels the viewer can read — the relay authorizes + * every search hit. Profiles for the discussing authors resolve in the same + * hook so rows can show names and avatars. + */ +export function useDiscussionChannels(query: string): { + channels: DiscussionChannel[]; + hits: SearchHit[]; + isLoading: boolean; + isTruncated: boolean; +} { + const search = useSearchMessagesQuery(query, { + limit: DISCUSSION_SEARCH_LIMIT, + }); + const hits = React.useMemo( + () => + [...(search.data?.hits ?? [])].sort((a, b) => b.createdAt - a.createdAt), + [search.data], + ); + const channels = React.useMemo(() => groupDiscussionChannels(hits), [hits]); + return { + channels, + hits, + isLoading: search.isLoading, + isTruncated: hits.length >= DISCUSSION_SEARCH_LIMIT, + }; +} + +/** Channel display name, preferring the hit's name, then the channel list, + * then a short id so private/renamed channels still render something. */ +function useChannelNameLookup(enabled: boolean) { + const channelsQuery = useChannelsQuery({ enabled }); + return React.useCallback( + (id: string, nameFromHit: string | null) => + nameFromHit ?? + channelsQuery.data?.find((channel) => channel.id === id)?.name ?? + id.slice(0, 8), + [channelsQuery.data], + ); +} + +/** + * "Channels" card for PR, issue, and commit detail views: a bordered, + * softly tinted block with a small header that separates it from the + * surrounding text. Each channel gets a single truncating line — + * "Alice, Bob and Carol discussed this in #channel · 2h ago — snippet…" — + * cut at the card edge regardless of screen width. Clicking the snippet + * jumps to that message (thread-aware), the same way inbox items do. + * Renders nothing until at least one channel references the entity, so + * the detail layout stays unchanged for undiscussed items. + */ +export function DiscussedInChannels({ + className, + entityLabel = "this", + query, + testId, +}: { + /** Extra spacing/alignment classes from the call site. */ + className?: string; + /** How the sentence names the entity, e.g. "this issue". */ + entityLabel?: string; + query: string; + testId?: string; +}) { + const { channels, hits, isTruncated } = useDiscussionChannels(query); + const { goChannel, openSearchHit } = useAppNavigation(); + const [expanded, setExpanded] = React.useState(false); + const channelName = useChannelNameLookup(channels.length > 0); + const visible = expanded + ? channels + : channels.slice(0, COLLAPSED_MENTION_ROWS); + const profilesQuery = useUsersBatchQuery( + visible.flatMap((channel) => channel.participants), + { enabled: visible.length > 0 }, + ); + const profiles = profilesQuery.data?.profiles; + // Hits are sorted newest first, so the first hit per channel is the one a + // click should land on (and the one worth quoting). + const latestHitByChannel = React.useMemo(() => { + const byChannel = new Map(); + for (const hit of hits) { + if (hit.channelId && !byChannel.has(hit.channelId)) { + byChannel.set(hit.channelId, hit); + } + } + return byChannel; + }, [hits]); + if (channels.length === 0) return null; + + const hiddenCount = channels.length - visible.length; + + return ( +
+

+ Channels +

+
+ {visible.map((channel) => { + const latestHit = latestHitByChannel.get(channel.id); + if (!latestHit) return null; + const name = channelName(channel.id, channel.name); + return ( +
+ + + + + {" "} + discussed {entityLabel} in{" "} + + + + +
+ ); + })} +
+ {hiddenCount > 0 ? ( + + ) : null} + {isTruncated ? ( +

+ Showing mentions from the 500 most recent search results. +

+ ) : null} +
+ ); +} + +const NAME_LIST_MAX = 3; + +/** + * The "Alice, Bob and Carol" (or "Alice, Bob and 2 others") part of the + * sentence, with each name opening that person's profile popover. Mirrors + * the wording of `formatNameList` in `discussionChannels.ts`. + */ +function DiscussionNameList({ + participants, + profiles, +}: { + participants: string[]; + profiles: UserProfileLookup | undefined; +}) { + const showAll = participants.length <= NAME_LIST_MAX; + const shown = showAll + ? participants + : participants.slice(0, NAME_LIST_MAX - 1); + const others = participants.length - shown.length; + return ( + <> + {shown.map((pubkey, index) => { + const isLast = index === shown.length - 1; + const separator = + index === 0 ? null : isLast && others === 0 ? " and " : ", "; + return ( + + {separator ? ( + {separator} + ) : null} + + + + + ); + })} + {others > 0 ? ( + + {" "} + and {others} others + + ) : null} + + ); +} + +function ParticipantFacepile({ + interactive = false, + participants, + profiles, +}: { + /** Wrap each avatar in a profile popover. Leave off when the facepile is + * nested inside another button (nested interactive elements are invalid). */ + interactive?: boolean; + participants: string[]; + profiles: UserProfileLookup | undefined; +}) { + const shown = participants.slice(0, 4); + const overflow = participants.length - shown.length; + return ( + + {shown.map((pubkey, index) => { + const label = resolveUserLabel({ profiles, pubkey }); + if (!interactive) { + return ( + 0 && "-ml-1.5", + )} + displayName={label} + key={pubkey} + size="xs" + /> + ); + } + return ( + + + + ); + })} + {overflow > 0 ? ( + + +{overflow} + + ) : null} + + ); +} + +/** + * Full-width channel list for the workspace "Channels" tab: every channel + * where the repository (or its PRs/issues) is linked in chat, with the + * people who discussed it there. + */ +export function DiscussionChannelsPanel({ query }: { query: string }) { + const { channels, isLoading, isTruncated } = useDiscussionChannels(query); + const { goChannel } = useAppNavigation(); + const channelName = useChannelNameLookup(channels.length > 0); + const profilesQuery = useUsersBatchQuery( + channels.flatMap((channel) => channel.participants), + { enabled: channels.length > 0 }, + ); + const profiles = profilesQuery.data?.profiles; + + if (isLoading) { + return ( +

+ Searching channel discussions… +

+ ); + } + if (channels.length === 0) { + return ( +

+ No channels reference this repository yet. Paste its link (or a PR or + issue link) in a channel and it will show up here. +

+ ); + } + + return ( +
+
    + {channels.map((channel) => { + const name = channelName(channel.id, channel.name); + const speakers = channel.participants + .slice(0, 2) + .map((pubkey) => resolveUserLabel({ profiles, pubkey })); + const others = channel.participants.length - speakers.length; + return ( +
  • + +
  • + ); + })} +
+ {isTruncated ? ( +

+ Showing the latest {DISCUSSION_SEARCH_LIMIT} mentions; totals may be + higher. +

+ ) : null} +
+ ); +} diff --git a/desktop/src/features/projects/ui/IssueAssigneesRow.tsx b/desktop/src/features/projects/ui/IssueAssigneesRow.tsx new file mode 100644 index 00000000000..b41145b01ad --- /dev/null +++ b/desktop/src/features/projects/ui/IssueAssigneesRow.tsx @@ -0,0 +1,346 @@ +import { Search, X } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; + +import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; +import type { Repository as Project } from "@/features/projects/hooks"; +import { + useAssignProjectIssueMutation, + useUnassignProjectIssueMutation, +} from "@/features/projects/issueAssignments"; +import type { ProjectIssue } from "@/features/projects/projectIssues.mjs"; +import { useUserSearchQuery } from "@/features/profile/hooks"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { UserSearchResult } from "@/shared/api/types"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +function profileForPubkey(pubkey: string, profiles?: UserProfileLookup) { + return profiles?.[normalizePubkey(pubkey)] ?? null; +} + +function labelForPubkey(pubkey: string, profiles?: UserProfileLookup) { + const profile = profileForPubkey(pubkey, profiles); + return ( + profile?.displayName?.trim() || + profile?.nip05Handle?.trim() || + truncatePubkey(pubkey) + ); +} + +function assigneeSearchLabel(user: UserSearchResult) { + return ( + user.displayName?.trim() || + user.nip05Handle?.trim() || + truncatePubkey(user.pubkey) + ); +} + +/** Compact overlapping assignee avatars for issue list rows. */ +export function IssueAssigneeFacepile({ + assignees, + profiles, +}: { + assignees: string[]; + profiles?: UserProfileLookup; +}) { + if (assignees.length === 0) return null; + return ( + + {assignees.slice(0, 3).map((pubkey) => { + const profile = profileForPubkey(pubkey, profiles); + const label = labelForPubkey(pubkey, profiles); + return ( + + + + ); + })} + + ); +} + +/** Assignee avatars and the assignment picker for an issue. + * + * The issue author, repo owner, or managed-agent owner + * (`canAssignOthers`) get the full people/agent picker. Everyone else + * who is signed in gets a self-assign button — readers trust an + * assignment whose only assignee is its signer (see `projectIssues.mjs`). + */ +export function IssueAssigneesRow({ + canAssignOthers, + issue, + profiles, + project, + signAsManagedOwner, + viewerPubkey, +}: { + canAssignOthers: boolean; + issue: ProjectIssue; + profiles?: UserProfileLookup; + project: Project; + signAsManagedOwner: boolean; + viewerPubkey: string | null; +}) { + const [pickerOpen, setPickerOpen] = React.useState(false); + const [assigneeQuery, setAssigneeQuery] = React.useState(""); + const assignmentOperationInFlightRef = React.useRef(false); + const assignMutation = useAssignProjectIssueMutation(project); + const unassignMutation = useUnassignProjectIssueMutation(project); + const deferredAssigneeQuery = React.useDeferredValue(assigneeQuery.trim()); + const currentAssignees = React.useMemo( + () => new Set(issue.assignees.map(normalizePubkey)), + [issue.assignees], + ); + const userSearchQuery = useUserSearchQuery(deferredAssigneeQuery, { + allowEmpty: true, + enabled: canAssignOthers && pickerOpen, + limit: 50, + }); + const isArchivedDiscovery = useIsArchivedPredicate(); + // Unlike PR reviewers, the issue author stays in the candidate list — + // self-assignment is normal issue-tracker behavior. + const candidates = React.useMemo( + () => + (userSearchQuery.data ?? []).filter((user) => { + const pubkey = normalizePubkey(user.pubkey); + return !currentAssignees.has(pubkey) && !isArchivedDiscovery(pubkey); + }), + [currentAssignees, isArchivedDiscovery, userSearchQuery.data], + ); + const viewer = viewerPubkey ? normalizePubkey(viewerPubkey) : null; + const operationSigner = viewer ?? project.owner; + + const handleAssign = React.useCallback( + async ( + pubkey: string, + assigneeLabel: string, + options?: { asManagedOwner?: boolean }, + ) => { + if (assignMutation.isPending || assignmentOperationInFlightRef.current) + return; + assignmentOperationInFlightRef.current = true; + try { + await assignMutation.mutateAsync({ + assignees: [pubkey], + assigneeLabel, + issue, + signerPubkey: operationSigner, + signAsManagedOwner: options?.asManagedOwner ?? false, + }); + setPickerOpen(false); + setAssigneeQuery(""); + toast.success("Issue assigned."); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to assign issue.", + ); + } finally { + assignmentOperationInFlightRef.current = false; + } + }, + [assignMutation, issue, operationSigner], + ); + + const handleUnassign = React.useCallback( + async (pubkey: string, assigneeLabel: string) => { + if (unassignMutation.isPending || assignmentOperationInFlightRef.current) + return; + assignmentOperationInFlightRef.current = true; + try { + await unassignMutation.mutateAsync({ + assignees: [pubkey], + assigneeLabel, + issue, + signerPubkey: operationSigner, + signAsManagedOwner, + }); + toast.success("Issue unassigned."); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to unassign issue.", + ); + } finally { + assignmentOperationInFlightRef.current = false; + } + }, + [issue, operationSigner, signAsManagedOwner, unassignMutation], + ); + + React.useEffect(() => { + if (!pickerOpen) setAssigneeQuery(""); + }, [pickerOpen]); + + const canSelfAssign = + viewer !== null && !canAssignOthers && !currentAssignees.has(viewer); + + if (issue.assignees.length === 0 && !canAssignOthers && !canSelfAssign) { + return null; + } + + return ( +
+ {issue.assignees.map((pubkey) => { + const profile = profileForPubkey(pubkey, profiles); + const label = labelForPubkey(pubkey, profiles); + const canUnassign = + canAssignOthers || + (viewer !== null && normalizePubkey(pubkey) === viewer); + const avatar = ( + + ); + return ( + + + {canUnassign ? ( + + ) : ( + {avatar} + )} + + + {canUnassign ? `Unassign ${label}` : `${label} — assigned`} + + + ); + })} + {canSelfAssign && viewer ? ( + + ) : null} + {canAssignOthers ? ( + + + + + + + Assign issue + + Choose a person or agent to work on this issue. + + +
+ + setAssigneeQuery(event.target.value)} + placeholder="Search people and agents" + value={assigneeQuery} + /> +
+
+ {userSearchQuery.isLoading ? ( +

+ Searching… +

+ ) : candidates.length > 0 ? ( + candidates.map((candidate) => { + const label = assigneeSearchLabel(candidate); + return ( + + ); + }) + ) : ( +

+ No matching people or agents. +

+ )} +
+
+
+ ) : null} +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectCards.tsx b/desktop/src/features/projects/ui/ProjectCards.tsx index 09535fdfc77..4c387b60c3a 100644 --- a/desktop/src/features/projects/ui/ProjectCards.tsx +++ b/desktop/src/features/projects/ui/ProjectCards.tsx @@ -24,6 +24,7 @@ import { relativeTime, } from "@/features/projects/lib/projectsViewHelpers"; import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability"; +import { projectShareLink } from "@/features/projects/lib/projectShareLinks"; import { projectTerminalLabel } from "@/features/projects/ui/useOpenProjectTerminal"; import { PROJECT_LIST_ROW_CLASS, @@ -50,6 +51,7 @@ import { Card } from "@/shared/ui/card"; import { DropdownMenuItem } from "@/shared/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { CopyShareLinkMenuItem } from "./CopyShareLinkMenuItem"; import { ProjectListRowMenu } from "./ProjectListRowMenu"; function ProjectUpdatedLabel({ @@ -169,6 +171,10 @@ const PROJECT_STAT_ITEMS = [ }, ] as const; +/** + * Textual commit/PR/issue counts. Repository lists show these next to the + * activity bar; project lists show the bar alone (counts via its tooltips). + */ export function ProjectStatsRow({ summary, fixedColumns = false, @@ -224,7 +230,10 @@ export function ProjectActivityBar({ // z-10 lifts the bar above the card's full-surface open button so it // can receive hover events. Fixed h-2 wrapper keeps layout stable // while the inner bar grows on hover. -
+
{total > 0 ? items @@ -395,6 +404,10 @@ function ProjectActionsMenu({ return ( + { event.preventDefault(); @@ -541,13 +554,8 @@ export function ProjectGridCard({ />
-
-
- -
-
- -
+
+
@@ -612,11 +620,10 @@ export function ProjectListRow({ />
- -
+
diff --git a/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx b/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx index 0f7d95a01c7..7937c3aba44 100644 --- a/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx @@ -8,7 +8,9 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; +import { commitDiscussionQuery } from "@/features/projects/lib/discussionChannels"; import type { ProjectRepoCommit, ProjectRepoDiff } from "@/shared/api/types"; +import { DiscussedInChannels } from "./DiscussionChannels"; import { CopyCommitHashButton } from "./ProjectCommitCopyButton"; import { PROJECT_DETAIL_PANEL_CLASS } from "./projectPanelStyles"; import { ProfileIdentityButton } from "./ProjectProfileIdentity"; @@ -115,6 +117,14 @@ export function ProjectCommitDetailPanel({ {diff?.commitBody ? ( ) : null} + ; - onGoChannel: (channelId: string) => void; onGoProjectHome: () => void; onGoProjects: () => void; project: Project; + /** + * Workspace tab the copied link should open (`undefined` = overview), so + * sharing from the PR or issue list lands recipients on that same list. + */ + shareTab?: EntityLinkTab; }) { return (
)} - {project.projectChannelId ? ( - - ) : null} +
+ {actions} + +
); diff --git a/desktop/src/features/projects/ui/ProjectDetailChromeActions.tsx b/desktop/src/features/projects/ui/ProjectDetailChromeActions.tsx new file mode 100644 index 00000000000..552745824f1 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectDetailChromeActions.tsx @@ -0,0 +1,30 @@ +import type { Project, Repository } from "@/features/projects/hooks"; +import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement"; + +/** + * Repository-scoped controls for the project detail chrome. Extracted from + * `ProjectDetailScreen` to keep the screen under the file-size ratchet. + */ +export function ProjectDetailChromeActions({ + identityPubkey, + onRepositoryChange, + project, + projects, + repository, +}: { + identityPubkey?: string; + onRepositoryChange: (repositoryId: string) => void; + project: Project; + projects: Project[]; + repository: Repository; +}) { + return ( + + ); +} diff --git a/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx b/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx index 6676044c86d..e5cb68431f4 100644 --- a/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx @@ -16,7 +16,7 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; -import { GitBranch, GitCommitHorizontal } from "lucide-react"; +import { GitBranch } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { CopyCommitHashButton } from "./ProjectCommitCopyButton"; @@ -84,13 +84,14 @@ export function ContributorsPanel({ {rows.map((row, index) => (
- {row.lastCommitAt ? ( - <> - · - updated {relativeTime(row.lastCommitAt)} - - ) : null}
+ {row.lastCommitAt ? ( + + {relativeTime(row.lastCommitAt)} + + ) : null}
))}
@@ -175,12 +179,6 @@ export function ActivityPanel({ return (
-
- -

- Commits -

-
{commits.map((commit) => { const matchedProfile = profileForCommit( @@ -241,12 +239,6 @@ export function ActivityPanel({ title={commit.subject} trailing={ <> - - {relativeTime(commit.timestamp)} - + + {relativeTime(commit.timestamp)} + } /> diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx index 1b2adf316a8..9285d256872 100644 --- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, ExternalLink, FolderGit2 } from "lucide-react"; +import { ArrowLeft, FolderGit2 } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; @@ -60,8 +60,13 @@ import { resolveProjectDefaultBranch, } from "@/features/projects/lib/projectBranches"; import { normalizeRepositoryUrl } from "@/features/projects/lib/projectsViewHelpers"; +import { + shareTabForWorkspaceTab, + workspaceTabForShareTab, +} from "@/features/projects/lib/projectShareLinks"; import { selectProjectRepository } from "@/features/projects/projectModels"; import { KIND_REPO_ANNOUNCEMENT } from "@/shared/constants/kinds"; +import type { EntityLinkTab } from "@/shared/lib/entityLink"; import { useProjectRepoPresentation } from "@/features/projects/useProjectRepoHost"; import { WorkspaceTabs } from "./ProjectWorkspaceTabs"; import type { RepoSourceHeaderControls } from "./ProjectRepositorySource"; @@ -73,7 +78,7 @@ import { import type { CreateIssueDialogInput } from "./CreateIssueDialog"; import { ProjectBranchActionDialogs } from "./ProjectBranchActionDialogs"; import { ProjectDetailChrome } from "./ProjectDetailChrome"; -import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement"; +import { ProjectDetailChromeActions } from "./ProjectDetailChromeActions"; import { UnavailableProjectRepositories } from "./UnavailableProjectRepositories"; import { PROJECT_TAB_CRUMB_LABELS, @@ -84,10 +89,13 @@ import { type ProjectDetailScreenProps = { commitHash?: string; + entityNavigationId?: string; projectId: string; pullRequestId?: string; issueId?: string; repositoryId?: string; + /** Workspace tab requested by a share link (link vocabulary). */ + tab?: EntityLinkTab; }; const PROJECT_DETAIL_PANEL_SEARCH_KEYS = [ @@ -103,7 +111,15 @@ const PROJECT_REPOSITORY_SEARCH_KEYS = [ ] as const; export function ProjectDetailScreen(props: ProjectDetailScreenProps) { - const { commitHash, projectId, pullRequestId, issueId, repositoryId } = props; + const { + commitHash, + entityNavigationId, + projectId, + pullRequestId, + issueId, + repositoryId, + tab, + } = props; const { goChannel, goProject, goProjects } = useAppNavigation(); const { activeCommunity } = useCommunities(); const mainInsetRef = useMainInsetRef(); @@ -161,14 +177,14 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { const [selectedPullRequestId, setSelectedPullRequestId] = React.useState< string | null >(pullRequestId ?? null); - React.useEffect( - () => setSelectedPullRequestId(pullRequestId ?? null), - [pullRequestId], - ); const [selectedIssueId, setSelectedIssueId] = React.useState( issueId ?? null, ); - React.useEffect(() => setSelectedIssueId(issueId ?? null), [issueId]); + // biome-ignore lint/correctness/useExhaustiveDependencies: the transient request ID deliberately reapplies an unchanged entity selection. + React.useEffect(() => { + setSelectedPullRequestId(pullRequestId ?? null); + setSelectedIssueId(issueId ?? null); + }, [entityNavigationId, issueId, pullRequestId]); const [selectedCommitHash, setSelectedCommitHash] = React.useState< string | null >(commitHash ?? null); @@ -176,9 +192,14 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { () => setSelectedCommitHash(commitHash ?? null), [commitHash], ); - // Bumped when breadcrumb navigation should land on the project Overview - // tab; remounts WorkspaceTabs, which owns the selected-tab state. + // Remounts WorkspaceTabs when breadcrumb navigation should open Overview. const [tabsResetKey, setTabsResetKey] = React.useState(0); + // Local state lets breadcrumb and repository resets drop a share-link tab. + const [requestedTab, setRequestedTab] = React.useState< + EntityLinkTab | undefined + >(tab); + // biome-ignore lint/correctness/useExhaustiveDependencies: the transient request ID deliberately reapplies an unchanged share-link tab. + React.useEffect(() => setRequestedTab(tab), [entityNavigationId, tab]); // Mirror of the WorkspaceTabs selection so the breadcrumb can name the // active sub-tab. The Overview (readme) tab is "home" and gets no crumb. const [activeTab, setActiveTab] = React.useState("overview"); @@ -484,6 +505,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { const issuePubkeys = (issuesQuery.data ?? []).flatMap((issue) => [ issue.author, ...issue.recipients, + ...issue.assignees, ...issue.comments.map((comment) => comment.author), ]); return [ @@ -814,6 +836,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { setSelectedPullRequestId(null); setSelectedIssueId(null); setSelectedCommitHash(null); + setRequestedTab(undefined); // Remount the workspace tabs so the project page opens on Overview // instead of whatever tab the work item left behind. setTabsResetKey((key) => key + 1); @@ -828,6 +851,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { setSelectedPullRequestId(null); setSelectedIssueId(null); setSelectedCommitHash(null); + setRequestedTab(undefined); setRepoSource("remote"); setTabsResetKey((key) => key + 1); }; @@ -846,63 +870,28 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { activeTabCrumb={activeTabCrumb} activeWorkItemCrumb={activeWorkItemCrumb} chromeRef={projectDetailHeaderChromeRef} - onGoChannel={(channelId) => { - void goChannel(channelId); - }} onGoProjectHome={handleGoToProjectHome} onGoProjects={() => { void goProjects(); }} project={project} + shareTab={ + activeWorkItemCrumb + ? undefined + : shareTabForWorkspaceTab(activeTab) + } />
-
-
-
-
-

- {project.name} -

- {repoRemote.webUrl && - (repoRemote.host.kind !== "external" || - repoSource === "local") ? ( - - ) : null} -
-
-
- - Repository - - -
-
-
- + } projectId={project.id} repoDiff={displayedRepoDiff} repoDiffError={displayedRepoDiffError} diff --git a/desktop/src/features/projects/ui/ProjectIssueCommentTimeline.tsx b/desktop/src/features/projects/ui/ProjectIssueCommentTimeline.tsx index 72c76c54737..75566530754 100644 --- a/desktop/src/features/projects/ui/ProjectIssueCommentTimeline.tsx +++ b/desktop/src/features/projects/ui/ProjectIssueCommentTimeline.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, ChevronUp, History, MessageSquare } from "lucide-react"; +import { ChevronDown, ChevronUp, History } from "lucide-react"; import * as React from "react"; import type { ProjectIssue } from "@/features/projects/hooks"; @@ -11,6 +11,8 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; import { ProfileAuthorName } from "./ProjectProfileIdentity"; import { ProjectRichContent } from "./ProjectRichContent"; @@ -44,14 +46,14 @@ export function ProjectIssueCommentTimeline({ const displayedComments = isCollapsed ? [] : visibleComments; if (orderedComments.length === 0) { - return

No comments yet.

; + return null; } return ( -
+
) : null} - {displayedComments.map((comment, index) => ( -
-
- {index < displayedComments.length - 1 ? ( - - ) : null} - - - -
-
-
- - - {resolveUserLabel({ profiles, pubkey: comment.author })} - - - - {relativeTime(comment.createdAt)} - + {displayedComments.map((comment, index) => { + const authorLabel = resolveUserLabel({ + profiles, + pubkey: comment.author, + }); + return ( +
+
+ {index < displayedComments.length - 1 ? ( + + ) : null} + {/* bg-background keeps the connector line from showing through + while the avatar image (or delayed fallback) loads. */} + +
+
+ {/* h-5 matches the avatar so the header line centers on it. */} +
+ + + {authorLabel} + + + + {relativeTime(comment.createdAt)} + +
+
-
-
- ))} + ); + })}
); } diff --git a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx index 6f34248dd1e..7b7d196ec47 100644 --- a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx @@ -2,6 +2,7 @@ import { CircleCheck, CircleDot, CircleX, MessageSquare } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; +import { useIsManagedAgent } from "@/features/agent-memory/hooks"; import { ForumComposer } from "@/features/forum/ui/ForumComposer"; import { type ProjectIssue, @@ -13,20 +14,26 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; +import { entityDiscussionQuery } from "@/features/projects/lib/discussionChannels"; +import { issueShareLink } from "@/features/projects/lib/projectShareLinks"; import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; +import { useIdentityQuery } from "@/shared/api/hooks"; import type { ChannelMember } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { IssueAssigneeFacepile, IssueAssigneesRow } from "./IssueAssigneesRow"; import { ProjectFeedRow, ProjectFeedRowCluster, ProjectFeedRowMonoCell, } from "./ProjectFeedRow"; +import { DiscussedInChannels } from "./DiscussionChannels"; import { ProjectIssueCommentTimeline } from "./ProjectIssueCommentTimeline"; import { ProjectOriginReference } from "./ProjectOriginReference"; import { OverviewRailSection } from "./ProjectOverviewPanel"; import { ProfileIdentityButton } from "./ProjectProfileIdentity"; import { ProjectRichContent } from "./ProjectRichContent"; +import { ShareLinkButton } from "./ShareLinkButton"; export function issueStatusClassName(status: ProjectIssue["status"]) { if (status === "Done") return "text-purple-400"; @@ -97,7 +104,7 @@ function IssueRow({ /> {authorLabel} created this - issue {relativeTime(issue.createdAt)} + issue · {issue.status} @@ -111,6 +118,7 @@ function IssueRow({ ))} } + eventId={issue.id} onOpen={onOpen} statusIcon={ @@ -119,6 +127,10 @@ function IssueRow({ title={issue.title} trailing={ <> + {issue.comments.length > 0 ? ( ) : ( - a public channel + a private channel )} - (author-claimed) + + (author-claimed) + ); } @@ -41,10 +58,15 @@ export function ProjectOriginReference({ if (agentName) { return ( - started privately with + + started privately with + {agentName} diff --git a/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx index 0cb06b3e99a..9f56f97524e 100644 --- a/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx @@ -36,6 +36,8 @@ type ProjectOverviewPanelProps = { externalUrl?: string | null; files: ProjectRepoFile[]; gitDataState: GitDataState; + /** Hide the readme header rows when the workspace renders them itself. */ + hideReadmeHeader?: boolean; project: Project; onViewContributors: () => void; profiles?: UserProfileLookup; @@ -149,6 +151,7 @@ export function ProjectOverviewPanel({ externalUrl, files, gitDataState, + hideReadmeHeader, onViewContributors, project, profiles, @@ -182,6 +185,7 @@ export function ProjectOverviewPanel({ externalUrl={externalUrl} file={readmeFile} gitDataState={gitDataState} + hideHeader={hideReadmeHeader} sourceControls={sourceControls} unavailableReason={unavailableReason} /> diff --git a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx index 54e3689e40f..3544c42c497 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx @@ -17,6 +17,7 @@ import * as React from "react"; import { toast } from "sonner"; import { useIsManagedAgent } from "@/features/agent-memory/hooks"; +import { DiscussedInChannels } from "./DiscussionChannels"; import { ProjectOriginReference } from "./ProjectOriginReference"; import { ForumComposer } from "@/features/forum/ui/ForumComposer"; import { @@ -26,6 +27,8 @@ import { useCreateProjectPullRequestCommentMutation, } from "@/features/projects/hooks"; import { projectPullRequestCommentTimelineKind } from "@/features/projects/projectPullRequests.mjs"; +import { entityDiscussionQuery } from "@/features/projects/lib/discussionChannels"; +import { pullRequestShareLink } from "@/features/projects/lib/projectShareLinks"; import { formatExactTimestamp, relativeTime, @@ -51,6 +54,7 @@ import { import { ProjectRichContent } from "./ProjectRichContent"; import { PullRequestReviewersRow } from "./PullRequestReviewersRow"; import { PullRequestReviewCard } from "./PullRequestReviewCard"; +import { ShareLinkButton } from "./ShareLinkButton"; function profileForPubkey(pubkey: string, profiles?: UserProfileLookup) { return profiles?.[normalizePubkey(pubkey)] ?? null; @@ -201,10 +205,7 @@ function PullRequestCommitRow({ /> {authorLabel}{" "} - authored{" "} - - {relativeTime(createdAt)} - + authored {branch ? ( @@ -218,16 +219,25 @@ function PullRequestCommitRow({ testId="project-pull-request-commit-row" title={message} trailing={ - hash ? ( - - - - - ) : undefined + <> + {hash ? ( + + + + + ) : null} + + {relativeTime(createdAt)} + + } /> ); @@ -268,10 +278,7 @@ function PullRequestRow({ {authorLabel} {" "} - created this pull request{" "} - - {relativeTime(pullRequest.createdAt)} - + created this pull request {pullRequest.branchName ? ( @@ -312,6 +319,13 @@ function PullRequestRow({ title="View pull request" /> + + {relativeTime(pullRequest.createdAt)} + } /> @@ -338,10 +352,22 @@ export function PullRequestDetailHeader({ #{pullRequest.id.slice(0, 8)} + -

- - +

+ + - + created {relativeTime(pullRequest.createdAt)} -

+ +
{reviewHistory.length > 0 ? (
-

- - Add Your Comment -

{sourceControls ? (
@@ -163,7 +169,6 @@ export function ReadmePanel({ (channelsQuery.data ?? []).filter( @@ -84,7 +104,10 @@ export function ProjectRepositoryManagement({ channels={accessChannels} isCreating={createMutation.isPending} onAdd={async (input) => { - const result = await createMutation.mutateAsync(input); + const result = await createMutation.mutateAsync({ + ...input, + ownerControlAgentPubkey, + }); onChange(result.repository.id); toast.success(`Repository "${result.repository.name}" created.`); }} @@ -96,6 +119,7 @@ export function ProjectRepositoryManagement({ isAttaching={attachMutation.isPending} onAttach={async (candidate) => { const result = await attachMutation.mutateAsync({ + ownerControlAgentPubkey, project, repository: candidate, }); @@ -118,7 +142,7 @@ export function ProjectRepositoryManagement({