20 KiB
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 thesignedclient'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.
Sidebarkeeps its identity; the state is chosen byNostrRegistry::current_user()the waySidebar::renderalready 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, andAppSettings::expanded_sectionsare 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) inSettings, following the removedpinned_roomspattern (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_messageadvancesRoom::created_atandChatRegistry::sortkeeps the vector sorted. Take the first 5. - D6 — The onboarding sidebar owns identity entry points.
Workspace::newstops auto-openingImportIdentityonStateEvent::NoSigner; the sidebar'sImport identitybutton opens it instead, andJoin nowgets 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::Ping → new_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:
- The
screeningbranch inSidebar::render_rowsis dead code: rows only come fromrooms(&RoomKind::Ongoing), sokind != RoomKind::Ongoingnever holds. Sidebardoes not observeNostrRegistry; it only re-renders when the chat, community, or settings entities notify. The onboarding state needs that subscription (andStateEvent::Busyis 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 identityopens the existingdialogs/import.rsmodal (the oneWorkspace::import_identityopens today).Join nowopens a newdialogs/create_identity.rs(see Phase 4).- Assets: add
assets/backgrounds/banner{1..3}.jpgand#[include = "backgrounds/**/*"]tocrates/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 indiv().flex_1()if the button's built-inflex_shrink_0fights it), with.tooltip(label)andSelectable::selected(..)(Button::selectedalready rendersghost_element_selected). - Icons:
Message(Chats),Group(Communities), and a newHistoryicon for Recents (assets/icons/history.svg+IconName::History; the assets crate already embedsicons/**/*).Inboxis the no-new-asset fallback. - Optional: mirror the requests dot on the Chats tab icon (
new_requests). - Clicking a tab sets
active_taband callscx.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 viasetting_accessors!. A pure helperrecord_recent(list, id, cap)(insettings, 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: SidebarTablives onSidebar, default Recents, not persisted. - Identity readiness.
SidebarobservesNostrRegistryand decides:current_user().is_some()→ tabs; else ifNostrRegistry::ready()→ onboarding; else → an inert sidebar.readyis 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
Files: crates/workspace/src/sidebar/mod.rs,
crates/workspace/src/sidebar/tab.rs (new), sidebar/tree.rs,
crates/settings/src/lib.rs.
- Add
SidebarTab { Recents, Chats, Communities }withlabel(),icon(),list_id(), andindex()insidebar/tab.rs; add aTabBarRenderOnceelement implementing §2.3. Sidebargainsactive_taband oneUniformListScrollHandleper tab. Replacetree_rows()withrows_for(tab)and render oneuniform_listper tab (idssidebar-recents|chats|communities).- Move existing content into the tabs: rooms → Chats, communities →
Communities; Recents is a hint until Phase 2. Keep
TreeRow(used bypanels/search.rs); replace theTreeSectionenum with plain section labels (SidebarRow::Section { label, count }, no caret, no click). - Delete
toggle_section,is_expanded,load_expanded,save_expanded, theexpanded_sectionssetting, and the dead screening branch. - 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
Files: crates/settings/src/lib.rs, crates/workspace/src/sidebar/mod.rs,
sidebar/tree.rs.
- Add
recent_communitiestoSettings+ accessors, and therecord_recent(..)helper with unit tests. rows_for(Recents): sections + truncated rows + action rows from §2.4.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
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.
Command::NewChat/Command::NewCommunity, handled inon_commandlike the other modal commands.new_chat.rs: a small view (Input + inline error, modeled onImportIdentity) that parses an npub and opens a DM:Room::new(current_user, [peer]).kind(RoomKind::Ongoing), thenchat.emit_room(&entity, window, cx);Workspacealready handlesChatEvent::OpenRoomby dockingchat_ui::init(room).new_community.rs: restore the modal from0328d35(name input → confirm →CommunityRegistry::create(CommunityMetadata { name, ..Default::default() }, cx)). SurfaceCommunityEvent::Erroras a notification instead of only logging it.- Wire the Chats/Communities nav rows from §2.5–2.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
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).
NostrRegistry: addready: bool(false innew), amark_readyhelper called wherever the credential check concludes —get_user_credential's stored-credential and no-credential paths, the wasmNoSignerbranch, andset_signer's completion — withcx.notify(); exposepub fn ready().SidebarobservesNostrRegistryand renders per §3's readiness rule.sidebar/onboarding.rsrenders §2.1.Import identityopensdialogs/import.rs; moveWorkspace::import_identity's modal construction into adialogs::import::open(window, cx)helper so both call sites can use it, then delete theStateEvent::NoSigner → import_identitybranch and the now-deadWorkspace::import_identitymethod (keep theSignerChanged → close modalsarm).create_identity.rs: generateKeysin the background, show npub + nsec with copy buttons and a "I saved my key" confirmation, thenNostrRegistry::set_signer(keys, cx). Recommended: do not write the key to the keyring, matching the existing nsec import behavior (see §9).- 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.
Phase 5 — polish and cleanup
- 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_allimage cache so the onboarding banner is cached), re-runcargo check; updatedocs/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_handlersregion and the traffic-light padding the user header uses today. - Settings compatibility: dropping
expanded_sectionsis safe (serde ignores the stale key in.settings);recent_communitiesmust be#[serde(default)]. - Uniform rows: every list row stays
h_8; fixed nav rows live outside theuniform_list.
7. Validation
cargo check --workspace;cargo test -p settings(new recents helper),cargo test -p community -p chatto confirm no regressions.- Manual matrix with
cargo run -p coop:- No stored credentials → onboarding, both buttons work, no auto modal.
- Bunker credentials → tabs on first frame after load (no flash).
- Tabs: switch, scroll, "Show all" rows move to the right tab.
- Recents: ≤3 communities / ≤5 chats; order follows recency.
- New chat from an npub opens the room; New community appears in both tabs.
- Requests dot appears on Ping and clears when Requests opens.
- Sign out (proxy failure path) → onboarding returns.
- GPUI tests, if any are added, must use
cx.background_executor().timer(..)rather thansmol::Timer, perAGENTS.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
- 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), withopen_communityas the single hook point for the real view. - 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_KEYRINGlike the bunker path. - Inbox / Search relocation. Recommended: user dropdown (D7). Alternative: a Chats-tab header search icon for Search, inbox folded into Requests.
- Recents scope. Global list filtered by the current registry (recommended), or keyed by account public key for strict per-account order.
- Recents icon. Add
History(two small changes) or reuseInbox.