Files
coop/docs/sidebar-tree-redesign.md
T
2026-09-18 17:03:17 +07:00

26 KiB

Sidebar tree redesign

Status: steps 1-10 implemented. Search lives in panels/search.rs; the sidebar renders the nav rail, the flattened tree, per-row pin/unpin context menus, and the Community section from placeholder data (TODO(concord)). Pins and expanded sections persist through settings::Settings. cargo check, cargo clippy --workspace --all-targets and rustfmt --check on the changed files are clean. Remaining: the §15 manual QA checklist (needs the running app).

Scope: crates/workspace/src/sidebar (mod.rs, entry.rs, new tree.rs), new panel shells in crates/workspace/src/panels/, and the Command wiring in crates/workspace/src/lib.rs. New icons in crates/ui/src/icon.rs and assets/icons/.

Related: docs/concord-usage.md — Community is placeholder data until a ConcordRegistry exists (that document describes the backend shape; none of it is wired up yet).

1. Goal

Replace the segmented filter (Inbox / Requests) plus flat room list with a collapsible tree, and give the sidebar a nav rail whose items open dock panels.

The sidebar body is always the tree. Search is not part of it: the find input, results, and contacts move to a Search panel (relocation lands here, step 5; the panel is owned separately afterwards).

Target layout:

┌ sidebar ───────────────────────────────┐
│ [avatar] user menu                     │  render_user (unchanged)
│                                        │
│  Inbox                                 │  opens Inbox panel
│  Browse                                │  opens Browse panel
│  Search                                │  opens Search panel
│                                        │
│  ▾ Pinned                     (2)      │  hidden when empty
│     ○ alice                            │
│     ○ team chat                        │
│  ▸ Requests                            │  collapsed by default
│  ▾ Community                           │
│     ○ Coop Contributors                │  1-3 dummy entries
│     ○ Nostr Design                     │
│  ▾ Messages                            │
│     ○ bob                              │  RoomKind::Ongoing
└────────────────────────────────────────┘

2. Current state

Piece Where
Sidebar view, search, filters, room list crates/workspace/src/sidebar/mod.rs
Room row element crates/workspace/src/sidebar/entry.rs (RoomEntry)
Room kinds and lookup crates/chat/src/lib.rs (ChatRegistry::rooms/count/room), crates/chat/src/room.rs (RoomKind::{Request, Ongoing})
User row, dropdown menu Sidebar::render_user (keep as is)
Dock panels crates/workspace/src/panels/ (greeter, profile, contact_list, ...)
Panel opening + commands crates/workspace/src/lib.rs (Command, Workspace::on_command, add_panel_to_dock)
Panel dedupe/focus crates/ui/src/dock/mod.rs (add_panel finds by panel_id and moves/focuses)
Buttons, icons, tooltips crates/ui/src/button.rs, crates/ui/src/icon.rs
Split button / dropdown primitives crates/ui/src/menu/ (DropdownMenu, PopupMenu, PopupMenuItem)
App settings persistence crates/settings/src/lib.rs (Settings, setting_accessors!)

Behavior to preserve:

  • RoomEntry click emits ChatEvent::OpenRoom through ChatRegistry::emit_room, and shows the screening modal for non-ongoing rooms (entry.rs).
  • ChatEvent::Ping sets new_requests = true, drawn as a dot on Requests.
  • The dock-facing Panel/Focusable impls on Sidebar stay untouched.
  • Search behavior is preserved by moving it, not rewriting it (§7).

3. Decisions and assumptions

# Question Decision
1 Nav items Inbox / Browse / Search dispatch new commands (Command::{ShowInbox, ShowBrowse, ShowSearch}); Workspace::on_command opens each panel with DockPlacement::Center. ui::dock::add_panel already focuses an open panel by panel_id instead of duplicating it.
2 Search in the sidebar No find input, no results/contacts sections. The existing search/select implementation moves to panels/search.rs as a mechanical relocation (§7, step 5).
3 Panels in this change Inbox and Browse render empty bodies for now (tab title only); Search gets its body from the search relocation in step 5. Real Inbox/Browse content is follow-up work.
4 Pin storage UI-local Vec<u64> of room ids, in memory first; persistence is step 8 (optional).
5 Pinned rooms in Messages Kept in both places; Pinned is a shortcut, not a move.
6 Row height Uniform h_8 (32px) for every tree row, including RoomEntry (currently h_9). Required by uniform_list, which measures only the first row.
7 Community data 1-3 hardcoded CommunityEntry values with a TODO(concord) pointing at docs/concord-usage.md.
8 Requests default Collapsed; the folder row still shows the unread dot. Expanding clears new_requests.

4. State model

Sidebar keeps only what the tree needs.

/// Collapsible tree sections; declaration order is render order.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
enum TreeSection {
    Pins,
    Requests,
    Community,
    Messages,
}

New fields:

expanded: BTreeSet<TreeSection>,
pinned_rooms: Vec<u64>, // room ids in pin order

Defaults: expanded = {Community, Messages} (Requests intentionally absent; Pins only matters when non-empty and starts expanded).

Both fields persist through settings::Settings:

#[serde(default)] pinned_rooms: Vec<u64>,
#[serde(default)] expanded_sections: Option<Vec<String>>,

expanded_sections is an Option so that an empty list (the user collapsed everything) is distinguishable from the field never having been written, which keeps the {Community, Messages} default. TreeSection::key()/from_key() map the sections to their stable string keys. Because settings load asynchronously, Sidebar observes the settings entity and re-reads both fields in restore_state instead of trusting the constructor's read.

New methods:

fn toggle_section(&mut self, section: TreeSection, cx: &mut Context<Self>);
fn is_expanded(&self, section: TreeSection) -> bool;
fn pin_room(&mut self, room_id: u64, cx: &mut Context<Self>);
fn unpin_room(&mut self, room_id: u64, cx: &mut Context<Self>);
fn is_pinned(&self, room_id: u64) -> bool;
fn restore_state(&mut self, cx: &mut Context<Self>); // step 8
fn tree_rows(&self, cx: &App) -> Vec<SidebarRow>; // see §5

toggle_section(Requests) clears new_requests.

Removed from Sidebar (all search-related, carried to the Search panel in step 5): filter: Entity<RoomKind>, current_filter, set_filter, show_find_panel, find_input, find_debouncer, finding, find_focused, find_results, find_task, has_search, contact_list, selected_pkeys, and methods get_contact_list, set_contact_list, debounced_search, search, set_results, set_finding, set_input_focus, reset, select, is_selected, get_selected, create_room, render_results, render_contacts. Nothing is deleted from the codebase: step 5 moves it into the Search panel.

5. Row model and flattening

The tree body is one uniform_list, so all rows must be the same height (h_8) and the flattened order must be precomputed per frame.

New file crates/workspace/src/sidebar/tree.rs:

/// One rendered tree row, in flattened order.
enum SidebarRow {
    Section { section: TreeSection, count: usize },
    Room { room: Entity<Room>, depth: u8, pinned: bool },
    Community { entry: &'static CommunityEntry, depth: u8 },
    Hint { text: SharedString, depth: u8 },
}

struct CommunityEntry {
    name: &'static str,
    // Rendered as a 20px circle with the first letter; no backend yet.
}

fn dummy_communities() -> &'static [CommunityEntry]; // TODO(concord)

/// Folder/file row. One element for section headers, community rows and hints.
#[derive(IntoElement)]
struct TreeRow { /* id, depth, caret, icon, avatar, label, count, dot, selected, on_click */ }

Sidebar::tree_rows:

// Pins (only when a pinned id resolves to a live room), in pin order
// Requests -> chat.rooms(&RoomKind::Request, cx)
// Community -> dummy_communities()
// Messages -> chat.rooms(&RoomKind::Ongoing, cx)
//
// Each section emits Section first, then children when expanded,
// then a Hint row when expanded and empty.

Render integration:

let rows = Rc::new(self.tree_rows(cx));

uniform_list("sidebar-tree", rows.len(), cx.processor(move |this, range, _window, cx| {
    this.render_rows(range, &rows, cx)
}))
.track_scroll(&self.scroll_handle)

render_rows matches on SidebarRow and builds either a TreeRow (sections, communities, hints) or a RoomEntry (rooms). Element ids: room rows use the flattened index (RoomEntry::new(ix)); non-room rows use ElementId::NamedInteger("tree-row".into(), ix as u64).

Why a flattened list instead of gpui_base::Tree / VirtualList: sections are few and fixed, rows are heterogeneous, and the crate already uses uniform_list + Scrollbar::vertical. The base Tree/VirtualList primitives remain available if variable row heights are ever needed.

6. Rendering spec

6.1 Nav rail

Three full-width Buttons, ghost_alt, small, dispatching actions:

Button::new("nav-inbox")
    .icon(IconName::Inbox)
    .label("Inbox")
    .w_full()
    .justify_start()
    .on_click(|_ev, _window, cx| {
        cx.dispatch_action(&Command::ShowInbox);
    })

Icons: Inbox, Compass (new, Browse), Search. Command is already imported in sidebar/mod.rs, and dispatching actions from a button listener is the existing greeter.rs pattern.

Nav rows carry no selected state: the sidebar does not know which panel the dock is showing. Highlighting the active destination is a follow-up if the dock API exposes it.

6.2 Section (folder) row

  • h_8, pl/pr matching the list padding, rounded(theme.radius), full width, hover ghost_element_hover.
  • Caret: CaretDown when expanded, CaretRight when collapsed.
  • Icon 16px (small()), text_muted.
  • Label: text_xs, font_semibold, text_muted; flex_1.
  • Trailing: count (text_xs, text_placeholder) for Requests/Pins, and the unread dot (size_1().rounded_full().bg(theme.cursor)) when new_requests && section == Requests.
  • Click toggles the section.

6.3 File rows

  • Rooms reuse RoomEntry with .depth(u8) (left padding px(6. + depth * 14.)), wrapped in a ContextMenu that opens the pin/unpin menu; height becomes h_8.
  • Community rows use TreeRow with a 20px element_background circle and the first letter, text_sm label.
  • Indent guide (optional polish): 1px border_variant vertical line at the child indent, drawn by the child row.

6.4 Fixed chrome

render_user unchanged. The loading pill stays positioned as today. The "Create DM" floating button and the screening flow move with search to the Search panel. Only the tree body scrolls.

7. Search leaves the sidebar (hidden, not removed)

Search is now a panel, not a sidebar mode:

  • Command::ShowSearch opens panels/search.rs in the dock center.
  • The current implementation moves there intact — same input, debounce (DebouncedDelay + FIND_DELAY), NostrRegistry::search, contact list, multi-select, create-DM flow, and the RoomEntry selection/screening behavior. The move is mechanical; no search logic is rewritten or dropped.
  • The sidebar renders no input and no results/contacts sections, and keeps no copy of the state (a dormant copy would be dead code).
  • The Search panel is a separate workstream from the tree: it owns the module after the relocation and evolves independently.

8. Pin folder

  • Pin state: pinned_rooms: Vec<u64> in Sidebar, order = pin order.
  • UI: right-clicking a room row opens a ContextMenu (crates/ui/src/menu/dropdown_menu.rs) with Pin / Unpin (PopupMenuItem::new(...).on_click(...)). The menu is a PopupMenu anchored to the row and opened with MouseButton::Right, reusing the cached-menu machinery shared with DropdownMenuPopover.
  • The row keeps its own left-click handler: GPUI fires on_click only for the left button, and the popover's right-button handler calls cx.stop_propagation(), so pinning never opens the room. RoomEntry no longer carries a trailing slot or a group name for hover-revealed chrome.
  • Pinned folder is hidden when no pinned room resolves to a live room; otherwise expanded by default, showing pinned rooms in pin order.
  • A pinned room remains listed under Messages.

9. Requests

  • Folder always rendered, collapsed by default (expanded does not contain Requests).
  • Count badge = chat.count(&RoomKind::Request, cx).
  • Expanding the folder clears new_requests.
  • Children are the same RoomKind::Request rooms the old Requests filter showed, with the same RoomEntry screening behavior.

10. Community (dummy data)

  • dummy_communities() returns 2 entries for now (Coop Contributors, Nostr Design) so the folder has content; 1-3 is the range the sketch asks for.
  • Rows do not navigate anywhere yet; clicking is a no-op. Add // TODO(concord): replace with ConcordRegistry communities, see docs/concord-usage.md.
  • Folder expanded by default.

11. Messages

  • RoomKind::Ongoing rooms, using the existing render_list_items logic (display name/avatar/member pubkey/kind/created_at, emit_room on click).
  • Expanded by default.
  • When empty and expanded, show a Hint row ("No conversations yet") instead of the current large dashed card; the card is removed.

12. Implementation steps

Steps 1-4 are additive and compile on their own. Step 5 is one atomic change set: the search relocation and the sidebar render rewrite depend on each other, because removing the search fields breaks the old render and rewriting the render orphans the search code. Helpers added in earlier steps may warn as unused until step 5 consumes them. Run the checks in §15 after each step.

  • Step 1 — icons. Add assets/icons/folder.svg, compass.svg, message.svg (24x24 viewBox, stroke="currentColor", stroke-width="1.5", matching existing files); add Folder, Compass, Message variants to IconName and its path() match in crates/ui/src/icon.rs.

  • Step 2 — tree primitives. Add crates/workspace/src/sidebar/tree.rs with TreeSection, SidebarRow, CommunityEntry, dummy_communities(), and the TreeRow element; declare mod tree; in sidebar/mod.rs.

  • Step 3 — RoomEntry. Add .depth(u8) and .trailing(AnyElement); change h_9 to h_8.

  • Step 4 — panel openers. Add Command::{ShowInbox, ShowBrowse, ShowSearch} and panels/{inbox,browse,search}.rs shells (init, Panel, Focusable, EventEmitter<PanelEvent>, empty Render, following greeter.rs); register them in panels/mod.rs; handle the commands in Workspace::on_command with add_panel_to_dock(..., DockPlacement::Center, ...). All three render empty bodies for now; the Search body is filled in step 5.

  • Step 5 — relocation + render rewrite (atomic, separate workstream handoff). Move the search/select implementation out of Sidebar into panels/search.rs (inventory in §7), wiring the input, results, contacts, selection, and create-DM button exactly as they are today; at the same time rewrite the sidebar render (nav rail dispatching the three commands, flattened tree list, scrollbar, render_user, loading pill), add expanded/pinned_rooms/tree_rows, and delete filter, current_filter, set_filter, and the sidebar's search state. The search workstream owns the relocated module afterwards. Done: SearchPanel owns the input, debounce, results, contacts, selection and create-DM flow; Sidebar owns expanded/pinned_rooms and flattens the four sections into one uniform_list("sidebar-tree"). has_search, find_focused, set_input_focus were dropped because they only existed to switch the sidebar between the room list and the search view.

  • Step 6 — pin UI. Each room row is wrapped in a ContextMenu (ui::menu::ContextMenu, added in this step) that opens a PopupMenu with Pin / Unpin on right-click; the handlers call pin_room/unpin_room through a WeakEntity<Sidebar>. ContextMenu reuses the cached-menu logic extracted from DropdownMenuPopover and opens through Popover::trigger_with, so the trigger keeps its own click handler and no Selectable state is forced onto the row. Left-click still opens the room, because GPUI fires on_click only for the left button while the popover handles the right one. (The first cut used a hover ellipsis in a RoomEntry::trailing slot; that was removed once the context menu existed.)

  • Step 7 — community section. Dummy entries and the empty-state hint are rendered; the TODO(concord) marker sits on dummy_communities(). The flattening and rendering landed with step 5 (SidebarRow::Community -> TreeRow), so this step added the missing hint branch and confirmed the §10 placeholder names.

  • Step 8 — persistence. settings::Settings gained #[serde(default)] pinned_rooms: Vec<u64> and #[serde(default)] expanded_sections: Option<Vec<String>>, both registered in setting_accessors! (so AppSettings::get_*/update_* exist). The #[serde(default)] attribute is required: Settings has no serde defaults, so a new field without it breaks parsing of existing .settings files. Sidebar::new loads both (falling back to the default sections when the setting is None), and toggle_section/pin_room/unpin_room write back through AppSettings::update_*; the settings observer already saves on every change, so no explicit file I/O was added. expanded_sections is Option so that collapsing every folder does not silently revert to the default on restart. Stale pinned ids are still skipped at flatten time rather than pruned on load.

    Settings load asynchronously (a deferred, background file read), so the constructor's read always sees defaults on a cold start. To pick up the loaded values, AppSettings::entity() now exposes the inner Entity<Settings> (it notifies on every field change) and Sidebar observes it, re-reading through restore_state and re-rendering only when the values actually differ. Without this the sidebar would render with empty pins until the next unrelated change. The observation is on the inner entity because AppSettings itself never notifies its own observers.

  • Step 9 — cleanup. Removed TreeRow::selected (the field, the builder method, and the ghost_element_selected render branch) — it was the only dead code left after step 5. No other unused imports or helpers remained. cargo clippy --workspace --all-targets reports zero warnings. Formatting is checked per file with rustfmt +nightly --check; cargo fmt --all is not run, because the repo's committed formatting does not match the installed nightly rustfmt (many pre-existing diffs in unrelated files).

  • Step 10 — nav item element. Extracted the rail rows into ui::nav_item::NavItem (crates/ui/src/nav_item.rs), ported from the signed_ui reference and adapted to this repo (Rc<dyn Fn> handlers, ghost_element_hover, StyledExt::refine_style, no gpui_component dependency). The sidebar builds the three rail rows directly with it and the local nav_item(...) -> Button helper is gone.

  • Step 11 — pixel avatars. Entities without a picture used to fall back to the generic brand/avatar.png (and brand/group.png for groups), and the community rows drew a first-letter circle. Both are replaced by a deterministic pixel avatar ported from the signed_ui pixel_avatar.rs reference and added to ui::avatar (crates/ui/src/avatar.rs) as PixelAvatar: an 8x8 mirrored grid seeded by an FNV-1a hash of a stable string, with the hue offset from theme().icon_accent and fixed saturation/lightness per appearance so patterns stay readable in both modes and distinguishable between seeds. The cells are painted as path geometry in a canvas and cropped to a circle with Sutherland-Hodgman clipping: GPUI clips an overflowing child to its bounding box and never to a corner radius, so a rounded container cannot crop a grid into a circle, while paths are rasterized with MSAA, so the crop is anti-aliased and the avatar is a true circle rather than a stair-stepped disc. It sizes through the shared avatar_size, so it matches Avatar at every size, including the default, and is adapted to this repo like step 10 (no gpui_component, crate::Sizable/Size, StyledExt::refine_style). Avatar::new now takes Option<SharedString> (the picture) plus .seed(...), and renders the generated avatar both when the picture is absent and when it fails to load; Person::avatar() and Room::display_image() return Option, with the new Person::avatar_seed() and Room::display_image_seed() supplying the seed (public key for a person or DM, room id for a group). RoomEntry takes the picture plus a seed, and TreeRow's letter circle became a PixelAvatar seeded by the row's name. Every avatar call site passes a seed: chat (chat_ui), device, screening, contact list, profile, search, and the sidebar.

13. Files touched

File Change
crates/workspace/src/sidebar/mod.rs State, flattening, render rewrite; search code moves out
crates/workspace/src/sidebar/tree.rs New: sections, rows, TreeRow, dummy data
crates/workspace/src/sidebar/entry.rs depth, height
crates/workspace/src/panels/{inbox,browse,search}.rs New panel modules
crates/workspace/src/panels/mod.rs Module registration
crates/workspace/src/lib.rs Command variants + on_command arms
crates/ui/src/icon.rs New icon variants
assets/icons/{folder,compass,message}.svg New assets
crates/settings/src/lib.rs Step 8: pinned_rooms, expanded_sections, accessors, entity()
crates/ui/src/nav_item.rs Step 10: NavItem element, new
crates/ui/src/avatar.rs Step 11: PixelAvatar; Avatar takes a picture plus a seed
crates/person/src/person.rs Step 11: avatar() returns Option, new avatar_seed()
crates/chat/src/room.rs Step 11: display_image() returns Option, new display_image_seed()
crates/workspace/src/{sidebar,panels,dialogs}/**.rs Step 11: room rows, community rows, and person avatars pass seeds
crates/ui/src/menu/dropdown_menu.rs Step 6: ContextMenu + cached-menu helper shared with DropdownMenuPopover
crates/ui/src/popover.rs Step 6: Popover::trigger_with for triggers without a selected state

14. Edge cases

  • Uniform height. uniform_list measures the first row and reuses that height; every row must be h_8. If a section row ever needs a different height, switch to gpui_base::VirtualList instead of mixing.
  • Panel dedupe. Clicking a nav item whose panel is already open focuses and moves it (ui::dock::add_panel looks up panel_id); no duplicate tabs.
  • Stale pins. A pinned id whose room is gone is skipped at flatten time (and pruned on the next pin/unpin).
  • Empty sections. Expanded + empty renders a Hint row; collapsed sections render nothing.
  • Logged out. NostrRegistry::current_user() is None: render_user keeps its import-identity prompt; sections resolve to empty and show hints.
  • Loading. Sections may be empty; the loading pill stays.
  • New requests while collapsed. The dot shows on the collapsed Requests folder; expanding clears it.
  • Image cache. Keep retain_all("sidebar") on the root.
  • Element ids. Flattened index for room rows, NamedInteger for others, so expansion/collapse does not smuggle state between rows.

15. Validation

  • rustfmt +nightly --check on the changed files (not cargo fmt --all: the repo's committed formatting does not match the installed nightly rustfmt, so a workspace-wide check reports many pre-existing diffs).
  • cargo check --workspace and cargo clippy --workspace --all-targets.
  • Manual QA checklist:
    • Inbox/Browse/Search each open their panel; clicking the same nav item again focuses the existing panel instead of duplicating it;
    • the sidebar has no search input and no results/contacts sections;
    • the Search panel keeps the old behavior (debounced search, contacts, multi-select, create DM, Enter to search);
    • each folder toggles and keeps its state across re-renders and room updates;
    • Requests starts collapsed; the dot appears on ChatEvent::Ping and clears when expanded;
    • pin/unpin from the row context menu (right-click) updates the Pinned folder without opening the room; clicking a pinned row opens it;
    • Messages lists ongoing rooms and still opens the screening modal for non-ongoing rooms;
    • pins and expanded/collapsed folders survive an app restart (collapsing every folder also survives, rather than reverting to the default sections);
    • empty states at 0 ongoing and 0 requests;
    • profiles, DMs, groups, and community rows without a picture show a generated pixel avatar, which is stable across restarts and matches wherever the same identity appears; a picture that fails to load falls back to it as well.
  • There is no GPUI test infrastructure in the repo (no #[gpui::test] anywhere), so tests are limited to pure helpers (TreeSection defaults, pin ordering) if they are extracted as free functions; cargo check plus the manual checklist is the baseline.

16. Open questions

  1. Persistence. Resolved in step 8: pins and expanded sections persist in settings::Settings.
  2. Row density. h_8 vs the current h_9; SIDEBAR_WIDTH stays 240px for now, one indent level fits.
  3. Community entries. Preferred dummy names/branding before the real registry lands.

17. Out of scope / follow-ups

  • Inbox/Browse panel content (empty bodies in this change).
  • Search panel development beyond the relocation; any search UI changes happen in that workstream.
  • Real Concord integration (ConcordRegistry, subscriptions, member lists) — tracked in docs/concord-usage.md.
  • Drag-to-reorder pins, pin folders/groups beyond the single Pinned folder.
  • Unread counts per room, nav-item active highlighting.
  • Variable-height rows or nested subfolders.