From ea618be03c254846ec1581b1b0cf724d986a031c Mon Sep 17 00:00:00 2001 From: Vincent Bernardoff Date: Sun, 18 Oct 2026 10:27:18 +0200 Subject: [PATCH 1/2] fix: keep Discord category channels visibly nested Indent category children consistently when expanded or collapsed so selected and unread exceptions still read as children of their header. Keep threads and voice participants one level deeper and ungrouped channels at the root. Preserve Slack sections, with regression coverage and updated sidebar design documentation. Co-Authored-By: GPT-6 --- crates/tinyclient/src/gui/sidebar/tree.rs | 33 +++++++++++++++++++---- docs/design/polish/02-discord-slack.md | 1 + 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/crates/tinyclient/src/gui/sidebar/tree.rs b/crates/tinyclient/src/gui/sidebar/tree.rs index fa5f7ac..7f975aa 100644 --- a/crates/tinyclient/src/gui/sidebar/tree.rs +++ b/crates/tinyclient/src/gui/sidebar/tree.rs @@ -241,7 +241,9 @@ pub fn flatten(space: &Key, groups: &[Group], conversations: &[Rc] rollup: if collapsed { rollup } else { Rollup::default() }, depth, }); - emit(&mut rows, &members, depth, collapsed); + // Discord categories own their channels, including the visible exceptions when folded. + let child_depth = depth + u8::from(!slack && space.network == Network::Discord); + emit(&mut rows, &members, child_depth, collapsed); if !quiet.is_empty() && !collapsed { let words = if quiet.len() == 1 { "1 quiet channel".to_owned() } else { format!("{} quiet channels", quiet.len()) }; rows.push(Row::Fold { words, reveal: Reveal::Quiet(group.id.clone()), depth }); @@ -346,7 +348,7 @@ mod tests { ], None, ); - assert_eq!(tree, ["rules", "orphan", "vtext", "general", "help", "vvoice", "lounge"]); + assert_eq!(tree, ["rules", "orphan", "vtext", ".general", ".help", "vvoice", ".lounge"]); } #[test] @@ -363,7 +365,28 @@ mod tests { let groups = [group("text", 0, true), group("quiet", 1, true)]; let all = vec![mentioned, unread, channel("help", Some("text"), Channel), lounge, muted]; // The mention, the unread channel, voice with people and the open row stay. - assert_eq!(rows(&groups, all, Some("help")), [">text(2)", "design", "general", "help", "lounge", "+2", ">quiet"]); + assert_eq!(rows(&groups, all, Some("help")), [">text(2)", ".design", ".general", ".help", ".lounge", "+2", ">quiet"]); + } + + #[test] + fn category_children_keep_their_depth_when_collapsed_with_an_open_thread() { + use ConversationKind::{Channel, Thread}; + let mut thread = channel("thread", None, Thread); + thread.parent = Some("general".into()); + let conversations = [channel("loose", None, Channel), channel("general", Some("text"), Channel), channel("quiet", Some("text"), Channel), thread].into_iter().map(Rc::new).collect::>(); + let groups = [group("text", 0, true)]; + let open = key("thread"); + let folded = flatten(&key("s"), &groups, &conversations, Some(&open), 2, &Rules::default()); + assert!(matches!(&folded[0], Row::Conversation { depth: 2, .. })); + assert!(matches!(&folded[1], Row::Group { depth: 2, collapsed: true, .. })); + assert!(matches!(&folded[2], Row::Conversation { conversation, depth: 3 } if conversation.key.id == "general")); + assert!(matches!(&folded[3], Row::Conversation { conversation, depth: 4 } if conversation.key == open)); + assert_eq!(folded.len(), 4, "the quiet sibling stays folded"); + let opened = HashSet::from(["text".into()]); + let expanded = flatten(&key("s"), &groups, &conversations, Some(&open), 2, &Rules { opened: Some(&opened), ..Rules::default() }); + assert!(matches!(&expanded[2], Row::Conversation { depth: 3, .. })); + assert!(matches!(&expanded[3], Row::Conversation { depth: 4, .. })); + assert!(matches!(&expanded[4], Row::Conversation { depth: 3, .. })); } #[test] @@ -372,10 +395,10 @@ mod tests { let mut muted = group("muted", 0, false); muted.muted = true; let tree = vec![channel("a", Some("muted"), Channel), channel("b", Some("text"), Channel)]; - assert_eq!(rows(&[muted.clone(), group("text", 1, false)], tree.clone(), None), [">muted", "vtext", "b"]); + assert_eq!(rows(&[muted.clone(), group("text", 1, false)], tree.clone(), None), [">muted", "vtext", ".b"]); let opened = HashSet::from(["muted".to_owned()]); let rules = Rules { opened: Some(&opened), ..Rules::default() }; - assert_eq!(rows_with(&[muted, group("text", 1, false)], tree, None, &rules), ["vmuted", "a", "vtext", "b"]); + assert_eq!(rows_with(&[muted, group("text", 1, false)], tree, None, &rules), ["vmuted", ".a", "vtext", ".b"]); let mut many: Vec = (0..90).map(|n| channel(&format!("c{n}"), Some("big"), Channel)).collect(); many[3].mentions = 1; diff --git a/docs/design/polish/02-discord-slack.md b/docs/design/polish/02-discord-slack.md index 47d9be3..d16d231 100644 --- a/docs/design/polish/02-discord-slack.md +++ b/docs/design/polish/02-discord-slack.md @@ -40,6 +40,7 @@ Top to bottom, one scroll: - **Followed threads** (only joined and unread or open, at most 3): corner glyph 14, name, count badge; then "n more threads" action sub-row expanding all followed threads under that channel, including read ones. "Show fewer threads" collapses back to the three-row limit; the open thread stays visible. Expansion is remembered per channel for the session. A thread whose starter is gone uses its own name. - **Voice**: occupied channel `text_soft` with `4/12` when it has a user limit; occupants as 28 sub-rows (avatar 20, server nickname, trailing mic-off / deafen 14 `danger`, screen share or camera 14 `text_soft`), 6 then "+n", friends first. Empty voice channel: `text_faint`, nothing trailing. Stage: "Live · 2 speaking" `text_faint`. - **Footers** (28 action rows, `text_faint`): "Show 41 muted channels" when *Hide muted channels* is on in Discord (reveals for the session); "Browse 212 more channels" for onboarding opt-outs (from the stored tree). +- **Hierarchy**: Discord category channels are indented one 16 pt level under their header, including selected/unread channels kept visible while collapsed. Threads and voice participants indent one further level. Ungrouped channels remain at the header level; expanding or collapsing never changes a channel's indentation. Slack sections keep their existing flat layout. - **Collapse**: muted categories start collapsed; over 80 visible channels, every category without a number starts collapsed. Collapsed still shows the open row, unread unmuted channels, rows with a number and voice channels with people. The user's own collapse wins after and is kept per server. - **300 channels** (frame 4): 10 headers, 9 rows that need you, 2 footers = 21 rows, 588 pt, one screen. From e0c8d4b2d7789fbbb92b5e2333b75c9f5bcaa0f4 Mon Sep 17 00:00:00 2001 From: Vincent Bernardoff Date: Sun, 18 Oct 2026 10:30:35 +0200 Subject: [PATCH 2/2] fix: hide every child of a collapsed Discord category Make collapse hide selected channels, unread channels, threads and voice participants. Keep hidden activity on the category header and leave the open conversation intact. Preserve Slack's attention exceptions and update the regression expectations and sidebar specification. Co-Authored-By: GPT-6 --- crates/tinyclient/src/gui/sidebar/tree.rs | 25 +++++++++++++---------- docs/design/polish/02-discord-slack.md | 6 +++--- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/crates/tinyclient/src/gui/sidebar/tree.rs b/crates/tinyclient/src/gui/sidebar/tree.rs index 7f975aa..0b58d63 100644 --- a/crates/tinyclient/src/gui/sidebar/tree.rs +++ b/crates/tinyclient/src/gui/sidebar/tree.rs @@ -2,8 +2,8 @@ //! a Slack workspace's sections, a team's channels. Rebuilt on collapse and on change. The //! network's settings are kept here (`design/polish/02-discord-slack.md` §4–5): opt-in and //! "hide muted channels", muted categories collapsed, only followed threads with news, Slack's -//! "unread only" sections, its quiet channels folded, its apps in their own group. A collapsed -//! group never hides what needs me (rule 3). +//! "unread only" sections, its quiet channels folded, its apps in their own group. Collapsed Discord categories summarize hidden activity on the header; Slack +//! sections retain attention exceptions. use protocol::{Badge, Conversation, ConversationKind, Group, GroupKind, Key, Network, Occupant, Special}; use std::collections::{HashMap, HashSet}; @@ -108,7 +108,7 @@ impl Rules<'_> { } } -/// Needs me, or is where I am: never hidden by a fold. +/// Needs me, or is where I am: retained by Slack folds and channel visibility filters. fn needs_me(c: &Conversation, open: Option<&Key>) -> bool { open == Some(&c.key) || matches!(c.badge(), Badge::Count(_)) || (c.badge() == Badge::Dot && !c.muted) } @@ -158,6 +158,9 @@ pub fn flatten(space: &Key, groups: &[Group], conversations: &[Rc] let listed: Vec<&Rc> = conversations.iter().filter(|c| !is_thread(c) && visible(c)).collect(); let emit = |rows: &mut Vec, members: &[&Rc], depth: u8, collapsed: bool| { + if collapsed && space.network == Network::Discord && !slack { + return; + } for conversation in members { let under = threads.get(conversation.key.id.as_str()).map(Vec::as_slice).unwrap_or_default(); if collapsed && !needs_me(conversation, open) && !occupied(conversation) { @@ -241,7 +244,7 @@ pub fn flatten(space: &Key, groups: &[Group], conversations: &[Rc] rollup: if collapsed { rollup } else { Rollup::default() }, depth, }); - // Discord categories own their channels, including the visible exceptions when folded. + // Discord category children stay nested beneath their header. let child_depth = depth + u8::from(!slack && space.network == Network::Discord); emit(&mut rows, &members, child_depth, collapsed); if !quiet.is_empty() && !collapsed { @@ -352,7 +355,7 @@ mod tests { } #[test] - fn a_collapsed_group_keeps_what_needs_me_and_says_what_else_is_inside() { + fn collapsed_discord_categories_hide_children_and_keep_attention_on_the_header() { use ConversationKind::Channel; let mut mentioned = channel("design", Some("text"), Channel); (mentioned.unread, mentioned.mentions) = (4, 2); @@ -364,12 +367,14 @@ mod tests { (muted.muted, muted.unread) = (true, 9); let groups = [group("text", 0, true), group("quiet", 1, true)]; let all = vec![mentioned, unread, channel("help", Some("text"), Channel), lounge, muted]; - // The mention, the unread channel, voice with people and the open row stay. - assert_eq!(rows(&groups, all, Some("help")), [">text(2)", ".design", ".general", ".help", ".lounge", "+2", ">quiet"]); + // Even the selected channel, mentions and occupied voice channels fold away. + assert_eq!(rows(&groups, all.clone(), Some("help")), [">text(2)", ">quiet"]); + let slack = Rules { network: Some(Network::Slack), ..Rules::default() }; + assert_eq!(rows_with(&groups, all, Some("help"), &slack), [">text(2)", "design", "general", "help", "lounge", "+2", ">quiet"]); } #[test] - fn category_children_keep_their_depth_when_collapsed_with_an_open_thread() { + fn a_collapsed_category_hides_the_open_thread_and_expands_with_nested_depths() { use ConversationKind::{Channel, Thread}; let mut thread = channel("thread", None, Thread); thread.parent = Some("general".into()); @@ -379,9 +384,7 @@ mod tests { let folded = flatten(&key("s"), &groups, &conversations, Some(&open), 2, &Rules::default()); assert!(matches!(&folded[0], Row::Conversation { depth: 2, .. })); assert!(matches!(&folded[1], Row::Group { depth: 2, collapsed: true, .. })); - assert!(matches!(&folded[2], Row::Conversation { conversation, depth: 3 } if conversation.key.id == "general")); - assert!(matches!(&folded[3], Row::Conversation { conversation, depth: 4 } if conversation.key == open)); - assert_eq!(folded.len(), 4, "the quiet sibling stays folded"); + assert_eq!(folded.len(), 2, "the selected thread and all channel children stay folded"); let opened = HashSet::from(["text".into()]); let expanded = flatten(&key("s"), &groups, &conversations, Some(&open), 2, &Rules { opened: Some(&opened), ..Rules::default() }); assert!(matches!(&expanded[2], Row::Conversation { depth: 3, .. })); diff --git a/docs/design/polish/02-discord-slack.md b/docs/design/polish/02-discord-slack.md index d16d231..82b1385 100644 --- a/docs/design/polish/02-discord-slack.md +++ b/docs/design/polish/02-discord-slack.md @@ -40,8 +40,8 @@ Top to bottom, one scroll: - **Followed threads** (only joined and unread or open, at most 3): corner glyph 14, name, count badge; then "n more threads" action sub-row expanding all followed threads under that channel, including read ones. "Show fewer threads" collapses back to the three-row limit; the open thread stays visible. Expansion is remembered per channel for the session. A thread whose starter is gone uses its own name. - **Voice**: occupied channel `text_soft` with `4/12` when it has a user limit; occupants as 28 sub-rows (avatar 20, server nickname, trailing mic-off / deafen 14 `danger`, screen share or camera 14 `text_soft`), 6 then "+n", friends first. Empty voice channel: `text_faint`, nothing trailing. Stage: "Live · 2 speaking" `text_faint`. - **Footers** (28 action rows, `text_faint`): "Show 41 muted channels" when *Hide muted channels* is on in Discord (reveals for the session); "Browse 212 more channels" for onboarding opt-outs (from the stored tree). -- **Hierarchy**: Discord category channels are indented one 16 pt level under their header, including selected/unread channels kept visible while collapsed. Threads and voice participants indent one further level. Ungrouped channels remain at the header level; expanding or collapsing never changes a channel's indentation. Slack sections keep their existing flat layout. -- **Collapse**: muted categories start collapsed; over 80 visible channels, every category without a number starts collapsed. Collapsed still shows the open row, unread unmuted channels, rows with a number and voice channels with people. The user's own collapse wins after and is kept per server. +- **Hierarchy**: Discord category channels are indented one 16 pt level under their header, when expanded. Threads and voice participants indent one further level. Ungrouped channels remain at the header level; expanding or collapsing never changes a channel's indentation. Slack sections keep their existing flat layout. +- **Collapse**: muted categories start collapsed; over 80 visible channels, every category without a number starts collapsed. A collapsed Discord category hides all its channels, threads and voice participants, including the selected channel; the conversation remains open. Hidden unread activity and mentions are represented on the category header. The user's own collapse wins after and is kept per server. - **300 channels** (frame 4): 10 headers, 9 rows that need you, 2 footers = 21 rows, 588 pt, one screen. ## 5. Slack home (`--mode slack`), frames 5–6 @@ -60,7 +60,7 @@ Opens in the sidebar from the Threads row (`‹ Threads`, workspace name `text_f | Strip anatomy (place name + chevron menu, `All · Unread`), jump strip | Discord: Friends, Message requests, In voice, Servers, folders. Slack: Threads, sections, Apps, quiet channels | | 28 and 48 rows, x 36 name line, section header, action rows, badges, tags | Discord DMs are 48 (who is around matters); Slack DMs are 28 inside sections (people are filed with channels) | | Unread = 600 + dot; to me = unfilled count; mention = accent pill; muted = faint, no glyph | Discord keeps muted rows in place; Slack sorts them last (each network's own rule) | -| Collapsed groups never hide what needs me | Discord's words: server, category, friends. Slack's: workspace, section, huddle, Connect | +| Collapsed Discord categories summarize hidden activity on the header; Slack sections retain their attention exceptions | Discord's words: server, category, friends. Slack's: workspace, section, huddle, Connect | ## 8. Empty, loading, error (frames 8–9); nothing animates | List | Empty | Loading / first sign-in | Error |