{"owner":"block","repo":"buzz","hasSkills":true,"hasMcp":false,"mcpConfig":null,"found":["AGENTS.md"],"files":{"AGENTS.md":"# AGENTS.md — AI Agent Contributor Guide\n\nThis guide is for AI agents contributing to the Buzz codebase. It covers\nagent-specific context and conventions. For general contributor info (setup,\ncode style, PR process, architecture), see [CONTRIBUTING.md](CONTRIBUTING.md).\n\n---\n\n## Ecosystem\n\nBuzz 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:\n\n| Repo | Purpose |\n|------|---------|\n| [block/buzz](https://github.com/block/buzz) | OSS source — relay, desktop app, mobile app, CLI, agent harness |\n| [squareup/buzz-releases](https://github.com/squareup/buzz-releases) | Buildkite pipelines producing Block-signed macOS + iOS builds with `-block` desktop version suffix |\n| [squareup/sprout-oss](https://github.com/squareup/sprout-oss) | CI pipeline building the relay Docker image and pushing to internal ECR |\n| [squareup/block-coder-tf-stacks](https://github.com/squareup/block-coder-tf-stacks) | Terraform + ArgoCD deploying the relay to the staging Kubernetes cluster |\n| [squareup/sprout-backend-blox](https://github.com/squareup/sprout-backend-blox) | Desktop backend provider script connecting Blox workstation agents to the relay |\n\n```\nblock/buzz (source)\n  ├─► buzz-releases      (desktop + mobile builds → Artifactory, GitHub, Mobile Releases)\n  ├─► sprout-oss         (relay Docker image → ECR)\n  │     └─► block-coder-tf-stacks  (Helm chart → ArgoCD → staging cluster)\n  └─── sprout-backend-blox         (Blox compute provider for Desktop agent launch)\n```\n\nSee [RELEASING.md](RELEASING.md) for the desktop release flow and\n[CONTRIBUTING.md § Ecosystem](CONTRIBUTING.md#ecosystem) for contributor\naccess information.\n\n---\n\n## Repo Structure\n\n```\ncrates/\n  # Relay + core\n  buzz-relay          # WebSocket relay server — main entry point; also hosts git + huddle audio\n  buzz-core           # Core types, event verification, filter matching, kind registry\n  buzz-db             # Postgres event store and data access layer\n  buzz-auth           # Authentication and authorization\n  buzz-pubsub         # Redis pub/sub fan-out, presence, typing indicators\n  buzz-search         # Postgres FTS full-text search\n  buzz-audit          # Hash-chain audit log\n  buzz-media          # Blossom/S3 media storage\n  # Agent surface\n  buzz-acp            # ACP harness bridging Buzz events to AI agents\n  buzz-agent          # Minimal ACP-compliant agent (non-streaming, tool-calls-as-output)\n  buzz-dev-mcp        # Developer MCP server — shell + file-edit tools\n  buzz-persona        # Agent persona packs\n  buzz-workflow       # YAML-as-code workflow engine (evalexpr conditions)\n  # Clients + interop\n  buzz-pair-relay     # Ephemeral sidecar relay for NIP-AB device pairing\n  buzz-pairing-cli    # CLI for NIP-AB device pairing interop testing\n  git-sign-nostr      # Sign git objects with a Nostr key\n  git-credential-nostr # Git credential helper for Nostr-authed push/fetch\n  # Tooling + shared\n  buzz-cli            # Agent-first CLI\n  buzz-sdk            # Typed Nostr event builders\n  buzz-admin          # Operator CLI for relay administration\n  buzz-ws-client      # Shared NIP-42 WebSocket client (connect, auth, publish)\n  buzz-test-client    # Integration test client and E2E test suite\n  sprig               # All-in-one harness bundling ACP, agent, and dev MCP\n\ndesktop/              # Tauri 2 + React 19 desktop app\nweb/                  # Browser web client (repo browser, served by the relay)\nmobile/               # Flutter mobile app\nmigrations/           # SQL migrations (auto-applied on relay startup)\nscripts/              # Dev tooling\n.env.example          # Config template — copy to .env before running\n```\n\n---\n\n## Getting Started\n\n```bash\n. ./bin/activate-hermit   # activate hermit toolchain (Rust, Node, etc.)\ncp .env.example .env      # configure local environment\njust setup                # install deps, run migrations\njust relay                # start relay at ws://localhost:3000\njust ci                   # run before any PR\n```\n\nSee CONTRIBUTING.md for full setup details and dependency requirements.\n\n---\n\n## Quality Gates\n\nRun `just ci` before every PR — it runs `fmt` + `clippy` + desktop lint +\nunit tests + builds. Clippy passing does not mean fmt passes; run both.\n\nRun `just test` for integration tests if you touched `buzz-relay`,\n`buzz-db`, or `buzz-auth` — these require a running Postgres and Redis.\n\n**Pre-commit hooks** are installed automatically by `just setup` and auto-fix\nformatting via `stage_fixed`. Pre-commit runs fix variants in parallel (Rust\nfmt, Tauri Rust fmt, desktop biome fix, web biome fix, mobile dart format).\nAuto-fixable issues are fixed and re-staged; unfixable lint issues block the\ncommit. **Pre-push hooks** run clippy (workspace + Tauri), desktop TypeScript\ntypechecking (`tsc --noEmit`), and fast unit tests in parallel (Rust, desktop\nJS, Tauri Rust, mobile Flutter) — no overlap with pre-commit. Builds are\nCI-only. Run `just fix-all` to auto-fix all formatting in one shot. Run\n`just ci` for the full local gate. Run `just hooks` to\nre-install hooks after env changes. Before agents run Git or hooks, activate the\nrepo's Hermit environment (`. ./bin/activate-hermit`); do not rewrite hook\ncommands to compensate for an unconfigured shell `PATH`.\n\n**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.\n\nAdditional rules:\n- No `unsafe` code\n- Do not introduce new `unwrap()` or `expect()` in production paths — use `?` and proper error types\n- New public API must have doc comments\n\n---\n\n## Key Patterns\n\n**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.\n\n**Prefer Nostr events over new HTTP endpoints**: For new feature work, model\nthe operation as a Nostr event (new kind in `buzz-core/src/kind.rs`, handler\nin `buzz-relay`) rather than adding endpoint-specific JSON APIs. HTTP is\nreserved for things that genuinely need an HTTP-only surface: media upload/download\n(Blossom), webhooks, git smart HTTP, NIP-11/NIP-05 metadata, health checks,\nand the generic Nostr bridge endpoints:\n\n- `POST /events` — submit any signed event (same path the WebSocket uses).\n- `POST /query` — Nostr REQ filters over HTTP. NIP-50 `search` filters\n  are routed to `buzz-search` (Postgres FTS) automatically.\n- `POST /count` — Nostr COUNT filters over HTTP.\n\nIf you find yourself reaching for a new HTTP endpoint, first check whether\nan event kind would do the job — it usually will, and you get realtime\nfan-out, NIP-29 scoping, and the existing auth pipeline for free.\n\nReference https://github.com/nostr-protocol/nips\n\n**Event kinds**: All event kind integers are defined in\n`buzz-core/src/kind.rs`. New features get new kind integers — add them here\nfirst, then implement handling in the relay.\n\n**Channel scoping**: Channels use `h` tags (NIP-29 group tag), not `e` tags.\nFilters and queries must scope to `h` tags when operating within a channel.\nThis applies to events *inside* a channel. Addressable events that describe a\nchannel carry its id in their `d` tag instead: kind:39000 (metadata),\nkind:39001, kind:39002 (membership). `get_channels` resolves a user's channels\nfrom the `d` tag of their kind:39002 events, not from `h`.\n\n**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.\n\n**Workflow conditions**: `buzz-workflow` uses\n[evalexpr](https://docs.rs/evalexpr) for condition evaluation. Keep expressions\nsimple and testable.\n\n**Thread counters**: `reply_count` and `descendant_count` are materialized on\nthread root events. Any code that inserts replies must update these counters —\ncheck existing reply handlers for the pattern.\n\n---\n\n## Agent CLI (`buzz-cli`)\n\n`buzz` is the agent-first CLI. Auth env vars\n(`BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG`) are auto-injected\nby the ACP harness into managed agent subprocesses. In development, set\n`BUZZ_PRIVATE_KEY` and `BUZZ_RELAY_URL` in your environment manually.\n\n### Building the CLI\n\n```bash\ncargo build --release -p buzz-cli\n```\n\nBinary location: `./target/release/buzz`. Add `./target/release` to `PATH`\nor invoke with the full path.\n\n### Deep Links\n\n`buzz://message?channel=<uuid>&id=<hex>` links reference a specific message\nthread. To read the linked thread:\n\n```bash\nbuzz messages thread --channel <uuid> --event <hex> --format compact\n```\n\nExtract `channel` and `id` from the URL query parameters. The optional\n`thread` parameter (root event ID) can be ignored — `messages thread` resolves\nthe full thread from the event ID alone.\n\nAll reads return sig-stripped JSON arrays; all writes return\n`{event_id, accepted, message}`; creates add the entity ID. Exit codes:\n0=ok, 1=input error, 2=network/relay, 3=auth, 4=other, 5=write conflict (NIP-33 LWW).\n\n`--format compact` is a **global** flag — it goes before the subcommand:\n`buzz --format compact channels list`, NOT `buzz channels list --format compact`.\n\nSee `crates/buzz-cli/TESTING.md` for the full live-testing runbook.\n\n---\n\n## Testing\n\n```bash\njust test-unit    # unit tests, no infrastructure needed\njust test         # full integration suite (requires Postgres + Redis)\n```\n\nE2E tests live in `crates/buzz-test-client/tests/`:\n- `e2e_relay.rs` — WebSocket relay protocol\n- `e2e_media.rs` — media upload/download (Blossom)\n- `e2e_media_extended.rs` — extended media scenarios\n- `e2e_nostr_interop.rs` — Nostr interop (NIP-50 search, NIP-10 threads, NIP-17 gift wraps)\n\nDesktop E2E: `cd desktop && pnpm exec playwright test`\n\nSee [TESTING.md](TESTING.md) for the full multi-agent E2E guide.\n\n### PR Screenshots\n\n> **Do NOT use `buzz upload`, the relay media endpoint, or any third-party\n> image host for PR screenshots.** Relay media URLs fail through GitHub's camo\n> proxy. Always use `scripts/post-screenshots.sh` for PNGs before linking them\n> from a PR body/comment. If you hand-edit PR markdown, run\n> `scripts/check-pr-image-urls.sh <markdown-file>` first to catch relay URLs.\n\nFor mobile simulator screenshots, save the PNGs in a local directory and run\n`./scripts/post-screenshots.sh <PR-number> <png-dir>` or use the third argument\nwith a markdown template containing `{{filename}}` placeholders.\n\nThe desktop app requires the E2E mock bridge to render — it cannot run in a plain\nbrowser. Use `just desktop-screenshot` to capture screenshots (builds frontend,\nstarts preview server, runs Playwright automatically):\n\n```bash\njust desktop-screenshot --name home\njust desktop-screenshot --name channel --route /channels/general\njust desktop-screenshot --name search --click open-search\njust desktop-screenshot --name settings --click open-settings\n```\n\nOptions: `--name` (filename), `--route` (client route), `--active-channel`\n(channel to view), `--click` (left-click data-testid or CSS selector),\n`--right-click` (right-click for context menus), `--hover` (hover before\ncapture), `--clip` (crop region as `x,y,w,h` — e.g. `0,0,256,720` for sidebar\nonly), `--wait` (ms, default 2000), `--viewport` (WxH, default 1280x720),\n`--outdir` (default `test-results/screenshots`), `--messages` (JSON file path).\nOutput is a PNG path on stdout.\n\nUse `--messages` to inject content into a channel before capture. The JSON file\nis an array of objects — `channelName` and `content` are required, all other\nfields are optional and passed through to `__BUZZ_E2E_EMIT_MOCK_MESSAGE__`:\n\n```json\n[\n  {\n    \"channelName\": \"random\",\n    \"content\": \"Hey @tyler check this out\",\n    \"pubkey\": \"953d...\",\n    \"kind\": 40002,\n    \"mentionPubkeys\": [\"deadbeef...\"],\n    \"extraTags\": [[\"broadcast\", \"1\"], [\"e\", \"some-root-id\"]],\n    \"parentEventId\": \"abc123\"\n  }\n]\n```\n\nWithout `--active-channel`, all messages must target the same channel and the\nhelper navigates to that channel (useful for showing message content). With\n`--active-channel`, messages can target multiple channels while the \"camera\"\nstays on the specified channel (useful for unread indicators, badges, etc.).\n\n```bash\n# Messages in the channel you're viewing (code blocks, formatting, etc.)\njust desktop-screenshot --name code-blocks --messages /tmp/msgs.json\n\n# Messages in OTHER channels to trigger unread state\njust desktop-screenshot --name unread-dot \\\n  --active-channel general --messages /tmp/badge-msgs.json\n\n# Cropped to sidebar only (256px wide)\njust desktop-screenshot --name sidebar-unread \\\n  --active-channel general --messages /tmp/badge-msgs.json \\\n  --clip 0,0,256,720\n\n# Context menu on an unread channel (wider crop to include popup)\njust desktop-screenshot --name ctx-mark-read \\\n  --active-channel general --messages /tmp/badge-msgs.json \\\n  --right-click channel-random --clip 0,200,320,300\n\n# Hover state (e.g. copy button reveal)\njust desktop-screenshot --name copy-hover \\\n  --messages /tmp/code-msgs.json --hover \"[data-testid='copy-code']\"\n```\n\nAvailable mock channels: `general`, `random`, `design`, `sales`, `engineering`,\n`agents`, `watercooler`, `announcements`, `alice-tyler`, `bob-tyler`.\n\n`scripts/post-screenshots.sh` hosts PNGs on a per-developer branch\n(`agent-screenshots/<github-username>`) and posts a PR comment with\ncommit-SHA-based image URLs (immutable — safe from later overwrites):\n\n```bash\n./scripts/post-screenshots.sh 803 test-results/screenshots\n./scripts/post-screenshots.sh 803 test-results/screenshots body.md  # custom body prepended\n```\n\nThe body file supports `{{filename}}` placeholders (without `.png`) to inline\nimages at specific positions. Images not referenced by any placeholder are\nappended at the end. Without placeholders, all images are appended (backward\ncompatible).\n\n```markdown\n### Unread dot\nA message arrives in `#random`.\n\n{{01-unread-dot}}\n\n### Context menu\nRight-click shows \"Mark as read\".\n\n{{02-context-menu}}\n```\n\nRe-runs overwrite the image blobs on the `agent-screenshots/<username>`\nbranch, but the script **appends a new PR comment** — it does not edit or\ndelete the previous one. After reposting, delete the superseded comment so\nonly the current set remains, otherwise reviewers still see the stale images:\n\n```bash\n# List screenshot comments to find the stale one's id\ngh pr view <pr> --repo block/buzz --json comments \\\n  --jq '.comments[] | select(.body | test(\"pr-<pr>--\")) | {id, url}'\ngh api -X DELETE repos/block/buzz/issues/comments/<stale-comment-id>\n```\n\nBranch cleanup when fully done: `git push origin --delete agent-screenshots/<username>`.\n\n### Writing E2E Screenshot Specs\n\nWhen screenshots need seeded state, live messages, or UI interaction before\ncapture, write a Playwright spec instead of using `just desktop-screenshot`.\nAdd specs to `desktop/tests/e2e/` and register them in `playwright.config.ts`\n(`smoke` project `testMatch`). Every test calls `installMockBridge(page)` for\nmock Tauri IPC. Mock pubkey, channel names, and UUIDs live in `e2eBridge.ts`.\n\n**Always build with `pnpm build:e2e`, never `pnpm run build`.** The mock Tauri\nbridge is compiled in only for `--mode e2e` (see `installE2eBridgeIfConfigured`\nin `desktop/src/main.tsx`). A plain `pnpm run build` strips it, so\n`window.__TAURI_INTERNALS__` is never defined and **every** mock-mode spec fails\nwith `Cannot read properties of undefined (reading 'invoke')` — the app renders\n\"Community connection failed\" instead of the UI under test. That looks exactly\nlike a product bug rather than a build mistake, so it burns real time.\n`pnpm test:e2e:smoke` and `pnpm test:e2e:integration` run the right build for\nyou; prefer them over a manual build plus `playwright test`.\n\n**Stale server:** `reuseExistingServer: true` means a previous build's server\nserves old code. Kill port 4173 and re-run `pnpm build:e2e` before re-running\ntests after code changes.\n\n**`addInitScript` before bridge:** `page.addInitScript` (localStorage seeding)\nmust run BEFORE `installMockBridge(page)` — React reads state on mount, the\nbridge triggers mount.\n\n**Live messages:** Call `waitForMockLiveSubscription(page, channelName)` before\n`__BUZZ_E2E_EMIT_MOCK_MESSAGE__` — messages are silently dropped without a\nsubscription. Navigate to the channel first (triggers subscription), then away\n(so unread indicators appear), then inject.\n\n**Animation timing:** Radix components animate in via CSS. `toBeVisible()`\nresolves mid-animation — wait for completion before screenshotting. Use the\nshared helper (mandatory before any `page.screenshot()` or\n`locator.screenshot()` in specs):\n\n```ts\nimport { waitForAnimations } from \"../helpers/animations\";\n\n// ... after the element is visible but before capturing:\nawait waitForAnimations(page);\nawait page.screenshot({ path: \"...\", clip: { ... } });\n```\n\nThe `just desktop-screenshot` path (`screenshot.mjs`) calls\n`waitForAnimations` automatically — no manual step needed there.\n\nFor per-element waits (rare — prefer the page-level helper above):\n\n```ts\nawait menuItem.evaluate((el) =>\n  Promise.all(\n    el.closest(\"[data-state]\")?.getAnimations().map((a) => a.finished) ?? [],\n  ),\n);\n```\n\n**Cropping:** Use `clip` — full-window (1280x720) screenshots are unreadable\nfor sidebar features. Sidebar = 256px; context menus ~450px.\n\n**Distinct states — verify before posting:** when one view renders many\nelements at once (e.g. all team cards in a single grid), an unscoped\nfull-page `page.screenshot()` captures the *same* pixels for every shot, so\nmultiple PNGs come out byte-identical. Scope each shot to its subject with\n`locator.screenshot()` (full-page `clip` only when an overlay like an open\ndropdown must be included). Then gate on hash distinctness before posting:\n\n```bash\nshasum -a 256 test-results/<dir>/*.png   # every hash must be unique\n```\n\nIdentical hashes mean two shots captured the same state — fix the spec, do\nnot post. This catches the most common screenshot regression.\n\n**`general` has pre-seeded messages** making `hasUnread` always true. Use\n`engineering` for \"muted + no unread\" visual states.\n\n**PR comments:** Use a body template (3rd arg to `post-screenshots.sh`) with\n`{{filename}}` placeholders. Each screenshot gets a `###` heading + one-line\ndescription. See [PR #803](https://github.com/block/buzz/pull/803).\n\n---\n\n## Common Gotchas\n\n1. **Kind `39000` for channel metadata, not `41`** — kind 41 is NIP-01 (unused). All kinds defined in `buzz-core/src/kind.rs`.\n2. **Relay queries must specify `kinds`** — omitting `kinds` triggers the p-gate (403). Always include explicit kind filters.\n3. **`messages search` must include `--kinds`** — an open-ended search (no kinds) hits the relay p-gate and returns 403. Pass at least `--kinds 9,45001,45003` to scope the query.\n4. **Worktrees: `cd` in the same command** — shell CWD doesn't persist between tool calls. Use `cd /path && cargo build` as one command.\n5. **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.\n6. **Desktop Tauri fmt fails in worktrees and blocks commits** — the pre-commit hook runs `just desktop-tauri-fmt`, which fails in git worktrees because `cargo fmt` resolves workspace paths relative to the worktree root. Run `just desktop-tauri-fmt` from the main checkout to apply the fix, then re-stage and commit. CI is unaffected.\n7. **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.\n\n---\n\n## Desktop App\n\nThe desktop app is Tauri 2 + React 19 + Vite + Tailwind CSS. Features are\norganized under `desktop/src/features/`. Biome handles linting and formatting.\n\n```bash\njust desktop-dev   # web-only dev server (faster iteration)\njust dev           # full Tauri app with native shell\n```\n\n### Text sizing & zoom (use rem, never px)\n\nThe desktop app implements Cmd +/- zoom by scaling the root `<html>`\nfont-size (`desktop/src/app/useWebviewZoomShortcuts.ts`) and pinning the native\nwebview zoom. **Only rem-based text scales with zoom — hardcoded px text sizes\nare frozen.**\n\nSo for any readable text, reach for rem-based Tailwind tokens, never arbitrary\npx:\n\n- ✅ Stock rem tokens (`text-base`, `text-sm`, `text-xs`, …). **Chat body/author\n  text === `text-base` (16px) — chat is the app's base type size**, and the\n  surrounding timeline elements (timestamps, system rows, code, reactions) are\n  deliberate steps on that same stock ramp.\n- ✅ The `text-2xs` (0.6875rem / 11px) and `text-3xs` (0.5rem / 8px) meta-text\n  tokens (in `desktop/tailwind.config.js` under `theme.extend.fontSize`) for the\n  sub-`text-xs` ramp — timestamps, count badges, tracking labels, tiny glyphs.\n  These replaced the dozens of arbitrary `text-[…rem]` literals that had drifted\n  apart pixel-by-pixel; keep meta text on these two tokens, not new arbitrary\n  values.\n- ❌ `text-[15px]`, `text-[13px]`, CSS `font-size: 15px` — px froze against zoom\n  and caused the message-timeline regression (PR #891).\n- ❌ Arbitrary rem literals too: `text-[0.6875rem]`, `text-[0.9rem]`, etc. They\n  zoom fine but re-fragment the scale we consolidated. Use a named token.\n\nPrefer stock tokens — they're rem and zoom-safe. Only if a design genuinely\nneeds a size the stock/`2xs`/`3xs` scale can't express should you **add a\nrem-based token** (in `desktop/tailwind.config.js` under `theme.extend.fontSize`)\nrather than an arbitrary literal. A CI guard (`pnpm check:px-text`, in\n`desktop/scripts/check-px-text.mjs`) scans all of `desktop/src` and fails on any\nnew arbitrary text-size literal — px **or** rem/em. Genuinely decorative glyphs\n(e.g. the `text-[6rem]` avatar emoji) are allowlisted by `path:line` in that\nscript.\n\n### Community Switching\n\nThe desktop app supports multiple communities (each backed by a different relay).\nSwitching communities does **not** reload the page — it uses React key-based\nremounting. `<AppReady key={communityKey} />` in `App.tsx` forces the entire\ncommunity-scoped subtree to unmount and remount with fresh state.\n\n**Module-level singletons must be explicitly reset.** React remounting only\nclears React state (useState, useRef, context). Module-level variables (Maps,\nclass instances, cached promises) survive across remounts. Every community-scoped\nsingleton needs a reset function wired into `resetCommunityState()` in\n`desktop/src/features/communities/useCommunityInit.ts`.\n\nCurrent singletons that are reset on relay boundary changes (same-relay\nreconnects preserve pending avatar verification work):\n- `relayClient.disconnect()` — WebSocket teardown + promise rejection\n- `resetRateLimitGate()` — clears any active rate-limit window from the old relay\n- `clearAllDrafts()` — message draft cache\n- `resetAgentObserverStore()` — agent observer relay store\n- `resetActiveAgentTurnsStore()` — active agent turn timers\n- `resetAgentWorkingSignal()` — agent working indicator signal\n- `resetAvatarProfileSync()` — pending verified-avatar profile writes\n- `resetAvatarPresentations()` — avatar probes, previews, and Retry toasts\n- `resetSidebarRelayConnectionCardState()` — sidebar relay card dismiss state\n- `resetMediaCaches()` — proxy port and relay origin caches\n- `resetVideoPlayerState()` — video player singleton\n- `resetRenderScopedReactionHydration()` — reaction hydration cache\n- `clearSearchHitEventCache()` — search result event cache\n- `clearMarkdownNodeCache()` — markdown parse-node cache\n- `resetLinkPreviewTitleCache()` — link preview title cache (Buzz entity titles come from relay events)\n\n**If you add a new module-level cache, Map, or class instance that holds\ncommunity-scoped data, you must add its reset to `resetCommunityState()`.**\nFailure to do so causes data from the old community to leak into the new one.\n\nKey files:\n- `desktop/src/app/App.tsx` — community key, init gate, remount boundary\n- `desktop/src/features/communities/useCommunityInit.ts` — `resetCommunityState()`, applies config to Tauri backend\n- `desktop/src/main.tsx` — provider hierarchy (`QueryClientProvider` > `App`)\n\n---\n\n## Mobile App (Flutter)\n\nThe mobile app lives in `mobile/` — a Flutter app using Riverpod + Hooks.\n\n### Architecture\n\n- **State management:** Riverpod + `flutter_hooks` (`HookConsumerWidget`)\n- **Theme:** Catppuccin Latte (light) / Macchiato (dark) — matches desktop\n- **Features:** Isolated under `lib/features/`, shared code in `lib/shared/`\n- **Nostr models:** `lib/shared/relay/nostr_models.dart` — event kinds must\n  stay in sync with `desktop/src/shared/constants/kinds.ts`\n\n### Rules\n\n- **NEVER use `StatefulWidget`** — favor Riverpod for state and always use\n  `HookConsumerWidget` or `ConsumerWidget` with `flutter_hooks` for local state.\n- **NEVER run `flutter run`, `flutter build`, `flutter clean`, or\n  `flutter upgrade`** — only `flutter test`, `flutter analyze`, and\n  `dart format` are safe for agents to run.\n- **Do NOT use `print()`** — use `debugPrint()` or structured logging.\n- Prefer `context.colors` and `context.textTheme` (via theme extensions)\n  over raw `Theme.of(context)` calls.\n- **Keep widgets small and composable.** One public widget per file; push\n  private sub-widgets (`_Foo`) into sibling `part` files under a\n  `<page>/` folder rather than growing the page file. Hard ceiling:\n  **1000 lines/file**, enforced by `mobile/scripts/check-file-sizes.mjs` via\n  `just mobile-check` (runs in `just check` + pre-push, mirroring desktop/web).\n  If the guard trips, **split the file — never bump the limit or add an\n  override to slip under it.**\n- Feature modules must not import from other feature modules — only from\n  `shared/`.\n- Use `Grid` tokens for spacing, `Radii` for border radius.\n\n### Quality Checks\n\n```bash\ncd mobile\ndart format --output=none --set-exit-if-changed .\nflutter analyze\nflutter test\n```\n\nOr from repo root: `just mobile-fmt` (auto-fix), `just mobile-check` (lint + fmt check), `just mobile-test` (tests).\n\nTo run the app locally (starts Docker, relay, iOS simulator automatically):\n\n```bash\njust mobile-dev\n```\n\nWhen run from a git worktree, `just mobile-dev` (and `just\nmobile-build-android`) give the debug build a per-worktree app identifier\n(keyed to the worktree directory name) and a branch-labelled app name via\n`scripts/mobile-worktree-overrides.sh`, so builds from multiple worktrees\ninstall side by side. Release builds are unaffected. `just mobile-clean`\nremoves stale worktree-suffixed installs from simulators/emulators. See\n[mobile/README.md](mobile/README.md) for direct Xcode / Android Studio\nusage.\n\n### Testing Conventions\n\n- Prefer **widget tests** over unit tests for UI components — test the\n  whole widget tree, not individual methods.\n- Use `ProviderScope(overrides: [...])` to inject fake notifiers.\n- Fake notifiers should extend the real notifier class and override `build()`.\n- Use the `WidgetHelpers.testable()` wrapper for simple widget tests or\n  build a custom `ProviderScope` + `MaterialApp` when you need specific overrides.\n\n---\n\n## See Also\n\n- [CONTRIBUTING.md](CONTRIBUTING.md) — setup, code style, PR process, how to add event kinds / CLI subcommands / HTTP endpoints\n- [TESTING.md](TESTING.md) — multi-agent E2E test guide\n- [ARCHITECTURE.md](ARCHITECTURE.md) — system design and component relationships\n- [RELEASING.md](RELEASING.md) — release process: `release-desktop`, `release-relay`, `scripts/mobile-release.sh`, candidate tags, internal builds\n- [README.md](README.md) — project overview and quick start\n"}}