mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
## Why The Inbox mixed overlapping feed categories with personal work queues, so **All** was not actually comprehensive and several filters did not make it clear why an item appeared. Threads and DMs could produce one row per event instead of one row per conversation, drafts were hidden until selected, and reminders appeared through multiple competing presentations. This refactor makes the **Inbox** a focused, conversation-oriented place to catch up on work relevant to you. It is intentionally not a mirror of every unread event in every channel. ## What changed - Keep the destination named **Inbox** and use the standard Lucide bell icon. - Refocus **All** on DMs, mentions, thread replies, needs-action items, replies from agents the user owns or controls, due reminders, and active drafts. - Exclude generic top-level channel traffic and updates from agents the user does not own or control. - Group each thread or DM into one row, sorted by latest activity. - Resume an unread conversation at its oldest unread message while opening the full thread or DM in the detail pane. - Reuse the existing **New** divider at the unread boundary. - Make the detail title a direct link to the canonical conversation. - Give Reminders and Drafts the same list/detail interaction and location metadata as conversation rows. - Separate Reminders and Drafts from message filters with a subtle divider, without adding another labeled section. - Put reminder and draft counts beside their corresponding filter labels instead of on the generic filter button. - Preserve the selected conversation when switching filters if it remains valid; otherwise select a valid replacement without flashing stale detail. - Use filter-specific empty states and rename the options toggle to **Show unread only**. - Ship the focused behavior directly. The earlier experiment gate, Custom view, and default-view controls have been removed from this PR to keep the first pass focused. ## Filter model | Filter | What appears | | --- | --- | | **All** | One row per personally relevant conversation, plus due reminders and active drafts. Includes DMs, mentions, thread replies, explicit needs-action items, and replies from agents the current user owns or controls. Excludes generic top-level channel traffic, other agents' updates, and reminders that are not due yet. | | **Mentions** | Conversations containing a direct mention. Each conversation appears once and opens with full context. | | **Threads** | Conventional threaded replies, grouped to one row per thread. Broadcast replies are not treated as conventional thread replies. | | **Needs action** | Feed items explicitly classified as requiring action. | | **Agents** | Conversations whose representative response was authored by an agent the current user owns or controls, including top-level DM responses. If a human replies afterward, the conversation leaves this filter until an owned agent responds again. | | **Reminders** | All pending reminders, including upcoming reminders that stay out of **All** until they are due. | | **Drafts** | Active drafts, ordered by their last real edit time. | ## Grouping, ordering, and state - A thread or DM creates one Inbox row rather than one row per event. - An unread conversation resumes at its oldest unread message so intervening context is not skipped. - Conversation rows still sort by their latest activity. - The detail pane opens the full available conversation and shows the shared **New** divider before the first unread message. - Upcoming reminders appear only in **Reminders**. - When a reminder becomes due, it enters **All** at its trigger time. If its source conversation is already represented, the reminder state merges into that row instead of creating a duplicate; otherwise it appears as a standalone reminder row. - A due reminder can enrich a row in another relative filter when that conversation already qualifies for the filter. Reminder lifecycle remains separate from message read state. - Drafts appear in **All** by their last real edit time. Opening an unchanged draft does not move it to the top. - Reminder and draft rows show their location as `In #channel` or `In DM with <name>`. - **Show unread only** hides reminder and draft work queues because they do not share message unread semantics. ## Removed or narrowed - **Remove the old Activity filter.** It overlapped with All while still omitting items All now includes. - **Narrow Agents.** It no longer gathers every agent participating in a shared thread or subsequent human follow-ups. - **Remove duplicate reminder presentations.** The aggregate pending-reminders jump and duplicate generic feed rows are replaced by one list/detail model. - **Remove Custom and default-view settings from this pass.** They added considerable state and UI before the core model had been validated. - **Do not add section labels for Reminders and Drafts.** A divider communicates the distinction without creating another hierarchy in the menu. ## Risk assessment Medium implementation risk because this changes composition, grouping, ordering, read behavior, and personal queues in a primary desktop view. The implementation is scoped to the desktop UI and its local feed projection; it does not change relay schemas or public APIs. ## Testing - Desktop formatting, lint, file-size, text-size, and TypeScript checks passed. - Desktop unit suite: **3,663 passed, 0 failed**. - Desktop E2E production build passed. - Playwright smoke coverage across every spec touching this surface (`channels`, `smoke`, `profile`, `project-inbox`, `community-rail`, `integration`, `drafts-screenshots`): **118 passed, 0 failed**. - Full Playwright smoke project: **732 passed, 1 skipped**. Three local failures were investigated and cleared — `community-rail` keyboard reorder passed on re-run (flaky), while `relay-reconnect:97` and `video-attachment:223` are untouched by this commit (the only change to shared `tests/helpers/bridge.ts` is a comment) and pass in CI. - Unit coverage includes focused All matching, owned-agent filtering, conversation grouping, oldest-unread selection, selection stability, chronological reminder/draft composition, trigger-time reminder ordering, and duplicate reminder suppression. ## Update: July 27, 2026 The naming decision is settled: the surface stays **Inbox**. An earlier pass in this branch had renamed it to **Activity**; that rename has been reverted in `9c00d2d6e`, which is naming-only and changes no behavior. The revert covers file names, component/hook/type/constant identifiers, the sidebar label and tooltip, the `Inbox options` and `Filter inbox:` aria-labels, and the corresponding test names, test ids, and fixture ids. Three things were deliberately left as `activity`: - **The feed API contract** — the `activity` / `agent_activity` categories, the `feed.activity` and `feed.agentActivity` keys, and the `types=` query parameter. These are the server's names, not the surface's. - **Plain-noun usage** — empty states such as "No activity yet", plus `latestActivityAt` and `PROJECT_ACTIVITY_KINDS`. - **Pre-existing agent, project, and profile activity code**, which refers to a different concept entirely. The earlier experiment-gate approach has also been dropped, so `tests/helpers/bridge.ts` no longer claims that an Activity preview feature exists — `preview-features.json` has no such entry and the seed helper enables every desktop feature. Generated with Codex --------- Signed-off-by: Clay Delk <clay.delk@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
595 lines
28 KiB
Markdown
595 lines
28 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).
|
|
|
|
---
|
|
|
|
## 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/sprout-releases](https://github.com/squareup/sprout-releases) | Buildkite pipeline producing Block-signed macOS + iOS builds with `-block` 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)
|
|
├─► sprout-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 `fmt` + `clippy` + desktop lint +
|
|
unit tests + 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 clippy (workspace + Tauri) 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. Before agents run Git or hooks, activate the
|
|
repo's Hermit environment (`. ./bin/activate-hermit`); do not rewrite hook
|
|
commands to compensate for an unconfigured shell `PATH`.
|
|
|
|
**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.
|
|
|
|
**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. To read the linked thread:
|
|
|
|
```bash
|
|
buzz messages thread --channel <uuid> --event <hex> --format compact
|
|
```
|
|
|
|
Extract `channel` and `id` from the URL query parameters. The optional
|
|
`thread` parameter (root event ID) can be ignored — `messages thread` resolves
|
|
the full thread from the event ID alone.
|
|
|
|
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 exec playwright test`
|
|
|
|
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` 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.
|
|
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. **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.
|
|
7. **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`, …). **Chat body/author
|
|
text === `text-base` (16px) — chat is the app's base type size**, and the
|
|
surrounding timeline elements (timestamps, system rows, code, reactions) are
|
|
deliberate steps on that same stock ramp.
|
|
- ✅ The `text-2xs` (0.6875rem / 11px) and `text-3xs` (0.5rem / 8px) 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`.
|
|
|
|
Current singletons that are reset on relay boundary changes (same-relay
|
|
reconnects preserve pending avatar verification work):
|
|
- `relayClient.disconnect()` — WebSocket teardown + promise rejection
|
|
- `resetRateLimitGate()` — clears any active rate-limit window from the old relay
|
|
- `clearAllDrafts()` — message draft cache
|
|
- `resetAgentObserverStore()` — agent observer relay store
|
|
- `resetActiveAgentTurnsStore()` — active agent turn timers
|
|
- `resetAgentWorkingSignal()` — agent working indicator signal
|
|
- `resetAvatarProfileSync()` — pending verified-avatar profile writes
|
|
- `resetAvatarPresentations()` — avatar probes, previews, and Retry toasts
|
|
- `resetSidebarRelayConnectionCardState()` — sidebar relay card dismiss state
|
|
- `resetMediaCaches()` — proxy port and relay origin caches
|
|
- `resetVideoPlayerState()` — video player singleton
|
|
- `resetRenderScopedReactionHydration()` — reaction hydration cache
|
|
- `clearSearchHitEventCache()` — search result event cache
|
|
- `clearMarkdownNodeCache()` — markdown parse-node cache
|
|
|
|
**If you add a new module-level cache, Map, or class instance that holds
|
|
community-scoped data, you must add its reset to `resetCommunityState()`.**
|
|
Failure to do so causes data from the old community to leak into the new one.
|
|
|
|
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.
|
|
- **NEVER run `flutter run`, `flutter build`, `flutter clean`, or
|
|
`flutter upgrade`** — only `flutter test`, `flutter analyze`, and
|
|
`dart format` are safe for agents to run.
|
|
- **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 by `mobile/scripts/check-file-sizes.mjs` via
|
|
`just mobile-check` (runs in `just check` + pre-push, mirroring desktop/web).
|
|
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 (starts Docker, relay, iOS simulator automatically):
|
|
|
|
```bash
|
|
just mobile-dev
|
|
```
|
|
|
|
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
|