list: collapse one-message threads and drop counts
Read rows now step back to secondary ink so unread ones stand out without a marker.
Assisted-by: Claude Opus 5.5 via Claude Code
diff --git a/assets/docket.css b/assets/docket.css
index 221013a..ab87eb7 100644
--- a/assets/docket.css
+++ b/assets/docket.css
@@ -47,25 +47,28 @@ button { font: inherit; color: inherit; }
.who-name { font-weight: 600; }
.who-login { text-transform: none; letter-spacing: 0; color: var(--gl-color-text-tertiary); }
-/* ── the list: one card; each thread hangs its messages off a rail ── */
+/* ── the list: one card. A one-message thread is a single row; a longer one
+ heads a row per message. Unread rows carry full ink; read ones
+ step back. ── */
.list { background: var(--gl-color-surface-card); border-radius: 6px; padding: var(--gl-space-m) var(--gl-space-m) 0; display: flex; flex-direction: column; gap: var(--gl-space-m); min-height: 0; overflow-y: auto; }
.list-head { display: flex; justify-content: space-between; align-items: baseline; }
.list-head h1, .thread-head h2 { font: 600 var(--gl-step-0)/1.2 var(--gl-font-text); letter-spacing: -.01em; }
-.section-head { display: flex; justify-content: space-between; }
-.section-head .sub { color: var(--gl-color-text-tertiary); }
-.groups { display: flex; flex-direction: column; gap: var(--gl-space-m); padding: var(--gl-space-s) 0 var(--gl-space-m); }
+.section-head { color: var(--gl-color-text-tertiary); }
+.groups { display: flex; flex-direction: column; gap: var(--gl-space-s); padding: var(--gl-space-2xs) 0 var(--gl-space-m); }
.group { display: flex; flex-direction: column; gap: var(--gl-space-3xs); }
-.group-head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
-.subj { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.more { color: var(--gl-color-text-tertiary); white-space: nowrap; }
+.subj { min-width: 0; font-weight: 600; color: var(--gl-color-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.group:not(.unread) > .subj, .row.solo:not(.unread) .subj { font-weight: 500; color: var(--gl-color-text-secondary); }
+.age { font-size: var(--gl-step--2); color: var(--gl-color-text-tertiary); white-space: nowrap; text-align: right; }
.rail { display: flex; flex-direction: column; gap: 2px; padding-left: 6px; }
-.row { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 10px; padding: 6px 8px 8px 10px; margin-right: -8px; border-radius: var(--gl-radius); color: var(--gl-color-text-secondary); text-decoration: none; transition: background var(--gl-dur-fast) var(--gl-ease); }
+.row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: baseline; gap: 2px 10px; padding: 6px 8px 8px 10px; margin-right: -8px; border-radius: var(--gl-radius); color: var(--gl-color-text-secondary); text-decoration: none; transition: background var(--gl-dur-fast) var(--gl-ease); }
+.row.solo { margin-left: -10px; }
.row:hover { background: var(--gl-color-surface-fill); color: var(--gl-color-text-secondary); }
.row.sel { background: var(--gl-color-surface-fill); }
-.row .from { flex: 1 1 0; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.row .from, .row .line { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row.unread .from { font-weight: 500; color: var(--gl-color-text-primary); }
-.row .age { flex: none; width: 76px; text-align: right; font-size: var(--gl-step--2); white-space: nowrap; }
-.row .snip { flex: 0 0 100%; font-size: var(--gl-step--2); line-height: 1.5; color: var(--gl-color-text-tertiary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.row .line { font-size: var(--gl-step--2); }
+.row .snip { font-size: var(--gl-step--2); color: var(--gl-color-text-tertiary); }
+.rail .row .snip { grid-column: 1 / -1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ── the thread: each email its own card, comments bare between them ── */
.reader { min-height: 0; overflow-y: auto; }
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 11ed185..9f415d4 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -95,7 +95,7 @@ There is no toolbar and no thread-level action. Each message's state, folder, an
## Core flows
-- **Landing — For me**: individual messages assigned to me (any state) plus unassigned Inbox messages, grouped by state, then by thread. A thread group lists only its messages in that state, with a count ("2 of 4"); the rest of the thread is omitted.
+- **Landing — For me**: individual messages assigned to me (any state) plus unassigned Inbox messages, grouped by state, then by thread. A thread group lists only its messages in that state; the rest of the thread is omitted.
- **Triage**: from the list or the thread view. Click the value to change it. No modals; an undo toast after each change.
- **Thread view**: received messages, sent messages, and comments in time order, as ruled rows of equal weight. Each row has the sender (and cc/bcc when present) and body on the left; a right gutter holds the date and, for received messages, state, folder, and assignees in fixed positions so they line up down the thread. Sent messages show the recipient after the sender and no values. Comments sit on a filled row. Events ("Sam moved to Do", "filed via another client"). Older messages collapse; their values stay clickable.
- **Lane views**: one list component showing individual messages, grouped by thread (only messages in that lane):
diff --git a/src/lists.rs b/src/lists.rs
index 9501ec5..b140c29 100644
--- a/src/lists.rs
+++ b/src/lists.rs
@@ -31,7 +31,6 @@ enum Order {
#[derive(Debug)]
pub struct Section<'a> {
pub head: String,
- pub sub: String,
/// Compact rows drop the snippet (Watch).
pub compact: bool,
pub groups: Vec<Group<'a>>,
@@ -40,18 +39,9 @@ pub struct Section<'a> {
#[derive(Debug)]
pub struct Group<'a> {
pub thread: &'a Thread,
- /// All messages in the thread, sent ones included.
- pub total: usize,
pub rows: Vec<&'a Message>,
}
-impl Group<'_> {
- /// "2 OF 4" when the group shows only part of its thread.
- pub fn more(&self) -> Option<String> {
- (self.rows.len() < self.total).then(|| format!("{} OF {}", self.rows.len(), self.total))
- }
-}
-
pub fn sections<'a>(store: &'a Store, me: &User, view: &View) -> Vec<Section<'a>> {
let received = || store.messages.iter().filter(|m| m.values().is_some());
let in_state = move |state| received().filter(move |m| m.state() == Some(state));
@@ -67,7 +57,7 @@ pub fn sections<'a>(store: &'a Store, me: &User, view: &View) -> Vec<Section<'a>
.collect();
out.push(section(
store,
- (state.name().to_uppercase(), String::new()),
+ state.name().to_uppercase(),
msgs,
Order::NewestFirst,
));
@@ -79,12 +69,7 @@ pub fn sections<'a>(store: &'a Store, me: &User, view: &View) -> Vec<Section<'a>
} else {
Order::OldestFirst
};
- let mut s = section(
- store,
- (String::new(), String::new()),
- in_state(*state).collect(),
- order,
- );
+ let mut s = section(store, String::new(), in_state(*state).collect(), order);
s.compact = *state == State::Watch;
out.push(s);
}
@@ -98,7 +83,7 @@ pub fn sections<'a>(store: &'a Store, me: &User, view: &View) -> Vec<Section<'a>
.collect();
out.push(section(
store,
- ("RESULTS".to_owned(), String::new()),
+ "RESULTS".to_owned(),
hits,
Order::NewestFirst,
));
@@ -131,7 +116,7 @@ fn matches(store: &Store, m: &Message, query: &str) -> bool {
fn section<'a>(
store: &'a Store,
- (head, sub): (String, String),
+ head: String,
mut msgs: Vec<&'a Message>,
order: Order,
) -> Section<'a> {
@@ -143,7 +128,6 @@ fn section<'a>(
} else if let Some(thread) = store.thread(m.thread) {
groups.push(Group {
thread,
- total: store.thread_messages(thread.id).len(),
rows: vec![m],
});
}
@@ -156,7 +140,6 @@ fn section<'a>(
}
Section {
head,
- sub,
compact: false,
groups,
}
@@ -167,14 +150,13 @@ mod tests {
use super::*;
use crate::fixtures;
- fn summary(store: &Store, user: &str, view: &View) -> Vec<(String, String, Vec<Vec<u32>>)> {
+ fn summary(store: &Store, user: &str, view: &View) -> Vec<(String, Vec<Vec<u32>>)> {
let me = store.user(user).unwrap();
sections(store, me, view)
.into_iter()
.map(|s| {
(
s.head,
- s.sub,
s.groups
.iter()
.map(|g| g.rows.iter().map(|m| m.id).collect())
@@ -187,7 +169,7 @@ mod tests {
fn heads(store: &Store, user: &str, view: &View) -> Vec<String> {
summary(store, user, view)
.into_iter()
- .map(|(h, s, _)| format!("{h}|{s}"))
+ .map(|(h, _)| h)
.collect()
}
@@ -195,37 +177,25 @@ mod tests {
fn for_me_groups_by_state_then_thread() {
let store = fixtures::store();
let sam = summary(&store, "sam", &View::ForMe);
- assert_eq!(
- heads(&store, "sam", &View::ForMe),
- ["INBOX|", "DO|", "WATCH|"]
- );
- let (_, _, groups) = &sam[0];
+ assert_eq!(heads(&store, "sam", &View::ForMe), ["INBOX", "DO", "WATCH"]);
+ let (_, groups) = &sam[0];
// Newest thread first; the roofing thread shows only its two Inbox emails.
assert_eq!(groups[0], vec![fixtures::WATER]);
assert!(groups.contains(&vec![3, 4]));
// Alex's own Inbox messages share the section with unassigned ones.
let alex = summary(&store, "alex", &View::ForMe);
- assert_eq!(heads(&store, "alex", &View::ForMe), ["INBOX|", "WAIT|"]);
+ assert_eq!(heads(&store, "alex", &View::ForMe), ["INBOX", "WAIT"]);
assert_eq!(
- alex[0].2,
+ alex[0].1,
[vec![7], vec![6], vec![10], vec![8], vec![9], vec![3, 4]]
);
}
#[test]
- fn group_counts() {
+ fn sidebar_count() {
let store = fixtures::store();
let sam = store.user("sam").unwrap();
- let sections = sections(&store, sam, &View::ForMe);
- let roofing = sections[0]
- .groups
- .iter()
- .find(|g| g.thread.id == 1)
- .unwrap();
- assert_eq!(roofing.more().as_deref(), Some("2 OF 4"));
- let water = §ions[0].groups[0];
- assert_eq!(water.more(), None);
assert_eq!(count(&store, sam, &View::ForMe), 7);
}
@@ -235,13 +205,13 @@ mod tests {
// Every assignee shares one unheaded section: Inbox newest first, Do
// oldest first.
let inbox = summary(&store, "sam", &View::Lane(State::Inbox));
- assert_eq!(heads(&store, "sam", &View::Lane(State::Inbox)), ["|"]);
- assert_eq!(inbox[0].2[0], vec![fixtures::WATER]);
+ assert_eq!(heads(&store, "sam", &View::Lane(State::Inbox)), [""]);
+ assert_eq!(inbox[0].1[0], vec![fixtures::WATER]);
let todo = summary(&store, "alex", &View::Lane(State::Do));
- assert_eq!(heads(&store, "alex", &View::Lane(State::Do)), ["|"]);
- assert_eq!(todo[0].2, [vec![13], vec![5]]);
+ assert_eq!(heads(&store, "alex", &View::Lane(State::Do)), [""]);
+ assert_eq!(todo[0].1, [vec![13], vec![5]]);
let wait = summary(&store, "sam", &View::Lane(State::Wait));
- assert_eq!(wait[0].2, vec![vec![11]]);
+ assert_eq!(wait[0].1, vec![vec![11]]);
let me = store.user("sam").unwrap();
let watch = sections(&store, me, &View::Lane(State::Watch));
@@ -254,11 +224,11 @@ mod tests {
fn search_covers_every_message() {
let store = fixtures::store();
let hits = summary(&store, "sam", &View::Search("downspout".into()));
- assert_eq!(hits[0].2, vec![vec![2, 4]]);
+ assert_eq!(hits[0].1, vec![vec![2, 4]]);
let by_sender = summary(&store, "sam", &View::Search("ALEX".into()));
- assert_eq!(by_sender[0].2, vec![vec![12]]);
+ assert_eq!(by_sender[0].1, vec![vec![12]]);
let by_subject = summary(&store, "sam", &View::Search("checkup".into()));
- assert_eq!(by_subject[0].2, vec![vec![15]]);
+ assert_eq!(by_subject[0].1, vec![vec![15]]);
assert!(summary(&store, "sam", &View::Search(" ".into())).is_empty());
assert!(summary(&store, "sam", &View::Search("zzz".into())).is_empty());
}
diff --git a/src/views.rs b/src/views.rs
index 6814425..5a06b93 100644
--- a/src/views.rs
+++ b/src/views.rs
@@ -5,7 +5,7 @@
use maud::{DOCTYPE, Markup, PreEscaped, html};
use crate::dates;
-use crate::lists::{self, Section, View};
+use crate::lists::{self, Group, Section, View};
use crate::model::{Comment, Kind, Message, State, User, Values};
use crate::store::{Flash, Item, Store};
@@ -168,16 +168,10 @@ fn whoami(p: &Page<'_>) -> Markup {
fn list(p: &Page<'_>) -> Markup {
let sections = lists::sections(p.store, p.me, p.view);
- let total: usize = sections
- .iter()
- .flat_map(|s| &s.groups)
- .map(|g| g.rows.len())
- .sum();
html! {
section.list aria-label="Messages" {
header.list-head {
h1 { (p.view.title()) }
- span.type-figure.count { (total) }
}
@if sections.is_empty() {
p.empty { "Nothing here." }
@@ -193,25 +187,13 @@ fn list_section(p: &Page<'_>, section: &Section<'_>) -> Markup {
html! {
div {
@if !section.head.is_empty() {
- div.type-section.section-head {
- span { (section.head) }
- span.sub { (section.sub) }
- }
+ span.type-label.section-head { (section.head) }
}
div.groups {
@for group in §ion.groups {
- div.group {
- div.group-head {
- span.subj { (group.thread.subject) }
- @if let Some(more) = group.more() {
- span.type-label.more { (more) }
- }
- }
- div.rail {
- @for m in &group.rows {
- (row(p, m, section.compact))
- }
- }
+ @match group.rows.as_slice() {
+ [m] => (solo(p, group, m, section.compact)),
+ rows => (thread_group(p, group, rows, section.compact)),
}
}
}
@@ -219,6 +201,40 @@ fn list_section(p: &Page<'_>, section: &Section<'_>) -> Markup {
}
}
+/// A thread with several messages listed: the subject heads a row per message.
+fn thread_group(p: &Page<'_>, group: &Group<'_>, rows: &[&Message], compact: bool) -> Markup {
+ let unread = rows.iter().any(|m| p.store.is_unread(&p.me.slug, m));
+ html! {
+ div.group.unread[unread] {
+ span.subj { (group.thread.subject) }
+ div.rail {
+ @for m in rows {
+ (row(p, m, compact))
+ }
+ }
+ }
+ }
+}
+
+/// A thread with one message listed collapses into a single row: subject and
+/// time, then sender and snippet.
+fn solo(p: &Page<'_>, group: &Group<'_>, m: &Message, compact: bool) -> Markup {
+ let selected = p.selected.is_some_and(|s| s.id == m.id);
+ let unread = p.store.is_unread(&p.me.slug, m);
+ html! {
+ a.row.solo.sel[selected].unread[unread] href=(select_path(p.view, m.id)) {
+ span.subj { (group.thread.subject) }
+ span.type-figure.age { (dates::short(p.store.now, m.at)) }
+ span.line {
+ span.from { (sender(p.store, m)) }
+ @if !compact {
+ span.snip { " — " (m.body) }
+ }
+ }
+ }
+ }
+}
+
fn sender(store: &Store, m: &Message) -> String {
match &m.kind {
Kind::Received { from, .. } => from.clone(),
diff --git a/tests/api.rs b/tests/api.rs
index ea45555..3161cc2 100644
--- a/tests/api.rs
+++ b/tests/api.rs
@@ -81,7 +81,6 @@ async fn for_me_lists_messages_grouped_by_state_and_thread() {
assert_eq!(status, StatusCode::OK);
assert!(body.contains("<title>For me · Docket</title>"));
assert!(body.contains("Gutter repair estimate"));
- assert!(body.contains("2 OF 4"));
assert!(body.contains("No thread open."));
// Rows carry sender, age and snippet; values live in the thread.
assert!(body.contains(r#"<span class="from">Northwind Roofing</span>"#));
@@ -174,13 +173,13 @@ async fn read_only_accounts_show_folder_as_plain_text() {
async fn opening_a_message_marks_it_read() {
let addr = spawn().await;
let (_, body) = get(addr, ALEX, "/").await;
- assert!(body.contains(r#"class="row unread" href="/?m=7""#));
+ assert!(body.contains(r#"class="row solo unread" href="/?m=7""#));
get(addr, ALEX, "/?m=7").await;
let (_, body) = get(addr, ALEX, "/").await;
- assert!(body.contains(r#"class="row" href="/?m=7""#));
+ assert!(body.contains(r#"class="row solo" href="/?m=7""#));
// Read tracking is per person.
let (_, body) = get(addr, SAM, "/").await;
- assert!(body.contains(r#"class="row unread" href="/?m=7""#));
+ assert!(body.contains(r#"class="row solo unread" href="/?m=7""#));
}
#[tokio::test]