Files
coop/docs/sidebar-redesign-plan.md
T
2026-09-20 09:02:57 +07:00

20 KiB
Raw Blame History

Sidebar redesign: onboarding and tabbed navigation

The sidebar is currently one flat tree: a user header, four action rows (Inbox / Requests / Browse / Search), and two collapsible sections (Community, Messages) whose expansion state is persisted in settings. This plan replaces that with two distinct states:

  • Signed out — a full-height onboarding sidebar with a banner, the brand mark, and two entry points (Join now, Import identity), patterned on the signed client's sidebar (signed/crates/workspace/src/views/sidebar/mod.rs, render_sign_in).
  • Signed in — three tabs (Recents, Chats, Communities) selected from an icon-only tab bar that floats at the bottom of the sidebar: absolute, bottom_2, left_0, w_full, px_2.

The tab split also removes the last reason for collapsible tree sections, so the TreeSection state and the expanded_sections setting go away.

Decisions taken

  • D1 — One panel, two states. Sidebar keeps its identity; the state is chosen by NostrRegistry::current_user() the way Sidebar::render already reads it. No second panel, no dock changes.
  • D2 — Three tabs, icons only, floating. Recents (default), Chats, Communities. Switching tabs only changes the sidebar body; the user header stays fixed at the top.
  • D3 — Tabs replace collapsible sections. TreeSection, the caret toggle, and AppSettings::expanded_sections are deleted. Section headers survive as non-interactive labels inside the tab lists.
  • D4 — "Recent communities" is the only new persisted state. recent_communities: Vec<String> (community ids, newest first) in Settings, following the removed pinned_rooms pattern (9e47882). Cap the stored list at 10, render at most 3.
  • D5 — "Latest chats" needs no new state. ChatRegistry::rooms(&RoomKind::Ongoing, cx) is already ordered by most recent message: Room::push_message advances Room::created_at and ChatRegistry::sort keeps the vector sorted. Take the first 5.
  • D6 — The onboarding sidebar owns identity entry points. Workspace::new stops auto-opening ImportIdentity on StateEvent::NoSigner; the sidebar's Import identity button opens it instead, and Join now gets a new create-identity dialog.
  • D7 — Inbox and Search leave the sidebar. They have no slot in the new IA. Recommended relocation: two entries in the existing user dropdown menu (render_user), which already hosts Profile / Contact List / Backup / Themes / Settings.

1. Current state

Piece Where Today
Panel crates/workspace/src/sidebar/mod.rs Sidebar renders header + 4 nav rows + tree, signed in or out
Rows crates/workspace/src/sidebar/tree.rs TreeRow (Section/Room/Community/Hint), h_8, avatar, click
Sections sidebar/mod.rs TreeSection::{Community, Messages}, caret toggling, persisted in expanded_sections
Communities CommunityRegistry::communities() listed with name() / icon(), no click handler
Chats ChatRegistry::rooms(&RoomKind::Ongoing, cx) listed with avatar, name, created_at.to_ago()
Requests badge ChatEvent::Pingnew_requests dot on the Requests row, cleared when the panel opens
Signed-out state Sidebar::render no dedicated view; Workspace opens the ImportIdentity modal on StateEvent::NoSigner
Recents nothing exists; ordering is registry order / message order
New chat / New community no UI; community creation prior art is commit 0328d35 (removed in 9e47882)
Search / Inbox panels panels/search.rs, panels/inbox.rs placeholders; TreeRow is shared with SearchPanel
Community view does not exist anywhere (grep finds no community panel/view)

Two defects worth folding into the rewrite:

  1. The screening branch in Sidebar::render_rows is dead code: rows only come from rooms(&RoomKind::Ongoing), so kind != RoomKind::Ongoing never holds.
  2. Sidebar does not observe NostrRegistry; it only re-renders when the chat, community, or settings entities notify. The onboarding state needs that subscription (and StateEvent::Busy is declared but never emitted, so there is no "still checking credentials" signal — see Phase 4).

2. Target design

2.1 Signed out — onboarding sidebar

Mirror render_sign_in from the signed client with coop's tokens (cx.theme().surface_background, no sidebar token exists here):

v_flex().size_full().relative().bg(surface_background)
├── drag region: absolute, top_0, h_12, w_full, title_bar_drag_handlers
├── background art: absolute, inset_0, img(..).size_full().object_fit(Cover)
└── v_flex().size_full().justify_end().p_4().mb_4().gap_4()
    ├── brand mark: svg("brand/coop.svg") (size_12)
    ├── headline: "Welcome to Coop!" + tagline
    ├── Button "Join now"         primary, full width, h_8
    └── Button "Import identity"  white/10%, full width, h_8
  • Import identity opens the existing dialogs/import.rs modal (the one Workspace::import_identity opens today).
  • Join now opens a new dialogs/create_identity.rs (see Phase 4).
  • Assets: add assets/backgrounds/banner{1..3}.jpg and #[include = "backgrounds/**/*"] to crates/assets/src/lib.rs, then pick one per launch the way the signed client does (subsec_nanos % 3). If banners are not wanted yet, fall back to a theme-colored background plus the brand mark; no other layout changes.
  • Keep the panel's existing right border and image_cache(retain_all("sidebar")).

2.2 Signed in — shell

v_flex().size_full().relative().bg(surface_background).border_r_1()
├── render_user(window, cx)                      // unchanged, title bar drag
├── tab content: v_flex().flex_1().min_h_0()     // one uniform_list per tab
│   └── pb_12() clearance so the last row clears the floating bar
└── tab bar: absolute, bottom_2, left_0, w_full, px_2

uniform_list stays the list primitive (all rows stay h_8). The tab bar is a sibling of the scrolling content, not a child, so it never scrolls. Give each tab its own UniformListScrollHandle so scroll position survives a tab switch.

The "Getting messages…" pill currently sits at absolute().bottom_2() and would collide with the tab bar; move it above the bar (bottom_16()), or render it as a fixed row at the end of the content column.

2.3 Floating tab bar

div().absolute().bottom_2().left_0().w_full().px_2()
└── h_flex().w_full().p_1().gap_1().rounded(radius_lg)
        .bg(elevated_surface_background).when(shadow, |t| t.shadow_md())
    ├── Button::new("tab-recents").icon(..).ghost().selected(active == Recents)
    ├── Button::new("tab-chats").icon(..).ghost().selected(..)
    └── Button::new("tab-communities").icon(..).ghost().selected(..)
  • Each button is icon-only, flex_1 (wrap in div().flex_1() if the button's built-in flex_shrink_0 fights it), with .tooltip(label) and Selectable::selected(..) (Button::selected already renders ghost_element_selected).
  • Icons: Message (Chats), Group (Communities), and a new History icon for Recents (assets/icons/history.svg + IconName::History; the assets crate already embeds icons/**/*). Inbox is the no-new-asset fallback.
  • Optional: mirror the requests dot on the Chats tab icon (new_requests).
  • Clicking a tab sets active_tab and calls cx.notify(); nothing else.

2.4 Recents tab

One uniform_list; empty state when both sections are empty.

# Row Content Source Click
1 Section Communities + count registry
2 Community ×≤3 avatar + name recent_communities ∩ registry, falling back to registry order when nothing is recorded record recent + open (see D/§9)
3 Action Show all communities switch to Communities tab
4 Section Chats + count registry
5 Room ×≤5 avatar + name + to_ago() first 5 of rooms(&RoomKind::Ongoing) ChatRegistry::emit_room (existing path)
6 Action Show all chats switch to Chats tab

Section counts are registry totals, not the truncated row count. Action rows are TreeRow-shaped (h_8, clickable) so the list stays uniform; a NavItem would break uniform_list's uniform-height assumption.

2.5 Chats tab

Row Kind Action
Contacts NavItem, fixed above the list Command::ShowContactList
Requests NavItem, fixed Command::ShowRequests; keep the new_requests dot and clear-on-click
New chat NavItem, fixed new dialogs/new_chat.rs modal
Chats + count section label, first list row
Room ×all TreeRow ChatRegistry::emit_room

Empty list shows the existing "No conversations yet" hint. Only RoomKind::Ongoing rooms are listed; requests stay in the Requests panel, so the dead screening branch is deleted.

2.6 Communities tab

Row Kind Action
Browse NavItem, fixed Command::ShowBrowse
New community NavItem, fixed new dialogs/new_community.rs modal
Communities + count section label, first list row
Community ×all TreeRow record recent + open (see §9)

Empty list shows the existing "No communities yet" hint.

3. State and data rules

  • Recents store. Settings.recent_communities: Vec<String> (community id hex), newest first, #[serde(default)], accessors via setting_accessors!. A pure helper record_recent(list, id, cap) (in settings, unit-tested) moves an existing id to the front and truncates at 10.
  • Rendering recents. Read the stored list, keep ids present in CommunityRegistry::community(id), take 3. When the stored list is empty or fully stale, fall back to the first 3 communities in registry order so the section is useful on a fresh install.
  • Recording. Only an explicit community click records; "Show all" rows and tab switches do not. Account switches need no invalidation because rendering filters against the current registry; the cap bounds cross-account residue.
  • Latest chats. First 5 of rooms(&RoomKind::Ongoing) (already newest-message-first). No persistence.
  • Tab state. active_tab: SidebarTab lives on Sidebar, default Recents, not persisted.
  • Identity readiness. Sidebar observes NostrRegistry and decides: current_user().is_some() → tabs; else if NostrRegistry::ready() → onboarding; else → an inert sidebar. ready is new (Phase 4) and exists to avoid flashing the onboarding view while the keyring/Nostr-Connect check is still in flight.

4. Implementation plan

Each phase is independently reviewable and leaves the app runnable.

Phase 1 — tab shell — DONE

Files: crates/workspace/src/sidebar/mod.rs, crates/workspace/src/sidebar/tab.rs (new), sidebar/tree.rs, crates/settings/src/lib.rs.

  1. Add SidebarTab { Recents, Chats, Communities } with label(), icon(), list_id(), and index() in sidebar/tab.rs; add a TabBar RenderOnce element implementing §2.3.
  2. Sidebar gains active_tab and one UniformListScrollHandle per tab. Replace tree_rows() with rows_for(tab) and render one uniform_list per tab (ids sidebar-recents|chats|communities).
  3. Move existing content into the tabs: rooms → Chats, communities → Communities; Recents is a hint until Phase 2. Keep TreeRow (used by panels/search.rs); replace the TreeSection enum with plain section labels (SidebarRow::Section { label, count }, no caret, no click).
  4. Delete toggle_section, is_expanded, load_expanded, save_expanded, the expanded_sections setting, and the dead screening branch.
  5. Add Inbox and Search entries to the user dropdown (render_user), per D7.

Validation: app runs signed in and signed out; chats and communities list and open as before; tab switching works; requests dot still clears.

Phase 2 — Recents tab — DONE

Files: crates/settings/src/lib.rs, crates/workspace/src/sidebar/mod.rs, sidebar/tree.rs.

  1. Add recent_communities to Settings + accessors, and the record_recent(..) helper with unit tests.
  2. rows_for(Recents): sections + truncated rows + action rows from §2.4.
  3. Sidebar::open_community(id, ..) records the id (capped) and notifies; wire it to community rows in both Recents and Communities.

Validation: cargo test -p settings; manually open communities, restart, and confirm the Recents order; confirm ≤3 / ≤5 rendering and both "Show all" rows.

Phase 3 — tab actions — DONE

Files: crates/workspace/src/dialogs/new_chat.rs (new), dialogs/new_community.rs (new), crates/workspace/src/dialogs/mod.rs, crates/workspace/src/lib.rs, sidebar/mod.rs.

  1. Command::NewChat / Command::NewCommunity, handled in on_command like the other modal commands.
  2. new_chat.rs: a small view (Input + inline error, modeled on ImportIdentity) that parses an npub and opens a DM: Room::new(current_user, [peer]).kind(RoomKind::Ongoing), then chat.emit_room(&entity, window, cx); Workspace already handles ChatEvent::OpenRoom by docking chat_ui::init(room).
  3. new_community.rs: restore the modal from 0328d35 (name input → confirm → CommunityRegistry::create(CommunityMetadata { name, ..Default::default() }, cx)). Surface CommunityEvent::Error as a notification instead of only logging it.
  4. Wire the Chats/Communities nav rows from §2.52.6.

Validation: create a chat from an npub and confirm the room opens; create a community and confirm it appears in the Communities tab and in Recents; requests/contacts/browse still dispatch.

Phase 4 — onboarding sidebar — DONE, except Join now

Files: crates/state/src/lib.rs, crates/workspace/src/sidebar/mod.rs, sidebar/onboarding.rs (new), crates/workspace/src/dialogs/create_identity.rs (new), crates/workspace/src/lib.rs, crates/assets/src/lib.rs (+ new assets).

  1. NostrRegistry: add ready: bool (false in new), a mark_ready helper called wherever the credential check concludes — get_user_credential's stored-credential and no-credential paths, the wasm NoSigner branch, and set_signer's completion — with cx.notify(); expose pub fn ready().
  2. Sidebar observes NostrRegistry and renders per §3's readiness rule.
  3. sidebar/onboarding.rs renders §2.1. Import identity opens dialogs/import.rs; move Workspace::import_identity's modal construction into a dialogs::import::open(window, cx) helper so both call sites can use it, then delete the StateEvent::NoSigner → import_identity branch and the now-dead Workspace::import_identity method (keep the SignerChanged → close modals arm).
  4. create_identity.rs: generate Keys in the background, show npub + nsec with copy buttons and a "I saved my key" confirmation, then NostrRegistry::set_signer(keys, cx). Recommended: do not write the key to the keyring, matching the existing nsec import behavior (see §9).
  5. Optional asset work from §2.1 (banners).

Validation: with no stored credentials the sidebar shows onboarding and no modal; Import identity still signs in; Join now signs in with a fresh key; with bunker credentials the tabs appear without an onboarding flash.

Deferred. dialogs/create_identity.rs is not implemented, so Join now renders without a click handler, and the §2.1 banner assets were skipped in favor of a plain theme-colored background with the brand mark.

Phase 5 — polish and cleanup — DONE

  • Reposition the "Getting messages…" pill above the tab bar.
  • Empty states and counts for all three tabs; truncation rules (§3).
  • Remove now-unused imports (keep the sidebar retain_all image cache so the onboarding banner is cached), re-run cargo check; update docs/concord-usage.md's sidebar paragraph if the row layout it describes changes.

5. File map

File Change
crates/workspace/src/sidebar/mod.rs tab state, subscriptions, rows_for, readiness gate, user menu additions
crates/workspace/src/sidebar/tab.rs (new) SidebarTab, TabBar
crates/workspace/src/sidebar/onboarding.rs (new) signed-out view
crates/workspace/src/sidebar/tree.rs section label without caret; keep TreeRow for SearchPanel
crates/workspace/src/dialogs/new_chat.rs (new) npub → DM room
crates/workspace/src/dialogs/new_community.rs (new) name → CommunityRegistry::create
crates/workspace/src/dialogs/create_identity.rs (new) Join now key generation + backup
crates/workspace/src/dialogs/import.rs open(window, cx) helper for the onboarding button
crates/workspace/src/lib.rs new commands; drop the auto-opened import modal
crates/state/src/lib.rs NostrRegistry::ready
crates/settings/src/lib.rs recent_communities; drop expanded_sections
crates/assets/src/lib.rs + assets/backgrounds/* banner assets (optional)
crates/ui/src/icon.rs + assets/icons/history.svg Recents tab icon (optional)

6. Edge cases

  • Fewer than 3 communities / 5 chats: no padding rows; sections render with whatever exists.
  • No communities and no chats: single Recents hint.
  • Stale ids in recent_communities (community left, dissolved, or another account): filtered out at render; do not rewrite settings on every render.
  • Empty recent_communities: fall back to registry order (D4/§3).
  • Loading chats: keep the existing pill (repositioned), independent of tabs.
  • macOS: onboarding needs its own title_bar_drag_handlers region and the traffic-light padding the user header uses today.
  • Settings compatibility: dropping expanded_sections is safe (serde ignores the stale key in .settings); recent_communities must be #[serde(default)].
  • Uniform rows: every list row stays h_8; fixed nav rows live outside the uniform_list.

7. Validation

  • cargo check --workspace; cargo test -p settings (new recents helper), cargo test -p community -p chat to confirm no regressions.
  • Manual matrix with cargo run -p coop:
    1. No stored credentials → onboarding, both buttons work, no auto modal.
    2. Bunker credentials → tabs on first frame after load (no flash).
    3. Tabs: switch, scroll, "Show all" rows move to the right tab.
    4. Recents: ≤3 communities / ≤5 chats; order follows recency.
    5. New chat from an npub opens the room; New community appears in both tabs.
    6. Requests dot appears on Ping and clears when Requests opens.
    7. Sign out (proxy failure path) → onboarding returns.
  • GPUI tests, if any are added, must use cx.background_executor().timer(..) rather than smol::Timer, per AGENTS.md.

8. Non-goals

  • A community channel/thread view; until it exists, a community click only records recency (see §9).
  • Redesigning Search, Inbox, Requests, or Contact List panel content.
  • Pinning chats, per-chat unread counts, or in-sidebar chat search.
  • Persisting the active tab.
  • Per-account recents scoping.

9. Open decisions

  1. Community click target. No community view exists, so the handler can only record recency. Options: (a) record-only, documented until the view lands; (b) add a placeholder CommunityPanel (Browse-style) to make the click visible. Recommendation: (a), with open_community as the single hook point for the real view.
  2. Join now persistence. Recommended: show the nsec once, require confirmation, do not write the keyring (matches the existing nsec import warning). Alternative: persist to USER_KEYRING like the bunker path.
  3. Inbox / Search relocation. Recommended: user dropdown (D7). Alternative: a Chats-tab header search icon for Search, inbox folded into Requests.
  4. Recents scope. Global list filtered by the current registry (recommended), or keyed by account public key for strict per-account order.
  5. Recents icon. Add History (two small changes) or reuse Inbox.