From 7273953a1965cdeacac0c31546442dbef80df30e Mon Sep 17 00:00:00 2001 From: Vincent Bernardoff Date: Sat, 17 Oct 2026 18:50:28 +0200 Subject: [PATCH] feat: keep Discord overview beside server channels Show the Discord home beside the selected server on wide windows. Keep its list state and updates alive, and return to the home when opening a DM or message requests. Account for the column in resizing, menus and conversation widths, with a narrow-window fallback and design docs. Co-Authored-By: GPT-6 --- crates/tinyclient/src/gui/layout.rs | 42 +++++++++++++++--- crates/tinyclient/src/gui/sidebar.rs | 43 ++++++++++++++++--- .../tinyclient/src/gui/sidebar/home_view.rs | 26 +++++++---- crates/tinyclient/src/gui/state.rs | 2 +- docs/design/polish/02-discord-slack.md | 18 ++++++++ 5 files changed, 109 insertions(+), 22 deletions(-) diff --git a/crates/tinyclient/src/gui/layout.rs b/crates/tinyclient/src/gui/layout.rs index fb5a368..53b9e7b 100644 --- a/crates/tinyclient/src/gui/layout.rs +++ b/crates/tinyclient/src/gui/layout.rs @@ -164,6 +164,16 @@ pub fn plan(window: f32, sidebar: f32, hidden: bool, right: Option) -> Plan Plan { sidebar, right } } +/// Add the Discord parent column only when both lists and the conversation fit. +fn discord_overview_width(window: f32, sidebar: f32, hidden: bool, right: Option, server: bool) -> f32 { + const OVERVIEW: f32 = 280.; + if server && !hidden && window >= RAIL_WIDTH + sidebar + OVERVIEW + CONVERSATION_MIN + right.unwrap_or(0.) { + OVERVIEW + } else { + 0. + } +} + /// The right pane would replace the conversation here rather than sit beside it: Details /// hides then, and a thread or search stands in for the conversation (07 §4). pub fn narrow(window: f32, sidebar: f32, hidden: bool, right: f32) -> bool { @@ -337,9 +347,10 @@ impl Shell { return; } let x = f32::from(event.position.x); + let overview = discord_overview_width(f32::from(window.viewport_size().width), self.layout_state.sidebar_width, self.layout_state.sidebar_hidden, self.layout_state.right.as_ref().filter(|_| !self.pane_tucked()).map(|_| self.layout_state.right_width), matches!(&self.sidebar_state.mode, super::sidebar::Mode::Space(space) if space.network == Network::Discord)); let state = &mut self.layout_state; match edge { - Edge::Sidebar => state.sidebar_width = (x - RAIL_WIDTH).round().clamp(SIDEBAR_RANGE.0, SIDEBAR_RANGE.1), + Edge::Sidebar => state.sidebar_width = (x - RAIL_WIDTH - overview).round().clamp(SIDEBAR_RANGE.0, SIDEBAR_RANGE.1), Edge::Right => { let window = f32::from(window.viewport_size().width); state.right_width = (window - x).round().clamp(RIGHT_RANGE.0, RIGHT_RANGE.1); @@ -581,12 +592,14 @@ impl Shell { } /// The open menu, over everything, with a catcher behind it that closes it. - fn menu_layer(&self, snapshot: &Snapshot, sidebar: f32, window: &mut Window, cx: &mut Context) -> Option
{ + fn menu_layer(&self, snapshot: &Snapshot, sidebar: f32, overview: f32, window: &mut Window, cx: &mut Context) -> Option
{ let open = self.layout_state.menu.as_ref()?; let tall = f32::from(window.viewport_size().height); // The rail's add button and own avatar, and the sidebar strip's trailing button. let rail_button = |from_bottom: f32| place::anchor(16., tall - from_bottom - 40., 40., 40.); - let strip_button = place::anchor(RAIL_WIDTH + sidebar - 12. - 28., 12., 28., 28.); + let strip_left = RAIL_WIDTH + if matches!(open, Menu::Space(_)) { overview } else { 0. }; + let strip_width = if overview > 0. && matches!(open, Menu::Network(_)) { overview } else { sidebar }; + let strip_button = place::anchor(strip_left + strip_width - 12. - 28., 12., 28., 28.); let placed: AnyElement = match open { Menu::Add => { let content = fade_in("menu-add", 120, self.add_menu(snapshot, cx)); @@ -825,8 +838,9 @@ impl Render for Shell { // Too narrow for the pane beside the conversation: sticky Details hides (07 §4). self.note_room(narrow(width, state.sidebar_width, state.sidebar_hidden, state.right_width), None); let right_width = state.right.as_ref().filter(|_| !self.pane_tucked()).map(|_| state.right_width); - let plan = plan(width, state.sidebar_width, state.sidebar_hidden, right_width); - self.note_room(self.pane_narrow(), Some(conversation_width(width, &plan))); + let overview = discord_overview_width(width, state.sidebar_width, state.sidebar_hidden, right_width, matches!(&self.sidebar_state.mode, super::sidebar::Mode::Space(space) if space.network == Network::Discord)); + let plan = plan(width - overview, state.sidebar_width, state.sidebar_hidden, right_width); + self.note_room(self.pane_narrow(), Some(conversation_width(width - overview, &plan))); let floating = plan.sidebar.is_none() && state.floating; let replaced = plan.right.is_some_and(|(_, replaces)| replaces); // With nothing open in a window too narrow for the sidebar, the list is the main view: @@ -835,6 +849,7 @@ impl Render for Shell { let _ = tall; root.child(self.rail(snapshot, window, cx)) + .when(overview > 0., |root| root.child(self.discord_overview(snapshot, overview, window, cx))) .children(plan.sidebar.map(|width| { self.sidebar(snapshot, width, window, cx).child(self.resize_handle(Edge::Sidebar, cx)) })) @@ -874,7 +889,7 @@ impl Render for Shell { ) }) .children(self.switcher(window, cx)) - .children(self.menu_layer(snapshot, plan.sidebar.unwrap_or(state.sidebar_width), window, cx)) + .children(self.menu_layer(snapshot, plan.sidebar.unwrap_or(state.sidebar_width), overview, window, cx)) .children(self.toast_layer(width, cx)) } } @@ -911,6 +926,21 @@ impl Shell { mod tests { use super::*; + #[test] + fn discord_columns_keep_a_readable_conversation_and_yield_to_narrow_windows() { + let minimum = RAIL_WIDTH + SIDEBAR_WIDTH + 280. + CONVERSATION_MIN; + assert_eq!(discord_overview_width(minimum, SIDEBAR_WIDTH, false, None, true), 280.); + assert_eq!(discord_overview_width(minimum - 1., SIDEBAR_WIDTH, false, None, true), 0.); + assert_eq!(discord_overview_width(1800., SIDEBAR_WIDTH, true, None, true), 0.); + assert_eq!(discord_overview_width(1800., SIDEBAR_WIDTH, false, None, false), 0.); + assert_eq!(discord_overview_width(minimum, SIDEBAR_WIDTH, false, Some(RIGHT_WIDTH), true), 0.); + let window = minimum + RIGHT_WIDTH; + let overview = discord_overview_width(window, SIDEBAR_WIDTH, false, Some(RIGHT_WIDTH), true); + let planned = plan(window - overview, SIDEBAR_WIDTH, false, Some(RIGHT_WIDTH)); + assert_eq!(planned.right, Some((RIGHT_WIDTH, false))); + assert_eq!(conversation_width(window - overview, &planned), CONVERSATION_MIN); + } + #[test] fn the_conversation_gives_up_width_then_the_right_pane_replaces_it_then_the_sidebar_leaves() { // Room for everything. diff --git a/crates/tinyclient/src/gui/sidebar.rs b/crates/tinyclient/src/gui/sidebar.rs index 49376ff..33090a7 100644 --- a/crates/tinyclient/src/gui/sidebar.rs +++ b/crates/tinyclient/src/gui/sidebar.rs @@ -302,7 +302,9 @@ impl Shell { } let state = &self.sidebar_state; state.inbox_list.scroll_to(gpui::ListOffset::default()); - state.home_list.scroll_to(gpui::ListOffset::default()); + if !matches!(&state.mode, Mode::Space(space) if space.network == Network::Discord) { + state.home_list.scroll_to(gpui::ListOffset::default()); + } state.tree_scroll.scroll_to_item(0, ScrollStrategy::Top); cx.notify(); return; @@ -320,13 +322,20 @@ impl Shell { state.inbox.fill(epoch, fresh, total); } } - let home_network = mode.network().filter(|_| matches!(mode, Mode::Network(_))); + let keep_discord = state.mode.network() == Some(Network::Discord) && mode.network() == Some(Network::Discord); + let home_network = match &mode { + Mode::Space(space) if space.network == Network::Discord => Some(Network::Discord), + Mode::Network(network) => Some(*network), + _ => None, + }; state.home.switch_network(home_network, &mut state.saved_homes); state.mode = mode; state.cursor = None; state.drill = None; state.slack_menu = false; - state.home_unread = false; + if !keep_discord { + state.home_unread = false; + } state.apps_open = false; state.apps_all = false; state.pill = 0; @@ -680,6 +689,9 @@ impl Shell { /// Opens a row the user chose by hand: the unread walk starts over from there. fn pick_row(&mut self, row: &Conversation, cx: &mut Context) { self.sidebar_state.walked.clear(); + if row.key.network == Network::Discord && row.kind.is_direct() && matches!(&self.sidebar_state.mode, Mode::Space(space) if space.network == Network::Discord) { + self.go(Mode::Network(Network::Discord), cx); + } if row.kind == ConversationKind::Forum && row.key.network == Network::Telegram { self.drill(home::Drill::Topics(Rc::new(row.clone())), cx); return; @@ -842,6 +854,10 @@ impl Shell { /// The people whose presence these rows draw, and the rows themselves for their previews. fn watch_rows<'a>(&self, rows: impl Iterator> + Clone, cx: &mut Context) { let hub = self.people_state.hub(); + let home = &self.sidebar_state.home_items; + let visible = on_screen(&self.sidebar_state.home_list, home.len(), |ix| home[ix].height()); + let extra = home[visible].iter().filter_map(home::Item::conversation).filter(|_| matches!(&self.sidebar_state.mode, Mode::Space(space) if space.network == Network::Discord)); + let rows = rows.cloned().chain(extra.cloned()); let peers = rows.clone().filter_map(|c| Some(Key { id: c.peer.clone()?, ..c.key.clone() })); hub.watch(WATCH, peers, cx); hub.watch_rows(WATCH, rows.map(|c| c.key.clone()), cx); @@ -1048,7 +1064,7 @@ impl Shell { out } - fn title_strip(&self, cx: &mut Context) -> Div { + fn title_strip(&self, mode: &Mode, cx: &mut Context) -> Div { let strip = drags_window(div()).h(px(theme::STRIP)).flex_none().pl(px(theme::PANE_PAD)).pr(px(12.)).flex().items_center().gap(px(8.)); let quiet = |button: Stateful
| button.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()); let back = |id: &'static str, words: String, cx: &mut Context| { @@ -1073,7 +1089,7 @@ impl Shell { .child(pane_title(title)) .children(place.map(|place| div().flex_none().max_w(px(120.)).truncate().type_small().text_color(theme::text_faint()).child(place))); } - match &self.sidebar_state.mode { + match mode { Mode::Inbox => { let filter = self.sidebar_state.filter; let counts = self.sidebar_state.counts; @@ -1333,9 +1349,22 @@ impl Shell { } } + /// The persistent Discord parent column, using the same home list and scroll state. + pub(super) fn discord_overview(&self, snapshot: &Snapshot, width: f32, window: &mut Window, cx: &mut Context) -> Div { + self.sidebar_state.width.set(width); + div().w(px(width)).h_full().flex_none().bg(theme::sidebar()).border_r_1().border_color(theme::hairline()).child( + div().id("discord-overview").size_full().flex().flex_col() + .child(self.title_strip(&Mode::Network(Network::Discord), cx)) + .child(self.jump_strip(cx)) + .child(div().flex_1().min_h_0().child(self.home_list(snapshot, window, cx))) + ) + } + pub(super) fn sidebar(&self, snapshot: &Snapshot, width: f32, window: &mut Window, cx: &mut Context) -> Div { let people = self.sidebar_state.mode == Mode::People; - self.sidebar_state.width.set(width); + if !matches!(&self.sidebar_state.mode, Mode::Space(space) if space.network == Network::Discord) { + self.sidebar_state.width.set(width); + } let body = if people { let drawn = self.slots.people.and_then(|draw| draw(self, window, cx)); drawn.unwrap_or_else(|| empty_state("icons/users.svg", "People", "Friends and contacts show up here.").px(px(24.)).into_any_element()) @@ -1354,7 +1383,7 @@ impl Shell { .bg(theme::sidebar()) .border_r_1() .border_color(theme::hairline()) - .child(self.title_strip(cx)) + .child(self.title_strip(&self.sidebar_state.mode, cx)) .when(!people, |sidebar| sidebar.child(self.jump_strip(cx))) .children(tabs) .when(!banners.is_empty(), |sidebar| sidebar.child(div().flex_none().px(px(theme::INSET)).pb(px(8.)).flex().flex_col().gap(px(4.)).children(banners))) diff --git a/crates/tinyclient/src/gui/sidebar/home_view.rs b/crates/tinyclient/src/gui/sidebar/home_view.rs index 02f21b0..1e303e1 100644 --- a/crates/tinyclient/src/gui/sidebar/home_view.rs +++ b/crates/tinyclient/src/gui/sidebar/home_view.rs @@ -52,8 +52,10 @@ fn whole(network: Network, folder: Option, shelf: Shelf) -> std::io::Res impl Shell { /// Reads what the home on show draws. Asking is also what makes chatd bring it up to date. pub(in crate::gui) fn load_home(&mut self, cx: &mut Context) { - let Mode::Network(network) = self.sidebar_state.mode else { - return; + let network = match &self.sidebar_state.mode { + Mode::Network(network) => *network, + Mode::Space(space) if space.network == Network::Discord => Network::Discord, + _ => return, }; // Keys only: the read runs off this thread. let drill = self.sidebar_state.drill.as_ref().map(|drill| match drill { @@ -156,9 +158,12 @@ impl Shell { let state = &self.sidebar_state; let items: Vec = match (&state.mode, &state.drill) { (Mode::Network(_), Some(_)) => state.home.drilled.iter().cloned().map(Item::Chat).collect(), - (Mode::Network(Network::Discord), None) => { + (Mode::Network(Network::Discord), None) | (Mode::Space(Key { network: Network::Discord, .. }), None) => { let spaces: Vec = self.spaces.iter().filter(|s| s.key.network == Network::Discord).cloned().collect(); - let open_space = self.snapshot.as_ref().and_then(|s| s.open.as_ref()).and_then(|open| open.space.clone()); + let open_space = match &state.mode { + Mode::Space(space) => Some(space.id.clone()), + _ => self.snapshot.as_ref().and_then(|s| s.open.as_ref()).and_then(|open| open.space.clone()), + }; let items = home::discord(&state.home, &spaces, open_space.as_deref(), state.home_unread); // The servers' indexes are into the shell's whole list of Spaces. items @@ -182,7 +187,7 @@ impl Shell { } _ => Vec::new(), }; - let news = if matches!(self.sidebar_state.mode, Mode::Network(Network::Discord)) && self.sidebar_state.drill.is_none() { + let news = if self.sidebar_state.mode.network() == Some(Network::Discord) && self.sidebar_state.drill.is_none() { let state = &self.sidebar_state; let spaces: Vec = self.spaces.iter().filter(|s| s.key.network == Network::Discord).cloned().collect(); home::discord(&state.home, &spaces, None, true).iter().filter(|item| matches!(item, Item::Chat(_) | Item::Space { .. } | Item::Folder { .. })).count() as u32 @@ -212,7 +217,12 @@ impl Shell { self.people_scope(Some(Network::Discord), cx); return; } - Act::Requests => return self.drill(Drill::Requests, cx), + Act::Requests => { + if matches!(self.sidebar_state.mode, Mode::Space(_)) { + self.go(Mode::Network(Network::Discord), cx); + } + return self.drill(Drill::Requests, cx); + }, Act::Archive => return self.drill(Drill::Archive, cx), Act::MoreDms => home.dms_all = true, Act::MoreVoice => home.voice_all = true, @@ -249,7 +259,7 @@ impl Shell { let Some(item) = self.sidebar_state.home_items.get(ix).cloned() else { return div().into_any_element(); }; - let focused = self.sidebar_state.cursor == Some(ix) && (self.sidebar_state.focus.is_focused(window) || self.focus.is_focused(window)); + let focused = self.sidebar_state.listed() && self.sidebar_state.cursor == Some(ix) && (self.sidebar_state.focus.is_focused(window) || self.focus.is_focused(window)); let network = self.sidebar_state.home.network; let click = move |row: Stateful
, cx: &mut Context| row.on_mouse_down(MouseButton::Left, cx.listener(move |this, _, _, cx| this.activate_home(ix, cx))); match item { @@ -306,7 +316,7 @@ impl Shell { return list_row(("space-gone", ix), theme::row_height(theme::ROW_ONE), false, false, div()); }; let lit = space.attention > 0 || space.unread; - let here = self.open.as_ref().is_some_and(|open| self.snapshot.as_ref().and_then(|s| s.open.as_ref()).is_some_and(|row| row.key == *open && row.space.as_deref() == Some(space.key.id.as_str()))); + let here = if let Mode::Space(selected) = &self.sidebar_state.mode { selected == &space.key } else { self.open.as_ref().is_some_and(|open| self.snapshot.as_ref().and_then(|s| s.open.as_ref()).is_some_and(|row| row.key == *open && row.space.as_deref() == Some(space.key.id.as_str()))) }; let icon = self.media_path(&space.key, space.icon.as_ref()); let color = if space.muted { theme::text_faint() } else if lit { theme::text() } else { theme::text_soft() }; // A team's chevron sits in the tree's 16 slot, so its name lines up with its channels'. diff --git a/crates/tinyclient/src/gui/state.rs b/crates/tinyclient/src/gui/state.rs index 46deabe..668deb7 100644 --- a/crates/tinyclient/src/gui/state.rs +++ b/crates/tinyclient/src/gui/state.rs @@ -426,7 +426,7 @@ impl Shell { let mut directs = false; for key in dirty.rows { let in_tree = self.trees.values().any(|tree| tree.conversations.iter().any(|c| c.key == key)); - directs |= !in_tree && matches!(self.sidebar_state.mode, Mode::Network(network) if network == key.network); + directs |= !in_tree && self.sidebar_state.home.network == Some(key.network); if in_tree || self.open.as_ref() == Some(&key) { self.load_row(key, cx); } diff --git a/docs/design/polish/02-discord-slack.md b/docs/design/polish/02-discord-slack.md index 2ef3440..7295bfe 100644 --- a/docs/design/polish/02-discord-slack.md +++ b/docs/design/polish/02-discord-slack.md @@ -93,5 +93,23 @@ For reconciliation with `polish/01-system.md`: `LEAD 20` (leading box of every 2 - **Hover swaps** happen inside a trailing slot of fixed width so the name never re-truncates. Presence and the group's front face take `theme::over(fill, sidebar)` as their ring per state (#121315, #202123, #2E2F31). - The private-channel lock and the spine are child divs; no masks, no blur. Typing dots are the only timer, in their own entity. + +### Discord server columns + +At wide window sizes, selecting a Discord server keeps the Discord overview +visible in a 280 pt column and opens its channels in the regular sidebar beside +it. The overview retains DMs, folders, expansion state and scroll position; +selecting another server replaces only the channels column and highlights that +server immediately. Choosing a DM returns to the Discord home and hides the +channels column. Message requests likewise return to the home before drilling +into their list. Home data continues refreshing while a server is selected. + +The extra column appears only when the rail, both lists, the 392 pt minimum +conversation and any visible right pane fit. Otherwise the original single +sidebar and back navigation apply. The existing sidebar resize handle changes +the channels width; the overview width is fixed. Other networks keep their +existing layout. + + ## Deliberately absent A server-icon column; chevrons and inline trees on server rows; banners, boosts, member and online counts; role colours; "@" in mention badges; a grey muted-mention look; mute bells on rows; elapsed timers and rich-presence art; time on Discord DM rows at rest; a read-only mark; unread-first sorting; prefix grouping; Slack's Unreads, Activity, Later, Huddles and Canvases views (Open in Slack); total reply count in a Threads row (in the pane header); any animation of rows filling in.