This commit is contained in:
2026-09-13 13:36:27 +07:00
parent f8277e4c2e
commit b3991810b6
77 changed files with 96 additions and 5189 deletions
File diff suppressed because it is too large Load Diff
-973
View File
@@ -1,973 +0,0 @@
# Inbox (home screen) implementation plan
Ported from GitWorkshop's home screen, the `Dashboard` rendered at route `/` for a logged-in user.
> **Correction to the first draft.** The first draft assumed the inbox was the `/notifications`
> page. It is not. GitWorkshop's `Index` route (`src/pages/Index.tsx`) renders `<Dashboard />` when
> an account is active, and that home screen is the inbox.
> **Status.** Phases 0-4 are implemented and green on `feat/inbox`, then the screen was redesigned to
> group **threads by repository** and to merge notifications with own activity into one row per thread
> (see the repository-grouping and thread-merge notes in §7).
> `cargo test -p signed_core` (69), `cargo test -p signed_state` (24),
> `cargo test -p workspace` (7), `cargo test -p dock` (1), `cargo clippy -p workspace --all-targets`
> clean, `cargo check --workspace --all-targets` succeeds.
> Phase 5 is not started. This document reflects the implementation as it stands: the Phase 1
> refactors, the §4.3 split of the inbox into a thin global `Inbox` and a panel-owned derivation, the
> Phase 4 click-through, and the repository-grouped thread list. The Phase 3 bottom-dock sub-views were
> removed before the redesign; their implementation notes in §7 are historical.
## 1. What the GitWorkshop home screen is
`Index.tsx`:
```tsx
if (account) return <Dashboard />;
return <LandingPage />;
```
`Dashboard.tsx` layout:
- Desktop: two columns.
- **Left column**: `GreetingHeader`, `NotificationsPanel`, `RecentActivitySection`.
- **Right column**: `MyRepositoriesPanel`, `AccessiblePrivateRepositoriesPanel`,
`FollowedReposPanel`.
- Mobile: a single column in a different order.
The panel that gives the screen its inbox identity is `NotificationsPanel`:
- heading **Notifications** with a bell icon and an unread count badge,
- a **Mark all read** action and a **View all** link to `/notifications`,
- a compact list of the first 5 **non-archived** notification items,
- the empty state reads **"Your inbox is empty"** (with an `Inbox` icon).
So in GitWorkshop's vocabulary, "inbox" is the non-archived activity directed at you, surfaced
inline on the home screen. The home screen also shows your own recent activity and your repositories.
Data hooks:
| Section | Hook | What it loads |
|---|---|---|
| Notifications (inbox) | `useNotifications()` | Notification model: grouped thread activity directed at you, read/archived state |
| Continue where you left off | `useUserActivity(pubkey)` | Git activity authored by you: kinds 1621/1617/1618/1111 (git `K`)/1624/1630-1633, newest first, limit 50 |
| My repositories | `useUserRepositories(pubkey)` | Kind 30617 announcements authored by you |
| Followed repositories | `useUserFollowedRepos(pubkey)` | Repos you follow |
| Accessible private repositories | `useAccessiblePrivateRepositories()` | Private repos from CI/services |
## 2. Scope for Signed
| Priority | Section | Notes |
|---|---|---|
| **P0** | Inbox panel | Activity directed at you and your own activity, **grouped by repository**; unread badge; mark all read; all groups shown |
| **P1** | Click-through | Open the issue/PR detail panel at the relevant thread root |
| **P2 (defer)** | Standalone notifications page, NIP-65 relay discovery, pagination | Web-app concerns |
| **Out of scope** | Greeting header, my repositories, followed repositories, private repositories, pinned repositories, Unread/Archived sub-views | Not needed in Signed |
Notes:
- There is **no greeting header**. The screen starts with the inbox panel.
- There is **no My repositories column**. The sidebar already lists the signed-in user's repositories, so the inbox is a single column.
- The Unread/Archived sub-view panels were removed: the panel is a single repository-grouped list instead.
## 3. The Signed screen
`InboxView` is a center panel, opened by the sidebar's existing **Inbox** nav item. It is one bordered
card holding a single virtual list. Every row is either a **repository header** or one of that
repository's **threads**, newest first. A thread merges the notifications directed at the user with
the user's own events in the same root, and shows the root's title plus up to five of its most recent
events:
The sections are **all of the user's own repositories**, seeded from `RepoListStore`, plus any other
repository that has threads. Owned repositories with nothing to show render an
empty state ("No activity yet.") under their header, and sort after the ones with activity (newest
announcement first). Threads with no repository address fall into a single "Other repository" section.
```
+-------------------------------------------------------------------------+
| Inbox (3 unread) [Mark all read] |
|-------------------------------------------------------------------------|
| [repo] you/repo-a (2) |
| [icon] Add retry logic (unread dot) |
| [avatar] You opened an issue 3d |
| [avatar] alice commented 2d |
| [icon] Fix flaky test |
| [avatar] You opened a PR 1h |
|-------------------------------------------------------------------------|
| [repo] you/repo-b |
| No activity yet. |
|-------------------------------------------------------------------------|
| [repo] you/repo-c |
| No activity yet. |
+-------------------------------------------------------------------------+
```
The sections are the repositories that actually have threads, ordered by their
newest row. A repository the user owns but that has no items is not shown. Threads with no repository
address fall into a single "Other repository" section.
## 4. Data layer
### 4.1 `signed_core`: pure logic
**`filters.rs`** (extend, next to `activity`/`comments_for`):
```rust
/// Kinds that notify a user when they tag them directly.
pub const NOTIFICATION_KINDS: [Kind; 9] = [
Kind::GitIssue,
Kind::GitPullRequest,
Kind::GitPatch,
Kind::GitPullRequestUpdate,
COVER_NOTE_KIND,
Kind::GitStatusOpen,
Kind::GitStatusApplied,
Kind::GitStatusClosed,
Kind::GitStatusDraft,
];
/// Comments on our issues/PRs/patches.
pub fn notification_comments(me: PublicKey) -> Filter {
Filter::new()
.kind(Kind::Comment)
.custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()])
.custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"])
}
/// Activity directed at us: comments on our roots, and git events tagging us.
pub fn notifications(me: PublicKey) -> Vec<Filter> {
vec![
notification_comments(me),
Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me),
]
}
/// Git activity authored by `me`, for "Continue where you left off".
pub fn authored_activity(me: PublicKey) -> Filter {
Filter::new()
.kinds([ACTIVITY_KINDS.as_slice(), &[COVER_NOTE_KIND]].concat())
.author(me)
}
```
`ACTIVITY_KINDS` already exists in this file. All builders use existing SDK APIs
(`Filter::kind/kinds/pubkey/custom_tags`, `SingleLetterTag::{UPPERCASE_P, UPPERCASE_K}`).
Comments authored by `me` are not all git comments, so the activity query needs a post-filter:
keep kind 1111 only when its uppercase `K` tag is a git root kind (1621/1617/1618/30617), matching
gitworkshop's `isGitComment`.
**`inbox.rs`** (new file):
```rust
pub struct InboxItem {
pub root: EventId,
/// The root event itself, when known locally; drives the row title.
pub root_event: Option<Event>,
pub root_kind: Option<Kind>,
pub address: Option<RepoAddr>,
/// Notification events directed at the user, newest first.
pub events: Vec<Event>,
/// The user's own events in the same thread, newest first.
pub own_events: Vec<Event>,
/// Unread event ids, oldest first.
pub unread_ids: Vec<EventId>,
pub archived: bool,
}
impl InboxItem {
/// Title of the thread root; falls back to the newest event it has.
pub fn title(&self) -> String;
/// Kind of the thread root; falls back to the newest event it has.
pub fn kind(&self) -> Option<Kind>;
pub fn latest_activity(&self) -> Timestamp;
/// Up to `limit` most recent events of the thread, oldest first.
pub fn timeline(&self, limit: usize) -> Vec<Event>;
pub fn is_unread(&self) -> bool;
pub fn apply_state(&mut self, state: &InboxReadState);
}
/// The thread root of a notification event, or `None` if it isn't git-related.
pub fn notification_root(
event: &Event,
lookup: &impl Fn(EventId) -> Option<Event>,
) -> Option<EventId>;
/// Group the notifications directed at the user together with the user's own
/// events into one item per thread, newest activity first.
pub fn group(
events: impl IntoIterator<Item = Event>,
own: impl IntoIterator<Item = Event>,
me: PublicKey,
state: &InboxReadState,
lookup: &impl Fn(EventId) -> Option<Event>,
) -> Vec<InboxItem>;
```
Root resolution, ported from `getNotificationRootId`:
- issue (1621) / PR (1618): itself
- patch (1617): its `e` parent patch, else itself
- NIP-22 comment (1111): uppercase `E` root pointer (SDK `nip22::extract_root`)
- PR update (1619): uppercase `E`
- statuses (1630-1633) / cover note (1624): NIP-10 root `e`
- notification events authored by `me` are dropped; the user's own events are kept in
`own_events` instead, never in `events`
- `unread_ids` and `archived` are derived from `events` only, so the user's own activity is never
unread and a thread with only own events is never archived
Read/archive state, the compact high-water-mark model:
```rust
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct InboxReadState {
#[serde(default)] pub read_before: Timestamp,
#[serde(default)] pub read_ids: HashSet<EventId>,
#[serde(default)] pub archived_before: Timestamp,
#[serde(default)] pub archived_ids: HashSet<EventId>,
}
impl InboxReadState {
pub fn is_read(&self, event: &Event) -> bool;
pub fn is_archived(&self, event: &Event) -> bool;
pub fn mark_read(&mut self, event: &Event);
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey);
/// Move the cutoff to `min(oldest unread - 1, now - 3 days)` and prune ids.
pub fn advance_read(&mut self, all: &[Event], me: PublicKey);
pub fn advance_archived(&mut self, all: &[Event], me: PublicKey);
}
```
`activity_subject` in `model.rs` already gives an issue/PR title from the `subject` tag or first
line; reuse it for the home screen rows.
### 4.2 Persistence: NIP-78 in the local database, never published
Read state is a normal NIP-78 (kind `30078`, `Kind::ApplicationSpecificData`) addressable event
**written to LMDB only**. It is never broadcast to a relay, so the read state stays on this device.
It is signed with a **random keypair**, never the user's signer. The event is local application
storage, so its author carries no identity; this avoids a signing round-trip and does not depend on
the signer type. The `d` tag identifies the owning user, so state does not leak across identities
when the signed-in key changes.
```rust
/// d tag identifying the inbox read/archive state event of `me`.
fn inbox_state_d_tag(me: PublicKey) -> String {
format!("signed-inbox-state:{}", me.to_hex())
}
/// Newest stored read state for `me`.
async fn load_state(client: &Client, me: PublicKey) -> Result<Option<InboxReadState>, Error> {
// No author filter: the signing key is random per save.
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(inbox_state_d_tag(me));
let events = client.database().query(filter).await?;
let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
return Ok(None);
};
match serde_json::from_str(&event.content) {
Ok(state) => Ok(Some(state)),
Err(error) => {
log::warn!("ignoring unreadable inbox state {}: {error}", event.id);
Ok(None)
}
}
}
/// Sign with a fresh random key and store locally.
async fn save_state(client: &Client, me: PublicKey, state: &InboxReadState) -> Result<(), Error> {
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
.tags([Tag::identifier(inbox_state_d_tag(me))])
.finalize(&Keys::generate())?; // synchronous: random key, no user signer
// Local-only: no `send_event`, no broadcast. The event lives in LMDB.
client.database().save_event(&event).await?;
Ok(())
}
```
A fresh random key is generated on every save, so each save writes a new event rather than
replacing the previous one. LMDB only auto-replaces an addressable event when the incoming event
has the **same pubkey**, so old copies accumulate. Nothing prunes them; `load_state` reads the
newest by `created_at`, so the behavior is correct. This is a deliberate trade for not caching a
key in the store (see §4.3). An earlier implementation deleted the previous event by tracking its
id across saves; that was removed as more derived state than it was worth.
`NostrDatabase::{save_event, query}` and `Client::database()` are existing SDK APIs.
### 4.3 Data layer: a thin global `Inbox`, a panel-owned derivation
The inbox is split in two, because the expensive derivation is only needed while the home screen is
open.
**`Inbox`** is a child `Entity<Inbox>` owned by `Backend` (`inbox: Entity<Inbox>`) and is
deliberately thin: it owns only the read/archive state that must outlive the panel and the NIP-78
load/save.
```rust
// backend.rs
pub struct Backend {
...
inbox: Entity<Inbox>,
}
// inbox.rs
#[derive(Default)]
pub struct Inbox {
state: InboxReadState,
loaded: bool,
}
impl Inbox {
pub fn state(&self) -> &InboxReadState;
pub fn is_loaded(&self) -> bool;
pub fn mark_read(&mut self, group: &[Event], all: &[Event], me: PublicKey, cx);
pub fn mark_archived(&mut self, group: &[Event], all: &[Event], me: PublicKey, cx);
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx);
pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx);
pub(crate) fn reset(&mut self, cx);
}
// inbox.rs (signed_state)
/// One item per thread, notifications and own activity merged.
pub async fn query_inbox(
client: &Client,
me: PublicKey,
state: &InboxReadState,
) -> Result<(Vec<InboxItem>, usize), Error>;
```
**The panel owns the derivation.** `InboxView` itself holds the derived lists, the copy of the read
state they were computed with, and the refresh coalescing. There is no separate store entity: the
panel is the only consumer, so an `Entity<InboxStore>` would add an `update` indirection and a
forwarding subscription without buying any sharing. The panel's own `unread_count` feeds its header
badge only; there is no global count and no sidebar badge.
```rust
pub struct InboxView {
focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>,
threads: Arc<Vec<InboxItem>>, // one row per thread, merged
sections: Arc<Vec<InboxSection>>, // grouped by repository
rows: Arc<Vec<InboxRow>>, // flattened list
unread_count: usize,
state: InboxReadState,
state_loaded: bool,
refresh: RefreshGate,
list: ListState,
_subscriptions: Vec<Subscription>,
}
impl InboxView {
pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>); // cx.defer(… sync_state)
pub fn sync_state(&mut self, cx); // observes the global Inbox
pub fn mark_all_read(&mut self, cx);
fn handle_backend_event(&mut self, event: &BackendEvent, cx);
fn refresh(&mut self, cx);
fn run_refresh(&mut self, cx);
fn regroup(&mut self, cx); // re-apply read state
fn rebuild(&mut self, cx); // group by repository, seed owned, flatten
fn clear(&mut self);
}
```
The panel owns three subscriptions that carry logic: it observes the global `Inbox`
(`InboxView::sync_state`), subscribes to `Backend` (`InboxView::handle_backend_event`), and observes
`RepoListStore` to rebuild when the user's own repositories load. Re-rendering itself needs no
subscription: GPUI invalidates a window for every entity it read during render, so the panel tracks
`RepoListStore` and `ProfileStore` just by reading them in `render`. The panel does not write back
to the global.
**`signed_state::query_inbox`.** The database work stays in `signed_state`, so the UI crate never
queries LMDB directly. `query_inbox` returns the grouped notifications, the user's own git
activity and the unread count; the panel applies the results on the main thread. `RefreshGate` is
re-exported for the panel's debounce.
```rust
pub async fn query_inbox(
client: &Client,
me: PublicKey,
state: &InboxReadState,
) -> Result<(Vec<InboxItem>, Vec<Event>, usize), Error>;
```
**Lifespan.** `Inbox` is created with the backend but idles until the user has a signer. The
derived lists live only as long as the panel. Nothing is wired from the `desktop` crate and
`signed_state::init` gains no parameters.
**No sidebar badge.** The sidebar's inbox nav item has no unread suffix (an earlier global count
derivation was removed with it). The unread count lives entirely in the panel, which shows it in
its header and per repository section. The trade-off is that the count is only current while the
panel is open, which is acceptable now that nothing outside it displays one.
The dependency chain is `Backend``Inbox` and `InboxView``query_inbox`.
`Backend` owns the inbox lifecycle (`sync_inbox`); the panel subscribes to `Backend` directly for
its lists. `BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay:
`CheckoutsStore` and `SidebarPanel` consume them. They no longer drive the inbox's activation
directly.
`InboxView::handle_backend_event` refreshes on:
- `NostrUpdate(updates)`: when any update kind is in `NOTIFICATION_KINDS`, is `Kind::Comment`, or is
a deletion (`EventDeletion` / `RequestToVanish`).
- `Synced` / `Published`.
- everything else: ignored.
**Signer lifecycle: `Backend::sync_inbox`.** `Backend` owns the wiring and calls `sync_inbox` from the
three real signer transitions: `create_identity`, `set_signer` (nsec, bunker and passphrase restore)
and `logout`. It starts the subscriptions and repo-relay connects, then calls `Inbox::activate` or
`Inbox::reset`. The client is passed into `activate`, so the global inbox never reads `Backend`
while `sync_inbox` is mid-update:
```rust
fn sync_inbox(&mut self, cx: &mut Context<Self>) {
let me = self.current_user;
if let Some(me) = me {
self.subscribe_bootstrap(filters::notifications(me), cx);
self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx);
let relays: HashSet<RelayUrl> = RepoListStore::global(cx)
.read(cx)
.announcements_of(&me)
.into_iter()
.flat_map(|announcement| announcement.relays)
.collect();
if !relays.is_empty() {
let relays: Vec<RelayUrl> = relays.into_iter().collect();
self.connect_repo_relays(relays.clone(), filters::notifications(me), cx);
self.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx);
}
}
let client = self.client.clone();
self.inbox.update(cx, |inbox, cx| match me {
Some(me) => inbox.activate(me, client, cx),
None => inbox.reset(cx),
});
}
```
The repo relays are read from `RepoListStore::global(cx).read(cx).announcements_of(&me)` at call
time and never cached. (NIP-65 outbox relay discovery is deferred; Signed does not fetch kind
10002 yet.) `Inbox::activate` and `Inbox::reset` are `pub(crate)`; `Inbox` has no `subscribe_remote`
/ `connect_own_repo_relays`.
**Activation** clears the state and loads the NIP-78 state from LMDB. The panel clears its own
lists and in-flight refresh when it sees the unloaded state, then refreshes once it is loaded:
```rust
pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context<Self>) {
// state = default; state_loaded = false; cx.notify();
// spawn load_state(client, me), then set state and state_loaded = true
}
```
`reset` performs the same clearing without a state load, and is used on logout.
Reading the state event needs no signer at all (the `d` tag carries the identity); activation is
still gated on the signer because the fetch filters need the user's pubkey.
**Fetch** reuses `Backend::subscribe_bootstrap` and `Backend::connect_repo_relays` through
`Backend::sync_inbox` (see above). The query the panel runs is intentionally the offline-first cache
read, not a wait on the network; see the note below.
**Refresh** (`InboxView::run_refresh`, mirrors `RepoListStore::run_refresh`):
- `cx.background_spawn`: query the notification filters and the activity filter from
`client.database()`.
- Query `filters::deletions()`, build `Deletions`, skip deleted events.
- Build `HashMap<EventId, Event>` for root walking; group the notification events with
`inbox::group`.
- Filter the activity events: keep issues/PRs/patches/statuses/cover notes, and comments only when
their `K` tag is a git kind; sort newest first.
- Cross back to the main thread: guard on `Backend::global(cx).read(cx).current_user() ==
Some(me)`; if the signer changed while the query ran, `refresh.abort()` instead of applying, so a
previous user's results never land. Then set `notifications`, `activity`, `unread_count`, rebuild the
repository sections (`rebuild`), `cx.notify()`, `refresh.finish()`.
`InboxView::sync_state` reacts to the global `Inbox`: while the state is not loaded it clears the
lists, on the first load it runs the initial refresh, and on a state change (a mark action) it
re-derives the flags (`InboxItem::apply_state`).
**Fetch vs. the immediate query.** `subscribe_bootstrap` / `connect_repo_relays` return immediately,
so the query that follows them reads the local cache rather than waiting for the relays. That is
deliberate offline-first behavior: cached content appears at once on a warm start and with no
network, instead of blocking the home screen on the network. The gap is closed by the SDK, not by
timing: received events are written to LMDB and surfaced as `ClientNotification::Event`, so
`Backend`'s pump batches them into `BackendEvent::NostrUpdate` and the store refreshes. This was
reviewed and left as-is.
**Actions**: `mark_all_read()` lives on the panel, which passes every known notification event to the
global `Inbox`. The global marks them, advances the cutoffs against *all* notification events to bound
the id sets, saves the state to LMDB (signed with a fresh random key, see 4.2), and notifies. The
panel then re-derives and publishes the unread count.
**Repository names need no new store**: `RepoListStore` already holds every announcement and
`repo_name` resolves an address to a display name.
### 4.4 `Cargo.toml`
- `signed_core`: add `serde.workspace` for the `InboxReadState` derives.
- `signed_state`: add `serde_json.workspace` for the NIP-78 content.
## 5. UI
### 5.1 `InboxView` center panel
`crates/workspace/src/views/inbox.rs`, a `BasePanel` + `Panel` + `Render`, like `RepoListView`.
It owns the derived lists directly, so `cx.notify()` from an update re-renders it. The panel is one
bordered card (`flex_1`, `min_h_0`) with a header bar and a scrolling body. The body is a single
`gpui::list` virtual list (`ListState` + `ListAlignment::Top`, 400px overdraw) with a
`vertical_scrollbar`; the panel itself does not scroll, so the list gets a definite viewport height.
The list count is reset from `render` whenever the rendered row count changes.
- **Header**: the unread count badge and **Mark all read**.
- **Body**: the flattened repository-grouped rows. A repository header is a muted bar with a git icon,
the repository name (or "Other repository" when the address is unknown) and its unread badge. Rows
under it show the actor avatar, a kind icon, the subject, the kind label, a relative time, and an
unread dot (the subject is semibold while unread). A repository with nothing to show renders
"No activity yet."; the panel-level "You're all caught up." empty state appears only when there are
no sections at all (no owned repositories and no items).
No greeting header, and no **My repositories** column - the sidebar already lists the user's
repositories.
### 5.2 Grouping by repository
The grouping is panel-owned derivation, done once per data change in `InboxView::rebuild` (called
from `run_refresh` and `regroup`), never per frame:
```rust
struct InboxSection {
address: Option<RepoAddr>, // repository, None for items without one
unread: usize, // unread notification groups
entries: Vec<InboxEntry>, // newest first
latest: Timestamp, // orders the sections
}
enum InboxEntry { // indices into the panel's own lists
Notification(usize),
Activity(usize),
}
enum InboxRow { // the flattened list
Repo(usize),
Entry(usize, usize),
Empty, // "No activity yet." under an empty section
}
```
The section list and the flattened rows are stored as `Arc`s and cloned into the `gpui::list`
closure, which indexes the panel's `notifications` / `activity` lists - no per-frame deep copies.
Notification groups carry their repository in `InboxItem::address`; activity events carry it in a
`GitRepoAnnouncement` `a` tag (`repo_address`). Archived notification groups are left out.
`rebuild` also seeds a section for every repository in `announcements_of(me)`. The panel observes
`RepoListStore` so a repository that loads after the last refresh still appears (its own empty
section, or with items if any arrived); this is the one logic subscription beyond the `Inbox` and
`Backend` ones. Repository names are resolved per render through `repo_name` -> `RepoListStore`, so a
late announcement still labels its section without re-deriving the grouping.
### 5.3 Sidebar
In `views/sidebar/mod.rs`:
- Add `inbox: Option<WeakEntity<InboxView>>` (mirrors `explore`).
- Add `fn open_inbox(&mut self, window, cx)` that returns when the panel is already open, else adds
a center panel (same shape as `open_explore`; there is no dock API to focus an existing tab).
`InboxView::new` takes the sidebar's `WeakEntity<DockArea>` so the panel can open a repo for a row.
- Point the existing nav item at it:
```rust
NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small())
.on_click(cx.listener(|this, _ev, window, cx| this.open_inbox(window, cx))),
```
- No unread badge. The nav item carries no suffix, and the sidebar does not observe the global
`Inbox`. The unread count lives in the panel only.
### 5.4 Click-through (P1)
The detail panels need a `Window`, and GPUI's `Entity::update_in` only exists on a `VisualContext`,
which a synchronous `App` + `Window` pair is not - so the entry point is a free function rather than
a `RepoDetailView::open_item` method. In `repo_detail/mod.rs`:
```rust
pub(crate) enum RepoItem {
Issue(EventId),
PullRequest(EventId),
Patch,
}
pub(crate) fn open_repo_item(
dock_area: &WeakEntity<DockArea>,
announcement: &Announcement,
item: RepoItem,
window: &mut Window,
cx: &mut App,
) { /* build the RepoStore here, then a new IssueDetailView / PullRequestDetailView, added to the center */ }
```
- `open_repo_item` builds its own `RepoStore` from `announcement` (a private `repo_store` helper calls
`RepoStore::new(addr, relays, cx)`), so the item panel is the **only** panel docked. An earlier
version opened `RepoDetailView` first and reused its store via `RepoDetailView::store()`; that
docked the repository panel too, which surfaced the repository load state (a `not found` error for
an announced repo with no local worktree) and left two center tabs. `RepoDetailView::store()` was
removed with it.
- `views/mod.rs` re-exports `RepoItem` and `open_repo_item`.
- `InboxView::open_item` resolves `item.address` to an `Announcement` from `RepoListStore`, and calls
`open_repo_item` with the root id and kind. The detail panel renders a "not found" placeholder
until the store's fetch lands, then re-renders.
- The item panel is added to the center group and activated.
Patches have no detail view in Signed (they are only consumed inside `PullRequestDetailView`), so a
patch-root click opens nothing. `RepoItem::Patch` carries no id for that reason. A group whose root is
not an issue/PR/patch, or whose repository is not in `RepoListStore`, opens nothing.
## 6. File-by-file change list
| File | Change |
|---|---|
| `crates/signed_core/Cargo.toml` | add `serde` |
| `crates/signed_core/src/filters.rs` | `NOTIFICATION_KINDS`, `notification_comments`, `notifications`, `authored_activity`, `is_git_activity`, `deletions` |
| `crates/signed_core/src/inbox.rs` | **new**: `InboxItem` (root event, notifications, own events), `notification_root`, `group`, `InboxReadState`, tests |
| `crates/signed_core/src/lib.rs` | `mod inbox;` and re-exports |
| `crates/signed_state/Cargo.toml` | add `serde_json` |
| `crates/signed_state/src/inbox.rs` | thin global `Inbox` (NIP-78 read state, mark actions) and `query_inbox` (query, merge notifications + activity into threads) |
| `crates/signed_state/src/backend.rs` | `inbox: Entity<Inbox>` field, construction, `inbox()` accessor, `sync_inbox`, `RepoListStore` import |
| `crates/signed_state/src/refresh.rs` | doc comment lists `Inbox` among the `RefreshGate` users |
| `crates/signed_state/src/lib.rs` | `mod inbox;`, re-export `Inbox` and `query_inbox`; re-export `RefreshGate` (no global install) |
| `crates/dock/src/lib.rs` | `add_bottom_panel` helper (currently unused; left over from the removed sub-views) |
| `crates/workspace/src/views/inbox.rs` | `InboxView` home panel owning the threads, the repository grouping, and the thread click-through |
| `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;`; re-export `RepoItem`, `open_repo_item`, `open_repo_panel` |
| `crates/workspace/src/views/sidebar/mod.rs` | `inbox` field, `open_inbox`, nav wiring |
| `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `open_repo_item` (builds its own `RepoStore` via the private `repo_store` helper) |
No changes to `desktop` or `signed_nostr`. `signed_state::init` gains no parameters; `Backend::sync_inbox`
activates the `Inbox` child entity at each signer transition.
## 7. Phasing
1. **Phase 0 - pure logic**: `signed_core` filters and `inbox.rs` plus tests. **DONE.**
Implemented as `filters::{NOTIFICATION_KINDS, notification_comments, notifications, authored_activity, is_git_activity}`
and `inbox::{InboxItem, notification_root, group, InboxReadState}`. Two deviations from the sketch:
the cutoff methods take an explicit `now: Timestamp` so the pure logic stays deterministic and testable,
and `authored_activity` results must pass through `is_git_activity` before display (comments on
non-git roots are matched by the filter). `cargo test -p signed_core` passes (66 tests at the
end of Phase 0; 68 after the two Phase 1 additions).
2. **Phase 1 - store**: `Inbox` child entity, activated by `Backend::sync_inbox` once a signer
exists; both queries, unread count, and NIP-78 load/save to LMDB. **DONE.** See the
implementation notes below.
3. **Phase 2 - screen**: `InboxView` (inbox + activity) and the sidebar nav item.
**DONE.** See the implementation notes below.
4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived.
**Done, then reverted.** The sub-views were removed before the repository-grouping redesign; the
notes below are historical.
5. **Phase 4 - click-through**: `open_item` and announcement lookup. **DONE.** See the implementation
notes below.
6. **Phase 5 (optional)**: standalone notifications page, NIP-65 relays, pagination, patch detail
view.
Each phase compiles and is usable on its own.
### Phase 1 implementation notes
Files: `crates/signed_state/{Cargo.toml, src/inbox.rs, src/lib.rs, src/backend.rs, src/refresh.rs}`
and two additions to `crates/signed_core/src/inbox.rs`.
- `Inbox` is a child entity of `Backend` (`inbox: Entity<Inbox>`), created in `Backend::new` and
reached via `Backend::inbox()`. Nothing in `desktop` is wired and `signed_state::init` gains no
parameters. The dependency is strictly one-way: `Inbox` holds no `Backend` handle.
- `Backend::emit` is the single funnel for every `BackendEvent`. It updates the inbox through
`cx.defer` and then emits to the other subscribers. The defer is required: every emit site runs
inside `Backend::update`, and the inbox handlers read `Backend`, so a synchronous call panics on
a re-entrant entity access.
- The signer lifecycle lives in `Backend::sync_inbox`, called from `create_identity`, `set_signer`
and `logout`. It starts the subscriptions and repo-relay connects, then defers `inbox.activate`
/ `inbox.reset`. `SignerChanged` / `SignerRequired` are still emitted for `CheckoutsStore` and
`SidebarPanel`, but no longer drive the inbox.
- `Inbox` mirrors `RepoListStore`: `RefreshGate` coalescing, `cx.background_spawn` for the
database work, plain data applied on the main thread, refresh-on-`NostrUpdate`/`Synced`/`Published`.
- Added `state_loaded: bool`, not in the sketch. Groups are derived from the read state, so a refresh
before the stored state is read would briefly mark everything unread. The first refresh is chained
after `load_state`, and later `refresh` calls are ignored until `state_loaded` is set.
- Account switches are guarded. `activate` and `reset` both replace `self.refresh` with a fresh
`RefreshGate`, dropping any in-flight or pending run of the previous user, and the apply step of
`run_refresh` aborts instead of applying when `Backend::current_user()` no longer matches the
user the query was started for.
- Two additions to `signed_core::inbox` that Phase 1 needs: `InboxReadState::mark_archived` (mirrors
`mark_read`) and `InboxItem::apply_state` (recomputes `unread_ids`/`archived`; `group` now uses it).
Both are covered by tests.
- The thread-root lookup is built by walking every `e`/`E` ancestor transitively (`fetch_notifications`)
rather than a single hop, because a patch series chains through parent patches. Only the notification
events are grouped; ancestors are used solely as the lookup, so a root authored by someone else is
not mistaken for a notification.
- The read/archive state event is written to LMDB only (`database().save_event`), signed with a fresh
`Keys::generate()` on each save and never published. Filtering is by `d` tag only, no author, so the
random key is irrelevant across sessions. `d` tag uses `me.to_hex()` rather than `Display`.
- Actions: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()`. Each marks the group, advances
the relevant cutoffs against **all** notification events (matching GitWorkshop's use of `allEvents`),
re-derives the groups locally so the UI updates immediately, then persists in the background.
- The global `Inbox` keeps no derived state. The signing key is generated per save, the current user is read
from `Backend::current_user()` where needed, and the relays of the user's own repositories are
queried from `RepoListStore` in `Backend::sync_inbox` rather than cached. There is no prune logic
either: the newest state event is selected by `created_at`.
- `Inbox::activate` / `Inbox::reset` are `pub(crate)`; the former `subscribe_remote` and
`connect_own_repo_relays` methods were deleted once their work moved into `Backend::sync_inbox`.
- `cargo test -p signed_core` passes (68 tests), `cargo test -p signed_state` passes (24 tests);
`cargo clippy -p signed_state --all-targets` is clean; `cargo check --workspace` succeeds.
### Phase 2 implementation notes
Files: `crates/workspace/src/views/{inbox.rs, mod.rs, sidebar/mod.rs}`. No store changes.
- `InboxView` is a plain center panel like `RepoListView`; the sidebar holds a
`WeakEntity<InboxView>` so there is no cycle. Re-rendering relies on GPUI's render-time entity
tracking rather than explicit observations. (Phase 2 introduced an `Entity<InboxStore>` here; it
was later folded into the panel - see the store-merge note below.)
- The layout is a column of two flexible bordered cards (`gap_4`, `p_4`, each `flex_1`/`min_h_0`),
inbox over activity. Each card is a rounded `v_flex` with a header bar (`section`) and a
`gpui::list` body. There is no **My repositories** column: the sidebar already lists the user's
repositories, so the panel is a single column.
- Notification rows read the newest event of each group for the actor, subject and time, and the
root's kind for the icon. The repo name is resolved from `item.address` through a linear scan of
`RepoListStore::announcements` (`repo_name`); the list is small and this keeps the store unchanged.
- The **Unread** / **Archived** header buttons are intentionally absent: they need
`add_bottom_panel` / `InboxFilterView`, which are Phase 3. The header is only **Mark all read**,
so the panel is fully usable on its own.
- `kind_icon` / `kind_label` map a `Kind` to a `CustomIconName`/`IconName` and a short noun. The
cover note is compared with `==` rather than matched, since `Kind` cannot appear in a pattern arm.
- Sidebar: `open_inbox` mirrors `open_explore` (return if open, else add a center panel); the inbox
nav item is repointed. The screen is still opened by the nav item, not on app startup, matching the
"idle until signer" rule; auto-opening it as the post-login home is a possible follow-up.
- The **My repositories** column (search `InputState`, **New** button, `open_repo_panel` rows) was
removed after Phase 2 as redundant with the sidebar, along with the panel's `dock_area`,
`open_repo` / `open_create_repo` helpers and the `create_repo_dialog` / `open_repo_panel` imports.
`InboxView::new` now takes only `cx`. `create_repo_dialog` is private again.
- `cargo clippy -p workspace --all-targets` is clean and `cargo check --workspace --all-targets`
succeeds. `cargo test -p signed_core` (68) and `cargo test -p signed_state` (24) still pass.
### Architecture refactor (after Phase 2)
Phases 0-2 kept all derivation in the global `Inbox`, so every notification and activity query ran
whether or not the home screen was open, and `Backend::emit` carried a deferred side effect just to
feed it.
- The global `Inbox` is now thin: `state: InboxReadState`, `state_loaded`, plus the NIP-78 load/save
and the mark actions.
- `Backend::emit` is gone. All `BackendEvent`s are emitted with `cx.emit` again, and `sync_inbox`
updates the inbox synchronously, passing the client in so nothing reads `Backend` mid-update.
- The panel became the client-side owner of the derivation, initially through a panel-scoped
`Entity<InboxStore>`.
- `signed_core` is unchanged.
### Store merged into the panel (after Phase 2)
The `InboxStore` entity was then folded into `InboxView`, since the panel was its only consumer.
- `InboxView` holds `notifications`, `activity`, `unread_count`, `state`, `state_loaded` and
`RefreshGate` as fields, and the store's methods (`sync_state`, `handle_backend_event`,
`refresh`/`run_refresh`, `regroup`, `publish_unread_count`, `clear`, the mark actions) became panel
methods. The two subscriptions call them directly, with no `update` indirection.
- The database work stayed in `signed_state` as `pub async fn query_inbox(...)`; `RefreshGate` and
`RefreshRequest` are re-exported. The UI crate never queries LMDB directly.
- `mark_read`, `mark_archived` and their `group_events` helper carry a scoped `#[allow(dead_code)]`
until the Phase 3 sub-views wire them up.
- `cargo test -p signed_core` (68), `cargo test -p signed_state` (24) and `cargo test -p workspace`
(7) pass; clippy and `cargo check --workspace --all-targets` are clean.
Trade-off: the unread count is derived by the panel, so it is only current while the panel is open.
(`publish_unread_count` fed a sidebar badge at the time; both were removed later - see "Sidebar
badge removed" below.)
### Phase 3 implementation notes
> Historical: the Unread/Archived sub-views below were later removed; the panel is now a single
> repository-grouped list. Kept for the `add_bottom_panel` / sub-view rationale.
Files: `crates/dock/src/lib.rs` and `crates/workspace/src/views/{inbox.rs, sidebar/mod.rs}`. No
store changes.
- `add_bottom_panel` sits next to `add_center_panel` and wraps
`DockArea::add_panel_view(panel, DockPlacement::Bottom, None, ...)`. A new bottom dock starts open,
and the workspace's existing `DockEvent::LayoutChanged` subscription removes an emptied bottom dock,
so a closed sub-view leaves no strip behind.
- `InboxFilterView` is private to `views/inbox.rs`. It holds an `Entity<InboxView>` (strong; the
panel keeps only the weak `filter_view` back, so there is no cycle), the mode, and its own
`ListState`. There is no subscription: it reads the inbox entity during render, which is enough for
GPUI to invalidate the window when the inbox notifies.
- `InboxFilter` is a private two-variant enum with `label()` and `matches(&InboxItem)`. The tab title
comes from `Panel::title`, so switching modes through `set_mode` retitles the same tab instead of
opening a second one.
- `InboxView` regained a `dock_area: WeakEntity<DockArea>` (removed with the My-repositories column)
and takes it in `new`. `open_filter` reuses the existing panel, focuses it, and reopens the bottom
dock when it is collapsed; otherwise it creates and adds the panel. `InboxView::new` is now called
as `InboxView::new(self.dock_area.clone(), cx)` from `SidebarPanel::open_inbox`.
- The three `#[allow(dead_code)]` markers on `mark_read`, `mark_archived` and `group_events` are gone:
Unread rows call `mark_read` on click and `mark_archived` from a trailing ghost icon button
(`Button` + `IconName::FolderClosed`, tooltip "Archive"). The button calls `cx.stop_propagation()`
so it does not also trigger the row's mark-read click. Archived rows are display-only; the read
state has no un-archive operation.
- `notification_row` takes an id `prefix` and returns `Stateful<Div>` rather than `AnyElement`, so
callers can attach a click handler and a trailing action. The inbox list passes `"inbox-row"` and
the sub-view `"inbox-filter-row"`, because the two lists render in the same window and would
otherwise collide on `(str, ix)` ids.
- `cargo clippy -p workspace -p dock --all-targets` is clean, `cargo check --workspace --all-targets`
succeeds, and `cargo test -p signed_core -p signed_state -p workspace` passes (68 / 24 / 7).
### Phase 4 implementation notes
Files: `crates/workspace/src/views/{inbox.rs, mod.rs, repo_detail/mod.rs}`. No store changes.
- `RepoItem { Issue(EventId), PullRequest(EventId), Patch }` and `pub(crate) fn open_repo_item` live
in `repo_detail/mod.rs`, next to `open_repo_panel`. `open_repo_item` builds its own `RepoStore` from
the announcement (private `repo_store` helper), so only the item panel is docked.
- It is a free function, not `RepoDetailView::open_item`: the detail constructors take a `Window`, and
a synchronous `&mut App` + `&mut Window` pair is not a `VisualContext`, so `Entity::update_in` is
not available. `InboxView` already has the window in the list's `on_click`, so it drives the free
function directly. The plan's original `detail.update_in(window, cx, ...)` sketch could not compile.
- `InboxView::open_item` is also a free function (it needs nothing but `dock_area`, which it captures
from the panel) because the `gpui::list` item closure only receives `&mut App`. It resolves
`item.address` through `RepoListStore`, returns silently when the repository is unknown, maps the
root kind to a `RepoItem`, and calls `open_repo_item`.
- Fixed: the first version opened `RepoDetailView` to borrow its store (`RepoDetailView::store()`),
which docked the repository panel alongside the item panel and showed its `not found` load error.
`open_repo_item` now builds the `RepoStore` itself and `RepoDetailView::store()` is gone.
- Only the notification rows are clickable. Activity rows are display-only. The Phase 3 mark-read /
archive row behaviour is gone with the sub-views.
- `RepoItem::Patch` is a unit variant because the id would be unused: patches have no detail panel, so
`open_repo_item` returns before doing anything and nothing is docked.
- `cargo clippy -p workspace --all-targets` is clean, `cargo check --workspace --all-targets` succeeds,
and `cargo test -p signed_core -p signed_state -p workspace -p dock` passes (68 / 24 / 7 / 1).
### Repository grouping redesign (after Phase 4)
Files: `crates/workspace/src/views/inbox.rs`. No store, no `signed_core` changes.
The two-card layout (notifications over activity) was replaced by a single repository-grouped list.
- The panel now derives `sections: Vec<InboxSection>` and a flattened `rows: Vec<InboxRow>` in
`rebuild`, called from `run_refresh` and `regroup`. Both are stored as `Arc`s and cloned into the
`gpui::list` closure, which indexes `notifications` / `activity` - no deep copies per frame and no
data duplicated between the section list and the source lists.
- `InboxSection` groups a repository's non-archived notification groups and the user's own activity,
newest first; sections are ordered by their newest entry. `InboxEntry` holds indices into the
panel's lists; `InboxRow::Repo` / `InboxRow::Entry` / `InboxRow::Empty` is the flattened shape the
list renders.
- All of the user's own repositories are seeded as sections from `RepoListStore::announcements_of`,
so an owned repository with nothing to show gets an empty section ("No activity yet.") and sorts
after the sections with activity. The panel observes `RepoListStore` to rebuild when the user's
repositories load or change.
- Activity is matched to a repository through a `GitRepoAnnouncement` `a` tag (`repo_address`).
Items without an address share the "Other repository" section.
- `notification_row` / `activity_row` no longer render the repository name - the section header does.
That also drops one `RepoListStore` scan per row.
- The single card has one `ListState`; the old `notifications_list` / `activity_list` and the
`render_inbox_panel` / `render_activity_panel` / `section` helpers are gone. `notification_row` still
takes an id prefix so rows stay unique within the list.
- `cargo clippy -p workspace --all-targets` is clean and `cargo test -p signed_core -p signed_state
-p workspace -p dock` passes (68 / 24 / 7 / 1).
### Sidebar badge removed (after the repository grouping redesign)
Files: `crates/signed_core/src/filters.rs`, `crates/signed_state/src/{inbox.rs,backend.rs}`,
`crates/workspace/src/views/{inbox.rs,sidebar/mod.rs}`.
An intermediate change made the sidebar badge live by moving the unread count into the global
`Inbox` (a `refresh_unread_count` driven by `Backend`). That was then reverted along with the badge
itself, so the global is thin again.
- The sidebar nav item no longer renders a `CountBadge`; `SidebarPanel` lost its `unread` field and
its observe of the global `Inbox`.
- The global `Inbox` no longer stores an `unread_count` and has no `set_unread_count` /
`refresh_unread_count`. `Backend` has no `refresh_inbox_unread` and no per-batch or per-sync count
refresh. `filters::affects_inbox` and the `query_inbox` helper split were reverted with it.
- `InboxView` keeps its local `unread_count` for its header badge and the per-section `unread` for
the repository headers; `publish_unread_count` stays deleted.
- Consequence: the unread count is only current while the panel is open, and there is no unread
indication anywhere else in the app.
- `cargo clippy -p signed_core -p signed_state -p workspace --all-targets` is clean,
`cargo check -p signed_core -p signed_state -p workspace --all-targets` succeeds, and
`cargo test -p signed_core -p signed_state -p workspace -p dock` passes (68 / 24 / 7 / 1).
### Threads merged: notifications + activity (after the sidebar badge removal)
Files: `crates/signed_core/src/inbox.rs`, `crates/signed_state/src/inbox.rs`,
`crates/workspace/src/views/inbox.rs`.
Notifications and own activity were two separate row kinds that could describe the same thread. They
are now one item per thread: the notifications directed at the user and the user's own events in that
thread live in the same `InboxItem`. A row shows the thread root's title and up to five of the
thread's most recent events:
```
[icon] Add retry logic (unread dot)
[avatar] You opened an issue · 3d
[avatar] alice commented · 2d
```
- `InboxItem` gained `root_event: Option<Event>` and `own_events: Vec<Event>`. `events` keeps only the
notifications (others' events); `own_events` holds the user's own. `unread_ids`/`archived` are
derived from `events` alone, so own activity is never unread and a thread with only own events is
never archived (`apply_state` guards the empty case).
- New methods on `InboxItem`: `title()` (root event's subject, falling back to the newest event),
`kind()` (root kind, same fallback), and `timeline(limit)` (thread events deduplicated by id,
oldest first, always keeping the root event and filling the remaining slots with the most recent
others).
- `group` now takes both `events` (notifications) and `own` (the user's activity) and merges them on
the resolved root. Own events resolve through the same `notification_root`; an unresolved own event
becomes its own root. `query_inbox` returns `(Vec<InboxItem>, usize)` - the separate activity list
is gone, and `by_id` is extended with the own events so a comment of ours resolves to its thread.
- The panel holds `threads: Arc<Vec<InboxItem>>` instead of `notifications` + `activity`. The
`InboxEntry` enum, `entry_time`, `repo_address`, `related_activity`, `notification_row`,
`activity_row` and `kind_label` are gone. `thread_row` replaces both row kinds and is clickable like
the old notification row; `group_sections` now just buckets threads by `item.address`.
- `sub_activity_line` is unchanged and still renders `[avatar] [name] [phrase] · [ago]`, with `You`
for the signed-in user and `activity_phrase(kind)` for the verb. Rows are variable height
(`py_2`), which `gpui::list` auto-measures.
- Thread rows in a section are drawn as one stack: `render_entry` passes `first`/`last` within the
section (`entry_ix == 0` / `entry_ix + 1 == section.entries.len()`), and `thread_row` rounds the
outer edges (`rounded_t` on the first, `rounded_b` on the last, theme radius) and draws a
`border_b_1` divider on every row but the last.
- Trade-off: the row title is the thread root's, not the newest event's, so a comment thread no longer
previews the comment text. That is the point of the merge - the row identifies the thread.
- `cargo clippy -p signed_core -p signed_state -p workspace --all-targets` is clean,
`cargo check -p signed_core -p signed_state -p workspace --all-targets` succeeds, and
`cargo test -p signed_core -p signed_state -p workspace` passes (69 / 24 / 7).
## 8. Validation
- `cargo test -p signed_core` (69 tests): root resolution, grouping, merging, read-state cutoff, serde
round-trip.
- `cargo test -p signed_state` (24 tests): the `Inbox` / `query_inbox` paths that do not need GPUI
(state round-trip, grouping helpers).
- `cargo test -p workspace` (7 tests): repository-detail helpers.
- `cargo clippy -p signed_state --all-targets`, `cargo clippy -p workspace --all-targets` and
`cargo check --workspace --all-targets` after each phase.
- Manual: log in with a repo-owning identity; open the inbox from the sidebar and confirm the panel
populates from another identity's issue/comment, the activity list shows your own items, and that no
kind-30078 event is broadcast (watch the relays / `Published` events). Restart to confirm the read
state is read back from LMDB. Confirm the sidebar has no unread badge.
## 9. SDK APIs used (verified in the pinned `5c669a4` checkout)
- `Kind::{Comment, GitIssue, GitPullRequest, GitPatch, GitPullRequestUpdate,`
`GitStatusOpen/Applied/Closed/Draft, ApplicationSpecificData, EventDeletion, RequestToVanish}`
- `Filter::{kind, kinds, pubkey, pubkeys, custom_tags, limit, since, events, coordinate, identifier}`
- Non-obvious: `Filter::pubkey`/`pubkeys` set the lowercase **`p` tag**, not `authors`. Use
`Filter::author`/`authors` for authorship. The `notifications` filter relies on this.
- `SingleLetterTag::{LOWERCASE_P, LOWERCASE_E, UPPERCASE_P, UPPERCASE_K, UPPERCASE_E}`
- `nostr::nips::nip22::{extract_root, extract_parent, CommentTarget}`: NIP-22 root/parent pointers
- `Tags::{event_ids, public_keys, coordinates, identifier, hashtags}` iterators
- `Client::{database, subscribe, sync, notifications, send_event, add_relay}`;
`NostrDatabase::{save_event, query}`; `NostrLmdb`, `NostrGossipMemory`
- `EventBuilder::{new, tags, finalize}`, `Tag::identifier`, `Keys::generate`
- `Timestamp`, `EventId` (hex serde), `PublicKey`, `Coordinate`
- Fetch paths converge on the same notification: `client.subscribe(...)` and negentropy
`client.sync(...)` both persist received events to LMDB and surface them as
`ClientNotification::Event`, which `Backend`'s pump batches into `BackendEvent::NostrUpdate`.
This is why the query right after a fetch is a cache read, not a race.
-372
View File
@@ -1,372 +0,0 @@
# Repository state and panel flow plan
Status: phases 1-3 implemented (2026-09-13)
Builds on `docs/backend-rearchitecture.md`, especially §7 (notify audit),
§11 (split independently-observed state), §12 (one debounce at the source)
and §13 (merge the repo listing files, not the entities).
Scope: `signed_state::{repo, repos}`, `workspace::views::{repo, issues,
pull_requests, inbox}`.
## Goal
1. Open an issue or PR directly, from any surface (inbox entry, notification,
future deep link), without walking Explore -> repository panel first.
2. One per-repository entity that spans both identities: the local git
repository and the NIP-34 announcement, instead of today's separate
`LocalReposStore` entry / `Option<Announcement>` + `Option<Entity<RepoStore>>`
view state.
## What the code does today (verified against the current tree)
### 1. Item panels are cached and never observe their store
The dock renders the active panel through `panel.cached(...)`
(`crates/dock/src/tab_panel.rs:777-795`), so a panel re-renders only when its
own entity notifies. Cross-entity reads are not reactive.
`RepoDetailView` observes its store (`views/repo/store.rs:35-43`), but the
panels that read the same store do not:
- `IssueDetailView` (`views/issues/detail.rs:79-85`) renders
`placeholder("Issue not found", cx)` when the issue is absent, with no
observer. When a directly opened store's first pass lands later and notifies,
nothing re-renders the panel: it stays on "Issue not found".
- `PullRequestDetailView::load` runs once from `cx.defer_in`
(`views/pull_requests/detail.rs:88-91`) and caches
`error = "Pull request not found"` when the store is empty
(`:124-133`). It can never recover.
- `IssuesView` / `PullRequestsView` memoize rows behind
`(store.version(), filter)` (`views/issues/mod.rs:324-351`,
`views/pull_requests/mod.rs:334-369`) but nothing re-renders them when the
version changes, so an open list does not see new events either.
- `NewPullRequestView` reads `store.head` / the announcement in `new`, so a
late first pass does not reach its defaults.
The normal flow hides this because the repository panel is opened first: by
the time the issues list or an item panel is created, the store has already
applied its first pass. Opening an item directly is the case where the store
is still empty at construction.
### 2. The initial pass waits on a timer the backend already provides
`RepoStore::new` defers `refresh` (`signed_state/src/repo.rs:130-141`), and
`refresh` always waits `REFRESH_DEBOUNCE` = 300 ms (`repo.rs:25-26`,
`:230-242`) before the local database query. `RepoListStore` has the same
timer (`repos.rs:120-123`, `:270-281`) plus a separate `refresh_initial`
that skips it (`:257-268`).
Both stores only refresh on `Backend` events:
- `NostrUpdate` is already batched by the notification pump with its own
`PUMP_DEBOUNCE` = 200 ms (`backend.rs:37`, `:112-166`). Per
`backend-rearchitecture.md` §12, the per-store timers were to be dropped
once the pump absorbed the bursts.
- `Published` and `Synced` are one-off events; `RefreshGate` already folds
them into an in-flight run.
The timers are therefore pure added latency for these two stores: ~300 ms
(batched updates) to ~500 ms (pump window + store window) before local data
appears.
### 3. Opening an item requires a hydrated `Announcement`
`repo_store(announcement)` (`views/repo/actions.rs:216-219`) and the inbox's
lookup in `RepoListStore` (`views/inbox.rs:384-404`) need the announcement in
hand and silently no-op when it is missing (`RepoListStore` not synced yet,
deleted repo, inbox section without a matching list entry). The store itself
can load the announcement from the local database; `run_refresh` already
queries `filters::announcement(&addr)` (`repo.rs:251-261`).
### 4. Local and announced repositories have no shared identity
- `LocalReposStore` holds scan paths; `RepoListStore` holds announcements;
`CheckoutsStore` joins them (`checkouts.rs:211-213`, `:569-609`).
- `RepoDetailView` encodes both modes in three option fields:
`initial: Option<Announcement>`, `store: Option<Entity<RepoStore>>`,
`local_path: Option<PathBuf>` (`views/repo/mod.rs:74-85`), with
`apply_announcement` moving between them (`views/repo/store.rs:13-31`).
The invalid combinations and the `initial` fallback in `announcement()`
(`views/repo/mod.rs:322-331`) are the cost of the missing per-repo entity.
## Design
### 1. Panels observe, derive into local state, notify on change
Every panel that reads a store keeps a local snapshot of exactly the slice it
renders, updates it in an observer, and calls `cx.notify()` only when the
slice changed. This is the pattern `RepoDetailView::refresh_statuses`
(`views/repo/store.rs:88-101`) and `SidebarPanel::refresh` already use; the
item panels are missing the observer half.
| View | Observed entity | Local snapshot |
|---|---|---|
| `IssueDetailView` | `Entity<RepoStore>` | root issue, status, comments |
| `PullRequestDetailView` | `Entity<RepoStore>` | root PR, description, tip, base, clone urls |
| `IssuesView` | `Entity<RepoStore>` | `visible_issues`, `counts`, `item_sizes`, `cache_key` |
| `PullRequestsView` | `Entity<RepoStore>` | `visible_prs`, `counts`, `item_sizes`, `cache_key` |
| `NewPullRequestView` | `Entity<RepoStore>` | announced head, default base |
Sketch, matching the existing idiom:
```rust
// new()
let subscription = cx.observe(&store, |this, store, cx| this.sync(store, cx));
// Copy the slice; notify only when it changed.
fn sync(&mut self, store: &Entity<RepoStore>, cx: &mut Context<Self>) {
let store = store.read(cx);
let issue = store.issues.iter().find(|issue| issue.id == self.issue_id).cloned();
let comments: Vec<Event> = store.comments_of(&self.issue_id).cloned().collect();
let status = issue.as_ref().map(|issue| store.status_of(issue));
if self.issue != issue || self.comments != comments || self.status != status {
self.issue = issue;
self.comments = comments;
self.status = status;
cx.notify();
}
}
```
`PullRequestDetailView` needs its one-shot `load` split in two:
- `bind` finds the root PR in the store and snapshots description, tip, base
and clone urls. Re-runnable on every store version change.
- `load_diff` does the async patch/git work. Runs once bound, and again when
the bound tip changes (a PR update arriving late).
### 2. One debounce, at the backend pump
Delete the `REFRESH_DEBOUNCE` timers from `RepoStore` and `RepoListStore`.
Their triggers all come from `Backend`; the pump batches relay traffic and
`RefreshGate` folds one-off events into an in-flight run. Keep `RefreshGate`
exactly as is, used without a timer:
```rust
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.refresh.request() != RefreshRequest::Schedule {
return;
}
self.run_refresh(cx);
}
```
`RepoListStore::refresh_initial` collapses into `refresh`; the
`new`-time `cx.defer` call becomes the initial load, with no timer.
Add `pub loaded: bool` to `RepoStore`, set when the first pass applies. It
separates "no data yet" from "genuinely empty": panels render a loading state
while `!loaded`, and "not found" only after `loaded`.
`CheckoutsStore` and `InboxView` keep their timers in this plan. Their
refresh request sources are not only the backend pump (checkout requests,
settings and scan observations), so the same argument does not hold
unchanged; revisit separately if measurements show the timers redundant.
### 3. `RepoStore` is the one per-repository entity
Keep the name `RepoStore` (`Repository` collides with `gix::Repository`,
already imported in `views/repo/loading.rs`). Shape:
```rust
pub struct RepoStore {
/// NIP-34 address. `None` while the repository is local-only.
addr: Option<RepoAddr>,
/// Latest announcement. Seeded from the open-time hint, replaced by the
/// database's latest on the first pass. `None` while local-only.
pub announcement: Option<Announcement>,
/// Local working copy: the scan path for a local repo, an associated
/// checkout for an announced one. A snapshot; `CheckoutsStore` stays the
/// authority for the full list of checkouts.
pub path: Option<PathBuf>,
/// The first local pass has been applied.
pub loaded: bool,
// issues, patches, pull_requests, comments, status_by_root, head, flags...
_subscription: Option<Subscription>,
}
```
`addr` is required in addition to `announcement`: `(announcement: None,
path: Some(_))` is otherwise ambiguous between "local-only" and "announced,
first pass pending", and the store needs the address to run its query.
Constructors and the state transition:
```rust
impl RepoStore {
/// Announced repository. Resolves `path` from `CheckoutsStore` if the
/// user already has a checkout.
pub fn new(addr: RepoAddr, hint: Option<Announcement>, cx: &mut Context<Self>) -> Self;
/// Local repository discovered by the scan, not announced yet.
pub fn new_local(path: PathBuf, cx: &mut Context<Self>) -> Self;
/// Local -> NIP-34 in place. Keeps `path`, so the panel keeps its worktree.
pub fn announce(&mut self, announcement: Announcement, cx: &mut Context<Self>);
pub fn addr(&self) -> Option<&RepoAddr>;
}
```
- `new`: subscribes to `Backend`, seeds `announcement` from the hint, defers
remote subscribe/connect, runs the local pass immediately.
- `new_local`: no `Backend` subscription, `loaded = true`, path set.
- `announce`: sets `addr`, `announcement`, keeps `path`; installs the
`Backend` subscription, connects the announced relays and refreshes. Called
from `apply_announcement`, which loses its field surgery.
- Nostr-side actions (`push_repository`, `clone_to_folder`,
`open_issue`/`open_pull_request`, status changes) already have
`action_error("Repository announcement is not loaded yet")`
(`repo.rs:1368-1378`); they now also handle `addr == None` the same way.
- `announce` should also `CheckoutsStore::record(path, addr)` for the scan
path, so the association exists immediately instead of waiting for the
origin/EUC match in `resolve_associations`. Optional, verify behavior.
`RepoDetailView` then holds `store: Entity<RepoStore>` plus explorer state
only. `initial` and `local_path` are deleted; `announcement()` reads the
store; local-mode checks become `store.read(cx).addr().is_none()`;
`load_repo` opens `path` when not announced, and keeps today's cache-mirror
flow for announced repositories.
### 4. Opening a repository or item needs only a `RepoAddr`
- `repo_store(addr: &RepoAddr, hint: Option<&Announcement>, cx) ->
Entity<RepoStore>`.
- `open_repo_item(dock_area, addr: &RepoAddr, item, window, cx)`.
- Inbox passes its already-parsed `address` (`signed_core::InboxItem.address`,
the root event's `a` tag) and drops the `RepoListStore` lookup.
- `open_upstream` no longer polls the list; it opens the panel by address and
the store fills it in (Phase 2).
- `RepoItem::Patch` behavior is unchanged.
## Phases
### Phase 1 - make item panels react to the store (fixes the reported flow)
Status: implemented, except step 6.
1. `signed_state/src/repo.rs`: `REFRESH_DEBOUNCE` and the timer spawn are
gone; `refresh` runs immediately. `loaded` was added and is set in the
apply closure. A first pass notifies even when it found nothing, so views
leave their loading state and show the empty result.
2. `signed_state/src/repos.rs`: same timer removal; `refresh_initial` folded
into `refresh`.
3. `views/issues/detail.rs`: observes the store and re-renders; loading
placeholder while `!store.loaded()`.
4. `views/pull_requests/detail.rs`: observes the store; `sync`/`sync_missing`
bind the root and `load_diff` does the async work, keyed to a `PrBinding`
and guarded by a generation so late results are discarded. Loading vs not
found is decided by `store.loaded()`.
5. `views/issues/mod.rs`, `views/pull_requests/mod.rs`: observe the store;
`rebuild` recomputes rows/counts/item sizes and `sync` notifies on change.
Filter buttons call `rebuild` before notifying.
6. `views/pull_requests/new.rs`: not done. Its store-derived inputs are
defaults for the compare/base selectors; re-applying them on a late store
pass would clobber a selection the user already made. Left for a follow-up
once the defaults can be derived without resetting the selectors.
### Phase 2 - entry points by identity
Status: implemented.
1. `views/repo/actions.rs`: `repo_store(addr, hint, cx)` seeds the store's
relays from an optional hint but needs nothing else; the store loads the
announcement itself. `open_repo_item(addr, hint, item, window, cx)` takes
the address, not a hydrated announcement.
2. `views/inbox.rs`: `open` passes its already-parsed `address` and the
`RepoListStore` lookup with its silent early-return is gone. An inbox row
opens whether or not the repository is in the list yet.
3. `open_repo_panel` and `RepoDetailView::new` take an address plus an optional
hint, so a repository panel opens from a `RepoAddr` alone. This pulls the
address-based constructor forward from Phase 3 step 2.
4. `RepoDetailView::attach_store` starts the explorer from the store's first
announcement when the panel was opened by address alone, so a panel opened
by address fills in instead of waiting for the caller to have the
announcement. Phase 3 moves this onto the single store observer, gated by
`repo_started`.
5. `open_upstream`: the 60 x 250 ms poll and the `pending_upstream` field are
gone. It opens the panel by address; the store's `subscribe_remote` fetches
the announcement from the bootstrap relays and step 4 loads the explorer.
### Phase 3 - one entity for local and NIP-34
Status: implemented.
1. `signed_state/src/repo.rs`: `addr: Option<RepoAddr>`,
`path: Option<PathBuf>`, `announcement: Option<Announcement>`,
`_subscription: Option<Subscription>`. `new(addr, hint, cx)` seeds the
announcement and relays from the hint; `new_local(path)`; `announce`
switches a local store to NIP-34 in place, keeping `path`. `addr()` returns
`Option<&RepoAddr>`; `refresh`/`connect_announced_relays`/`subscribe_remote`
no-op without an address. Nostr-side actions guard with `not_announced`
(unit actions) or `action_error` (task actions).
2. `views/repo/mod.rs`: one `store: Entity<RepoStore>` field. `initial` and
`local_path` are deleted; `new_local` builds a local store. The store
observer starts the explorer once an announcement lands, tracked by
`repo_started`. `display_name`, `load_repo` and `open_init_dialog` derive
their mode from `addr()`/`path` instead of the removed fields.
3. `views/repo/store.rs`: the store is observed from construction for both
modes; `apply_announcement` calls `store.announce` on the existing entity.
`refresh_ready_statuses` and `refresh_statuses` return early when `addr()`
is `None`.
4. `views/repo/{actions,header,banners,loading}.rs`: the
`Option<Entity<RepoStore>>` guards are gone. Announced-only entry points
(issue/PR lists, new PR, send patch) guard on `addr()`; `NewPullRequestView`
and `PullRequestDetailView` thread the address option through their
prefill/binding paths.
5. `LocalReposStore` stays as the scan index; `CheckoutsStore` stays the
association authority.
Deviations from the sketch above:
- `path` is set only by `new_local` and kept by `announce`. `new` does not
resolve an associated checkout: the explorer still mirrors the cache for
announced repositories, so a stored checkout path would be dead weight.
The field is the seam for the open question below.
- `new_local` takes no `Context`: a local store has nothing to subscribe to and
no first pass to defer.
- `new` keeps the open-time hint until the first pass has confirmed what the
database holds, so a panel opened from a hint renders before the query lands
and still adopts a later deletion.
### Phase 4 - deferred, only if duplicate stores become a problem
One store per address via `HashMap<RepoAddr, WeakEntity<RepoStore>>` inside
`RepoListStore`, so an item panel opened while the repository panel is open
shares the same store and its subscriptions. Not needed for correctness once
Phase 1 lands; each open then loads from the local database immediately.
## Non-goals
- No per-store debounce; the pump is the one debounce (§12). `RepoStore` and
`RepoListStore` must not grow timers again.
- No new global store, and no merging `LocalReposStore`, `RepoListStore`
and `CheckoutsStore` into one entity (§13).
- Views never query the database directly; `RepoStore` stays the single
projection so status/comment derivation is not duplicated.
- Explorer state (tree, refs, commits, scroll) stays in `RepoDetailView`.
- No `Repository` rename.
## Validation
- `cargo check -p signed_state -p workspace`, then clippy.
- Manual scenarios:
1. Fresh database, never open Explore: click an issue in the inbox. Panel
shows a loading state, then the issue with comments and status.
2. Same for a PR, including the patch diff loading once the root binds.
3. Open the issues list, then receive a new issue (second client or
publish); the row appears without reopening.
4. Local repo: open from the sidebar, Init, panel keeps the worktree and
gains the nostr header; reopening from Explore shows the same data.
5. Existing flows: Explore list, ready-to-contribute and ready-to-push
banners, new issue/PR dialogs.
## Open questions
1. Should an announced repository with an associated checkout browse that
checkout instead of the cache mirror? Today the panel always mirrors.
`RepoStore.path` makes this a one-line decision later.
2. Store sharing (Phase 4): worth it only if duplicate subscriptions show up
in practice.