mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Lefthook selected pre-push lanes from the file set `git diff --name-only
HEAD @{push}` returns, which is overinclusive two different ways:
- **Rebase/merge poisoning.** `@{push}` is a two-dot diff against the
branch's last-pushed tip. After a rebase onto newer main — or the `git
merge origin/main` that `check-branch-skew.sh` itself instructs agents
to run — that diff includes everything `main` changed since the last
push, so `mobile-checks`, the `desktop-*` lanes, and `rust-tests` fire
on branches that never touched those trees (a recent relay-only push
discovered 266 files, 26 of them `mobile/`).
- **Brand-new branch in a worktree.** When `@{push}` doesn't resolve,
lefthook reads `$GIT_DIR/refs/remotes/origin/HEAD` — which doesn't exist
in a linked worktree's git-dir — then falls back to the
alphabetically-first `HEAD ->` remote. In a clone with a stale
third-party remote sorted before `origin`, that diffs against a frozen
tip and runs the full suite. All agent work happens in worktrees.
## Fix
Add `files: git diff --name-only origin/main...HEAD` to the six globbed
pre-push lanes (`rust-tests`, `desktop-check`, `desktop-typecheck`,
`desktop-test`, `desktop-tauri-checks`, `mobile-checks`). The three-dot
form is the branch's merge-base diff against `origin/main` — the same
base resolution `scripts/check-file-sizes-core.mjs` (`resolveBaseRef`)
and CI's dorny/paths-filter already use. A lane now fires only when this
branch actually changed a file it covers; `main`-side changes never
re-trigger it, and a new branch diffs against `origin/main` (empty diff
→ all globbed lanes skip) rather than a stale remote. `branch-skew` and
`file-size-check` stay unfiltered by design.
Also pin hook dispatch to the Hermit-managed lefthook. The generated
`.git/hooks/*` dispatchers prefer `$LEFTHOOK_BIN`, then a `lefthook` on
`PATH` — so a Homebrew lefthook shadowed the pinned 2.1.3. A `rc:
bin/.lefthookrc` (a lefthook config key `lefthook install` bakes into
every dispatcher) exports `LEFTHOOK_BIN` to `bin/lefthook`, so a push
runs the pinned version regardless of `PATH`. `just hooks` already runs
`lefthook install`, so re-running it picks up the `rc` line — no recipe
change needed.
`AGENTS.md` documents that lanes are scoped by merge-base diff and that
agent shells should lead `PATH` with `./bin` so pinned tools win.
## Verification
Reproduced both bugs in a scratch repo mirroring the worktree +
stale-remote topology, with lanes stubbed to echo on selection:
- **Bug 1:** relay-only branch, `git merge origin/main` (main carried
`mobile/` + `desktop/` changes). Without the fix, `desktop-check` and
`mobile-checks` fired; with it, three-dot diff = only the relay file,
all globbed lanes skip.
- **Bug 2:** brand-new branch, no upstream, in a linked worktree with an
alphabetically-first stale remote. Confirmed `@{push}` is `fatal`,
`refs/remotes/origin/HEAD` is absent from the worktree git-dir, and the
stale remote's `HEAD ->` sorts first. Without the fix it fired lanes off
the stale tip; with it, lanes scope to `origin/main` merge-base and
skip.
- **Positive controls:** a `mobile/**` commit still fires
`mobile-checks`; a `crates/**` commit still fires `rust-tests` +
`desktop-tauri-checks`.
- **Dispatch:** with Homebrew lefthook 2.1.10 on `PATH`, running the
real generated `.git/hooks/pre-push` under `LEFTHOOK_VERBOSE=1` shows it
sourcing `bin/.lefthookrc`, exporting `LEFTHOOK_BIN`, and running
`lefthook v2.1.3`.
- This PR's own push (a brand-new branch in a linked worktree — the bug
2 case) ran only `branch-skew` and `file-size-check`; no
mobile/desktop/rust lanes fired, since the branch touches only
`lefthook.yml`, `AGENTS.md`, and `bin/.lefthookrc`.
---------
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
646 lines
30 KiB
Markdown
646 lines
30 KiB
Markdown
# AGENTS.md — AI Agent Contributor Guide
|
|
|
|
This guide is for AI agents contributing to the Buzz codebase. It covers
|
|
agent-specific context and conventions. For general contributor info (setup,
|
|
code style, PR process, architecture), see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
|
---
|
|
|
|
## 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
|
|
|
|
Buzz spans five repos. This one (`block/buzz`) is the OSS source for the relay, desktop, mobile, and CLI. The others handle internal builds and deployment:
|
|
|
|
| Repo | Purpose |
|
|
|------|---------|
|
|
| [block/buzz](https://github.com/block/buzz) | OSS source — relay, desktop app, mobile app, CLI, agent harness |
|
|
| [squareup/buzz-releases](https://github.com/squareup/buzz-releases) | Buildkite pipelines producing Block-signed macOS + iOS builds with `-block` desktop version suffix |
|
|
| [squareup/sprout-oss](https://github.com/squareup/sprout-oss) | CI pipeline building the relay Docker image and pushing to internal ECR |
|
|
| [squareup/block-coder-tf-stacks](https://github.com/squareup/block-coder-tf-stacks) | Terraform + ArgoCD deploying the relay to the staging Kubernetes cluster |
|
|
| [squareup/sprout-backend-blox](https://github.com/squareup/sprout-backend-blox) | Desktop backend provider script connecting Blox workstation agents to the relay |
|
|
|
|
```
|
|
block/buzz (source)
|
|
├─► buzz-releases (desktop + mobile builds → Artifactory, GitHub, Mobile Releases)
|
|
├─► sprout-oss (relay Docker image → ECR)
|
|
│ └─► block-coder-tf-stacks (Helm chart → ArgoCD → staging cluster)
|
|
└─── sprout-backend-blox (Blox compute provider for Desktop agent launch)
|
|
```
|
|
|
|
See [RELEASING.md](RELEASING.md) for the desktop release flow and
|
|
[CONTRIBUTING.md § Ecosystem](CONTRIBUTING.md#ecosystem) for contributor
|
|
access information.
|
|
|
|
---
|
|
|
|
## Repo Structure
|
|
|
|
```
|
|
crates/
|
|
# Relay + core
|
|
buzz-relay # WebSocket relay server — main entry point; also hosts git + huddle audio
|
|
buzz-core # Core types, event verification, filter matching, kind registry
|
|
buzz-db # Postgres event store and data access layer
|
|
buzz-auth # Authentication and authorization
|
|
buzz-pubsub # Redis pub/sub fan-out, presence, typing indicators
|
|
buzz-search # Postgres FTS full-text search
|
|
buzz-audit # Hash-chain audit log
|
|
buzz-media # Blossom/S3 media storage
|
|
# Agent surface
|
|
buzz-acp # ACP harness bridging Buzz events to AI agents
|
|
buzz-agent # Minimal ACP-compliant agent (non-streaming, tool-calls-as-output)
|
|
buzz-dev-mcp # Developer MCP server — shell + file-edit tools
|
|
buzz-persona # Agent persona packs
|
|
buzz-workflow # YAML-as-code workflow engine (evalexpr conditions)
|
|
# Clients + interop
|
|
buzz-pair-relay # Ephemeral sidecar relay for NIP-AB device pairing
|
|
buzz-pairing-cli # CLI for NIP-AB device pairing interop testing
|
|
git-sign-nostr # Sign git objects with a Nostr key
|
|
git-credential-nostr # Git credential helper for Nostr-authed push/fetch
|
|
# Tooling + shared
|
|
buzz-cli # Agent-first CLI
|
|
buzz-sdk # Typed Nostr event builders
|
|
buzz-admin # Operator CLI for relay administration
|
|
buzz-ws-client # Shared NIP-42 WebSocket client (connect, auth, publish)
|
|
buzz-test-client # Integration test client and E2E test suite
|
|
sprig # All-in-one harness bundling ACP, agent, and dev MCP
|
|
|
|
desktop/ # Tauri 2 + React 19 desktop app
|
|
web/ # Browser web client (repo browser, served by the relay)
|
|
mobile/ # Flutter mobile app
|
|
migrations/ # SQL migrations (auto-applied on relay startup)
|
|
scripts/ # Dev tooling
|
|
.env.example # Config template — copy to .env before running
|
|
```
|
|
|
|
---
|
|
|
|
## Getting Started
|
|
|
|
```bash
|
|
. ./bin/activate-hermit # activate hermit toolchain (Rust, Node, etc.)
|
|
cp .env.example .env # configure local environment
|
|
just setup # install deps, run migrations
|
|
just relay # start relay at ws://localhost:3000
|
|
just ci # run before any PR
|
|
```
|
|
|
|
See CONTRIBUTING.md for full setup details and dependency requirements.
|
|
|
|
---
|
|
|
|
## Quality Gates
|
|
|
|
Run `just ci` before every PR — it runs repository-wide formatting, lint,
|
|
and static checks; Rust, Tauri, desktop, and mobile tests; and desktop and web
|
|
builds. Clippy passing does not mean fmt passes; run both.
|
|
|
|
Run `just test` for integration tests if you touched `buzz-relay`,
|
|
`buzz-db`, or `buzz-auth` — these require a running Postgres and Redis.
|
|
|
|
**Pre-commit hooks** are installed automatically by `just setup` and auto-fix
|
|
formatting via `stage_fixed`. Pre-commit runs fix variants in parallel (Rust
|
|
fmt, Tauri Rust fmt, desktop biome fix, web biome fix, mobile dart format).
|
|
Auto-fixable issues are fixed and re-staged; unfixable lint issues block the
|
|
commit. **Pre-push hooks** run the repository-wide differential file-size gate,
|
|
clippy (workspace + Tauri), desktop TypeScript typechecking (`tsc --noEmit`),
|
|
and fast unit tests in parallel (Rust, desktop JS, Tauri Rust, mobile Flutter)
|
|
— no overlap with pre-commit. Builds are CI-only. Run `just fix-all` to auto-fix
|
|
all formatting in one shot. Run `just ci` for the full local gate. Run `just
|
|
hooks` to re-install hooks after env changes. Each globbed pre-push lane is
|
|
scoped to the branch's merge-base diff against `origin/main` (`git diff
|
|
origin/main...HEAD`), matching CI's paths-filter — so a lane only fires when this
|
|
branch actually changed a file it covers, never because `origin/main` moved.
|
|
These lanes validate the checked-out HEAD; pushing a non-HEAD ref (explicit
|
|
refspec, `--all`) gets a non-fatal `push-head-scope` warning and relies on CI for
|
|
its path-scoped checks.
|
|
Before agents run Git or hooks, activate the repo's Hermit environment
|
|
(`. ./bin/activate-hermit`) so `./bin` leads `PATH` and the pinned toolchain
|
|
(flutter, dart, lefthook) wins over any Homebrew version; do not
|
|
rewrite hook commands to compensate for an unconfigured shell `PATH`. The
|
|
pre-push hook self-pins regardless: `bin/.lefthookrc` (sourced by the generated
|
|
`.git/hooks/*`) prepends the Hermit `bin/` to `PATH` and pins `LEFTHOOK_BIN`, so
|
|
lane subprocesses resolve the pinned flutter/dart/lefthook even when an
|
|
unactivated shell has Homebrew first. Activating Hermit remains recommended for
|
|
non-hook commands.
|
|
|
|
**Commit with `git commit -s`.** The required **DCO Check** fails any PR with a commit missing a `Signed-off-by` trailer, and `just hooks` installs a `commit-msg` hook that adds it to commits you create locally (`git rebase` and `git cherry-pick` still need `--signoff`) — if you build commit commands programmatically, include `-s` every time. To repair a branch that already has unsigned commits: `git rebase --signoff main`, then force-push.
|
|
|
|
Additional rules:
|
|
- No `unsafe` code
|
|
- Do not introduce new `unwrap()` or `expect()` in production paths — use `?` and proper error types
|
|
- New public API must have doc comments
|
|
|
|
---
|
|
|
|
## Key Patterns
|
|
|
|
**Nostr-first HTTP surface**: Buzz's primary API is NIP-29 over WebSocket. The relay also exposes a narrow HTTP surface: NIP-11/NIP-05 metadata, `POST /events`, `POST /query`, `POST /count`, workflow webhooks at `/hooks/{id}`, Blossom media, git smart HTTP, git policy hooks, and health probes. These HTTP paths all preserve the same host-derived community boundary.
|
|
|
|
**Prefer Nostr events over new HTTP endpoints**: For new feature work, model
|
|
the operation as a Nostr event (new kind in `buzz-core/src/kind.rs`, handler
|
|
in `buzz-relay`) rather than adding endpoint-specific JSON APIs. HTTP is
|
|
reserved for things that genuinely need an HTTP-only surface: media upload/download
|
|
(Blossom), webhooks, git smart HTTP, NIP-11/NIP-05 metadata, health checks,
|
|
and the generic Nostr bridge endpoints:
|
|
|
|
- `POST /events` — submit any signed event (same path the WebSocket uses).
|
|
- `POST /query` — Nostr REQ filters over HTTP. NIP-50 `search` filters
|
|
are routed to `buzz-search` (Postgres FTS) automatically.
|
|
- `POST /count` — Nostr COUNT filters over HTTP.
|
|
|
|
If you find yourself reaching for a new HTTP endpoint, first check whether
|
|
an event kind would do the job — it usually will, and you get realtime
|
|
fan-out, NIP-29 scoping, and the existing auth pipeline for free.
|
|
|
|
Reference https://github.com/nostr-protocol/nips
|
|
|
|
**Event kinds**: All event kind integers are defined in
|
|
`buzz-core/src/kind.rs`. New features get new kind integers — add them here
|
|
first, then implement handling in the relay.
|
|
|
|
**Channel scoping**: Channels use `h` tags (NIP-29 group tag), not `e` tags.
|
|
Filters and queries must scope to `h` tags when operating within a channel.
|
|
This applies to events *inside* a channel. Addressable events that describe a
|
|
channel carry its id in their `d` tag instead: kind:39000 (metadata),
|
|
kind:39001, kind:39002 (membership). `get_channels` resolves a user's channels
|
|
from the `d` tag of their kind:39002 events, not from `h`.
|
|
|
|
**Agent-facing operations go in `buzz-cli`**: New agent-facing features belong in `buzz-cli` — add a subcommand there first, then wire the REST/WebSocket call in `client.rs`. `buzz-dev-mcp` (shell + file tools for `buzz-agent`) is separate.
|
|
|
|
**Workflow conditions**: `buzz-workflow` uses
|
|
[evalexpr](https://docs.rs/evalexpr) for condition evaluation. Keep expressions
|
|
simple and testable.
|
|
|
|
**Thread counters**: `reply_count` and `descendant_count` are materialized on
|
|
thread root events. Any code that inserts replies must update these counters —
|
|
check existing reply handlers for the pattern.
|
|
|
|
---
|
|
|
|
## Agent CLI (`buzz-cli`)
|
|
|
|
`buzz` is the agent-first CLI. Auth env vars
|
|
(`BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG`) are auto-injected
|
|
by the ACP harness into managed agent subprocesses. In development, set
|
|
`BUZZ_PRIVATE_KEY` and `BUZZ_RELAY_URL` in your environment manually.
|
|
|
|
### Building the CLI
|
|
|
|
```bash
|
|
cargo build --release -p buzz-cli
|
|
```
|
|
|
|
Binary location: `./target/release/buzz`. Add `./target/release` to `PATH`
|
|
or invoke with the full path.
|
|
|
|
### Deep Links
|
|
|
|
`buzz://message?channel=<uuid>&id=<hex>` links reference a specific message
|
|
thread. Pass the link directly to the CLI:
|
|
|
|
```bash
|
|
buzz --format compact messages thread --link '<buzz://message?...>'
|
|
```
|
|
|
|
The selected message ID is authoritative: `messages thread` verifies its
|
|
channel and derives its containing root. An optional `thread` parameter is
|
|
accepted only when it matches that derived root. The explicit
|
|
`--channel <uuid> --event <hex>` form remains available.
|
|
|
|
All reads return sig-stripped JSON arrays; all writes return
|
|
`{event_id, accepted, message}`; creates add the entity ID. Exit codes:
|
|
0=ok, 1=input error, 2=network/relay, 3=auth, 4=other, 5=write conflict (NIP-33 LWW).
|
|
|
|
`--format compact` is a **global** flag — it goes before the subcommand:
|
|
`buzz --format compact channels list`, NOT `buzz channels list --format compact`.
|
|
|
|
See `crates/buzz-cli/TESTING.md` for the full live-testing runbook.
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
```bash
|
|
just test-unit # unit tests, no infrastructure needed
|
|
just test # full integration suite (requires Postgres + Redis)
|
|
```
|
|
|
|
E2E tests live in `crates/buzz-test-client/tests/`:
|
|
- `e2e_relay.rs` — WebSocket relay protocol
|
|
- `e2e_media.rs` — media upload/download (Blossom)
|
|
- `e2e_media_extended.rs` — extended media scenarios
|
|
- `e2e_nostr_interop.rs` — Nostr interop (NIP-50 search, NIP-10 threads, NIP-17 gift wraps)
|
|
|
|
Desktop E2E: `cd desktop && pnpm test:e2e:smoke` for mock-bridge smoke
|
|
coverage, or `pnpm test:e2e:integration` for relay-backed coverage. These
|
|
scripts build the required E2E bridge before running Playwright.
|
|
|
|
See [TESTING.md](TESTING.md) for the full multi-agent E2E guide.
|
|
|
|
### PR Screenshots
|
|
|
|
> **Do NOT use `buzz upload`, the relay media endpoint, or any third-party
|
|
> image host for PR screenshots.** Relay media URLs fail through GitHub's camo
|
|
> proxy. Always use `scripts/post-screenshots.sh` for PNGs before linking them
|
|
> from a PR body/comment. If you hand-edit PR markdown, run
|
|
> `scripts/check-pr-image-urls.sh <markdown-file>` first to catch relay URLs.
|
|
|
|
For mobile simulator screenshots, save the PNGs in a local directory and run
|
|
`./scripts/post-screenshots.sh <PR-number> <png-dir>` or use the third argument
|
|
with a markdown template containing `{{filename}}` placeholders.
|
|
|
|
The desktop app requires the E2E mock bridge to render — it cannot run in a plain
|
|
browser. Use `just desktop-screenshot` to capture screenshots (builds frontend,
|
|
starts preview server, runs Playwright automatically):
|
|
|
|
```bash
|
|
just desktop-screenshot --name home
|
|
just desktop-screenshot --name channel --route /channels/general
|
|
just desktop-screenshot --name search --click open-search
|
|
just desktop-screenshot --name settings --click open-settings
|
|
```
|
|
|
|
Options: `--name` (filename), `--route` (client route), `--active-channel`
|
|
(channel to view), `--click` (left-click data-testid or CSS selector),
|
|
`--right-click` (right-click for context menus), `--hover` (hover before
|
|
capture), `--clip` (crop region as `x,y,w,h` — e.g. `0,0,256,720` for sidebar
|
|
only), `--wait` (ms, default 2000), `--viewport` (WxH, default 1280x720),
|
|
`--outdir` (default `test-results/screenshots`), `--messages` (JSON file path).
|
|
Output is a PNG path on stdout.
|
|
|
|
Use `--messages` to inject content into a channel before capture. The JSON file
|
|
is an array of objects — `channelName` and `content` are required, all other
|
|
fields are optional and passed through to `__BUZZ_E2E_EMIT_MOCK_MESSAGE__`:
|
|
|
|
```json
|
|
[
|
|
{
|
|
"channelName": "random",
|
|
"content": "Hey @tyler check this out",
|
|
"pubkey": "953d...",
|
|
"kind": 40002,
|
|
"mentionPubkeys": ["deadbeef..."],
|
|
"extraTags": [["broadcast", "1"], ["e", "some-root-id"]],
|
|
"parentEventId": "abc123"
|
|
}
|
|
]
|
|
```
|
|
|
|
Without `--active-channel`, all messages must target the same channel and the
|
|
helper navigates to that channel (useful for showing message content). With
|
|
`--active-channel`, messages can target multiple channels while the "camera"
|
|
stays on the specified channel (useful for unread indicators, badges, etc.).
|
|
|
|
```bash
|
|
# Messages in the channel you're viewing (code blocks, formatting, etc.)
|
|
just desktop-screenshot --name code-blocks --messages /tmp/msgs.json
|
|
|
|
# Messages in OTHER channels to trigger unread state
|
|
just desktop-screenshot --name unread-dot \
|
|
--active-channel general --messages /tmp/badge-msgs.json
|
|
|
|
# Cropped to sidebar only (256px wide)
|
|
just desktop-screenshot --name sidebar-unread \
|
|
--active-channel general --messages /tmp/badge-msgs.json \
|
|
--clip 0,0,256,720
|
|
|
|
# Context menu on an unread channel (wider crop to include popup)
|
|
just desktop-screenshot --name ctx-mark-read \
|
|
--active-channel general --messages /tmp/badge-msgs.json \
|
|
--right-click channel-random --clip 0,200,320,300
|
|
|
|
# Hover state (e.g. copy button reveal)
|
|
just desktop-screenshot --name copy-hover \
|
|
--messages /tmp/code-msgs.json --hover "[data-testid='copy-code']"
|
|
```
|
|
|
|
Available mock channels: `general`, `random`, `design`, `sales`, `engineering`,
|
|
`agents`, `watercooler`, `announcements`, `alice-tyler`, `bob-tyler`.
|
|
|
|
`scripts/post-screenshots.sh` hosts PNGs on a per-developer branch
|
|
(`agent-screenshots/<github-username>`) and posts a PR comment with
|
|
commit-SHA-based image URLs (immutable — safe from later overwrites):
|
|
|
|
```bash
|
|
./scripts/post-screenshots.sh 803 test-results/screenshots
|
|
./scripts/post-screenshots.sh 803 test-results/screenshots body.md # custom body prepended
|
|
```
|
|
|
|
The body file supports `{{filename}}` placeholders (without `.png`) to inline
|
|
images at specific positions. Images not referenced by any placeholder are
|
|
appended at the end. Without placeholders, all images are appended (backward
|
|
compatible).
|
|
|
|
```markdown
|
|
### Unread dot
|
|
A message arrives in `#random`.
|
|
|
|
{{01-unread-dot}}
|
|
|
|
### Context menu
|
|
Right-click shows "Mark as read".
|
|
|
|
{{02-context-menu}}
|
|
```
|
|
|
|
Re-runs overwrite the image blobs on the `agent-screenshots/<username>`
|
|
branch, but the script **appends a new PR comment** — it does not edit or
|
|
delete the previous one. After reposting, delete the superseded comment so
|
|
only the current set remains, otherwise reviewers still see the stale images:
|
|
|
|
```bash
|
|
# List screenshot comments to find the stale one's id
|
|
gh pr view <pr> --repo block/buzz --json comments \
|
|
--jq '.comments[] | select(.body | test("pr-<pr>--")) | {id, url}'
|
|
gh api -X DELETE repos/block/buzz/issues/comments/<stale-comment-id>
|
|
```
|
|
|
|
Branch cleanup when fully done: `git push origin --delete agent-screenshots/<username>`.
|
|
|
|
### Writing E2E Screenshot Specs
|
|
|
|
When screenshots need seeded state, live messages, or UI interaction before
|
|
capture, write a Playwright spec instead of using `just desktop-screenshot`.
|
|
Add specs to `desktop/tests/e2e/` and register them in `playwright.config.ts`
|
|
(`smoke` project `testMatch`). Every test calls `installMockBridge(page)` for
|
|
mock Tauri IPC. Mock pubkey, channel names, and UUIDs live in `e2eBridge.ts`.
|
|
|
|
**Always build with `pnpm build:e2e`, never `pnpm run build`.** The mock Tauri
|
|
bridge is compiled in only for `--mode e2e` (see `installE2eBridgeIfConfigured`
|
|
in `desktop/src/main.tsx`). A plain `pnpm run build` strips it, so
|
|
`window.__TAURI_INTERNALS__` is never defined and **every** mock-mode spec fails
|
|
with `Cannot read properties of undefined (reading 'invoke')` — the app renders
|
|
"Community connection failed" instead of the UI under test. That looks exactly
|
|
like a product bug rather than a build mistake, so it burns real time.
|
|
`pnpm test:e2e:smoke` and `pnpm test:e2e:integration` run the right build for
|
|
you; prefer them over a manual build plus `playwright test`.
|
|
|
|
**Stale server:** `reuseExistingServer: true` means a previous build's server
|
|
serves old code. Kill port 4173 and re-run `pnpm build:e2e` before re-running
|
|
tests after code changes.
|
|
|
|
**`addInitScript` before bridge:** `page.addInitScript` (localStorage seeding)
|
|
must run BEFORE `installMockBridge(page)` — React reads state on mount, the
|
|
bridge triggers mount.
|
|
|
|
**Live messages:** Call `waitForMockLiveSubscription(page, channelName)` before
|
|
`__BUZZ_E2E_EMIT_MOCK_MESSAGE__` — messages are silently dropped without a
|
|
subscription. Navigate to the channel first (triggers subscription), then away
|
|
(so unread indicators appear), then inject.
|
|
|
|
**Animation timing:** Radix components animate in via CSS. `toBeVisible()`
|
|
resolves mid-animation — wait for completion before screenshotting. Use the
|
|
shared helper (mandatory before any `page.screenshot()` or
|
|
`locator.screenshot()` in specs):
|
|
|
|
```ts
|
|
import { waitForAnimations } from "../helpers/animations";
|
|
|
|
// ... after the element is visible but before capturing:
|
|
await waitForAnimations(page);
|
|
await page.screenshot({ path: "...", clip: { ... } });
|
|
```
|
|
|
|
The `just desktop-screenshot` path (`screenshot.mjs`) calls
|
|
`waitForAnimations` automatically — no manual step needed there.
|
|
|
|
For per-element waits (rare — prefer the page-level helper above):
|
|
|
|
```ts
|
|
await menuItem.evaluate((el) =>
|
|
Promise.all(
|
|
el.closest("[data-state]")?.getAnimations().map((a) => a.finished) ?? [],
|
|
),
|
|
);
|
|
```
|
|
|
|
**Cropping:** Use `clip` — full-window (1280x720) screenshots are unreadable
|
|
for sidebar features. Sidebar = 256px; context menus ~450px.
|
|
|
|
**Distinct states — verify before posting:** when one view renders many
|
|
elements at once (e.g. all team cards in a single grid), an unscoped
|
|
full-page `page.screenshot()` captures the *same* pixels for every shot, so
|
|
multiple PNGs come out byte-identical. Scope each shot to its subject with
|
|
`locator.screenshot()` (full-page `clip` only when an overlay like an open
|
|
dropdown must be included). Then gate on hash distinctness before posting:
|
|
|
|
```bash
|
|
shasum -a 256 test-results/<dir>/*.png # every hash must be unique
|
|
```
|
|
|
|
Identical hashes mean two shots captured the same state — fix the spec, do
|
|
not post. This catches the most common screenshot regression.
|
|
|
|
**`general` has pre-seeded messages** making `hasUnread` always true. Use
|
|
`engineering` for "muted + no unread" visual states.
|
|
|
|
**PR comments:** Use a body template (3rd arg to `post-screenshots.sh`) with
|
|
`{{filename}}` placeholders. Each screenshot gets a `###` heading + one-line
|
|
description. See [PR #803](https://github.com/block/buzz/pull/803).
|
|
|
|
---
|
|
|
|
## Common Gotchas
|
|
|
|
1. **Kind `39000` for channel metadata, not `41`** — kind 41 is NIP-01 (unused). All kinds defined in `buzz-core/src/kind.rs`.
|
|
2. **Relay queries must specify `kinds`** — omitting `kinds` triggers the p-gate (403). Always include explicit kind filters.
|
|
3. **`messages search` chooses its own supported kinds** — do not add a `--kinds` option; the current command does not accept one. This differs from raw relay filters, which still need explicit kinds.
|
|
4. **Worktrees: `cd` in the same command** — shell CWD doesn't persist between tool calls. Use `cd /path && cargo build` as one command.
|
|
5. **Desktop crate excluded from root workspace** — `cargo test` at repo root does NOT run desktop tests. Use `cargo test --manifest-path desktop/src-tauri/Cargo.toml` explicitly.
|
|
6. **React render perf: `React.memo` is all-or-nothing** — it only skips a re-render when *every* prop is reference-stable; one unstable prop (inline arrow/JSX, or a hook returning a fresh `{}`/`[]`/`Map` each render) defeats it. Two repeat offenders: (a) React Query results (`useMutation`/`useQuery`) are a **new object each render** — depend on the stable method (`mutation.mutateAsync`), not the object; (b) derived `Map`/array state that recomputes on a version bump — wrap in a content-equality ref cache (`shared/hooks/useStableReference.ts`). When chasing interaction lag, **measure with DevTools closed and no perf probes** (an open Web Inspector + per-keystroke `console.log` inflate the numbers), and isolate by removing one suspect at a time rather than guessing.
|
|
|
|
---
|
|
|
|
## Desktop App
|
|
|
|
The desktop app is Tauri 2 + React 19 + Vite + Tailwind CSS. Features are
|
|
organized under `desktop/src/features/`. Biome handles linting and formatting.
|
|
|
|
```bash
|
|
just desktop-dev # web-only dev server (faster iteration)
|
|
just dev # full Tauri app with native shell
|
|
```
|
|
|
|
### Text sizing & zoom (use rem, never px)
|
|
|
|
The desktop app implements Cmd +/- zoom by scaling the root `<html>`
|
|
font-size (`desktop/src/app/useWebviewZoomShortcuts.ts`) and pinning the native
|
|
webview zoom. **Only rem-based text scales with zoom — hardcoded px text sizes
|
|
are frozen.**
|
|
|
|
So for any readable text, reach for rem-based Tailwind tokens, never arbitrary
|
|
px:
|
|
|
|
- ✅ Stock rem tokens (`text-base`, `text-sm`, `text-xs`, …) for general
|
|
interface text. All of these derive from the virtual typography rem and
|
|
therefore follow the user's font-size preference and Cmd +/- zoom.
|
|
- ✅ Conversation text uses the named `text-message` token. Its
|
|
**Smaller / Default / Larger contract is 13 / 14 / 15px** before keyboard
|
|
zoom. Author names use the same conversation-size step; timestamps, system
|
|
rows, code, and reactions are deliberate neighboring steps on the shared
|
|
virtual-rem ramp. Keep those relationships tokenized rather than restoring a
|
|
fixed 16px chat baseline or hardcoding preference-specific values in
|
|
components.
|
|
- ✅ The `text-2xs` (0.6875rem / 11px at a 16px virtual rem) and `text-3xs`
|
|
(0.5rem / 8px at a 16px virtual rem) meta-text
|
|
tokens (in `desktop/tailwind.config.js` under `theme.extend.fontSize`) for the
|
|
sub-`text-xs` ramp — timestamps, count badges, tracking labels, tiny glyphs.
|
|
These replaced the dozens of arbitrary `text-[…rem]` literals that had drifted
|
|
apart pixel-by-pixel; keep meta text on these two tokens, not new arbitrary
|
|
values.
|
|
- ❌ `text-[15px]`, `text-[13px]`, CSS `font-size: 15px` — px froze against zoom
|
|
and caused the message-timeline regression (PR #891).
|
|
- ❌ Arbitrary rem literals too: `text-[0.6875rem]`, `text-[0.9rem]`, etc. They
|
|
zoom fine but re-fragment the scale we consolidated. Use a named token.
|
|
|
|
Prefer stock tokens — they're rem and zoom-safe. Only if a design genuinely
|
|
needs a size the stock/`2xs`/`3xs` scale can't express should you **add a
|
|
rem-based token** (in `desktop/tailwind.config.js` under `theme.extend.fontSize`)
|
|
rather than an arbitrary literal. A CI guard (`pnpm check:px-text`, in
|
|
`desktop/scripts/check-px-text.mjs`) scans all of `desktop/src` and fails on any
|
|
new arbitrary text-size literal — px **or** rem/em. Genuinely decorative glyphs
|
|
(e.g. the `text-[6rem]` avatar emoji) are allowlisted by `path:line` in that
|
|
script.
|
|
|
|
### Community Switching
|
|
|
|
The desktop app supports multiple communities (each backed by a different relay).
|
|
Switching communities does **not** reload the page — it uses React key-based
|
|
remounting. `<AppReady key={communityKey} />` in `App.tsx` forces the entire
|
|
community-scoped subtree to unmount and remount with fresh state.
|
|
|
|
**Module-level singletons must be explicitly reset.** React remounting only
|
|
clears React state (useState, useRef, context). Module-level variables (Maps,
|
|
class instances, cached promises) survive across remounts. Every community-scoped
|
|
singleton needs a reset function wired into `resetCommunityState()` in
|
|
`desktop/src/features/communities/useCommunityInit.ts`.
|
|
|
|
`resetCommunityState()` is the canonical inventory of community-scoped
|
|
singletons. **If you add a new module-level cache, Map, or class instance that
|
|
holds community-scoped data, add its reset there in the same change.** Failure
|
|
to do so causes data from the old community to leak into the new one. Avoid
|
|
duplicating its complete reset list here; the implementation is the source of
|
|
truth.
|
|
|
|
Key files:
|
|
- `desktop/src/app/App.tsx` — community key, init gate, remount boundary
|
|
- `desktop/src/features/communities/useCommunityInit.ts` — `resetCommunityState()`, applies config to Tauri backend
|
|
- `desktop/src/main.tsx` — provider hierarchy (`QueryClientProvider` > `App`)
|
|
|
|
---
|
|
|
|
## Mobile App (Flutter)
|
|
|
|
The mobile app lives in `mobile/` — a Flutter app using Riverpod + Hooks.
|
|
|
|
### Architecture
|
|
|
|
- **State management:** Riverpod + `flutter_hooks` (`HookConsumerWidget`)
|
|
- **Theme:** Catppuccin Latte (light) / Macchiato (dark) — matches desktop
|
|
- **Features:** Isolated under `lib/features/`, shared code in `lib/shared/`
|
|
- **Nostr models:** `lib/shared/relay/nostr_models.dart` — event kinds must
|
|
stay in sync with `desktop/src/shared/constants/kinds.ts`
|
|
|
|
### Rules
|
|
|
|
- **NEVER use `StatefulWidget`** — favor Riverpod for state and always use
|
|
`HookConsumerWidget` or `ConsumerWidget` with `flutter_hooks` for local state.
|
|
- Agents may build and run the Flutter app when it materially helps implement,
|
|
debug, or validate mobile changes. Prefer the smallest relevant command and
|
|
reuse an already-running simulator/emulator and the app's configured staging
|
|
or production community when that is sufficient. Do not start or rebuild
|
|
local relay services unless the task specifically requires relay-side or
|
|
isolated integration behavior.
|
|
- For iOS runtime validation, prefer `just mobile-dev`; it applies the
|
|
worktree-specific debug identity and runs `flutter run`. Direct `flutter run`
|
|
or IDE workflows are also allowed. Use `just mobile-build-android` only when
|
|
an APK build is relevant to the task.
|
|
- Do not rebuild, reinstall, or relaunch merely for ceremony. Preserve Flutter's
|
|
incremental build cache and use hot reload/restart where appropriate. Use
|
|
`flutter clean` only when stale build artifacts are a credible cause. Run
|
|
`flutter upgrade` only when the task explicitly requires a toolchain change.
|
|
- For user-visible or integration changes, exercise the affected workflow in a
|
|
real app when practical and report the device/simulator, connected community,
|
|
and workflow actually tested.
|
|
- **Do NOT use `print()`** — use `debugPrint()` or structured logging.
|
|
- Prefer `context.colors` and `context.textTheme` (via theme extensions)
|
|
over raw `Theme.of(context)` calls.
|
|
- **Keep widgets small and composable.** One public widget per file; push
|
|
private sub-widgets (`_Foo`) into sibling `part` files under a
|
|
`<page>/` folder rather than growing the page file. Hard ceiling:
|
|
**1000 lines/file**, enforced across Desktop, Web, and Mobile by the
|
|
repository-level `just file-size-check` gate (`just check`, CI, and every
|
|
pre-push). If the guard trips, **split the file — never bump the limit or add
|
|
an override to slip under it.**
|
|
- Feature modules must not import from other feature modules — only from
|
|
`shared/`.
|
|
- Use `Grid` tokens for spacing, `Radii` for border radius.
|
|
|
|
### Quality Checks
|
|
|
|
```bash
|
|
cd mobile
|
|
dart format --output=none --set-exit-if-changed .
|
|
flutter analyze
|
|
flutter test
|
|
```
|
|
|
|
Or from repo root: `just mobile-fmt` (auto-fix), `just mobile-check` (lint + fmt check), `just mobile-test` (tests).
|
|
|
|
To run the app locally with a worktree-specific debug identity and a
|
|
started or reused iOS Simulator:
|
|
|
|
```bash
|
|
just mobile-dev
|
|
```
|
|
|
|
This runs `flutter run` against the app's configured community; it does not
|
|
start Docker or local relay services.
|
|
|
|
When run from a git worktree, `just mobile-dev` (and `just
|
|
mobile-build-android`) give the debug build a per-worktree app identifier
|
|
(keyed to the worktree directory name) and a branch-labelled app name via
|
|
`scripts/mobile-worktree-overrides.sh`, so builds from multiple worktrees
|
|
install side by side. Release builds are unaffected. `just mobile-clean`
|
|
removes stale worktree-suffixed installs from simulators/emulators. See
|
|
[mobile/README.md](mobile/README.md) for direct Xcode / Android Studio
|
|
usage.
|
|
|
|
### Testing Conventions
|
|
|
|
- Prefer **widget tests** over unit tests for UI components — test the
|
|
whole widget tree, not individual methods.
|
|
- Use `ProviderScope(overrides: [...])` to inject fake notifiers.
|
|
- Fake notifiers should extend the real notifier class and override `build()`.
|
|
- Use the `WidgetHelpers.testable()` wrapper for simple widget tests or
|
|
build a custom `ProviderScope` + `MaterialApp` when you need specific overrides.
|
|
|
|
---
|
|
|
|
## See Also
|
|
|
|
- [CONTRIBUTING.md](CONTRIBUTING.md) — setup, code style, PR process, how to add event kinds / CLI subcommands / HTTP endpoints
|
|
- [TESTING.md](TESTING.md) — multi-agent E2E test guide
|
|
- [ARCHITECTURE.md](ARCHITECTURE.md) — system design and component relationships
|
|
- [RELEASING.md](RELEASING.md) — release process: `release-desktop`, `release-relay`, `scripts/mobile-release.sh`, candidate tags, internal builds
|
|
- [README.md](README.md) — project overview and quick start
|