Skip to content

Commit 60ffcc8

Browse files
javimoschclaude
andcommitted
feat: inbox list collapses by thread, Gmail-style
Direct follow-up to conversation threading: once a reply-back correctly joins its thread, it showed up as a second row in the inbox list -- same conversation, two rows, reading as a duplicate. groupByThread (api.jsx) collapses same-thread-id rows client-side over whatever page is already loaded: since the list is always sorted created_at desc, the first message seen for a thread_id is its latest and becomes the representative row, with later same-thread messages folding into its count/anyUnread/ anyStarred. A message with no thread_id (a fresh Compose) keeps its own single-message group. Stated plainly, not silently: this is a page-local collapse, not a true thread count -- a thread whose messages straddle a page boundary still shows as separate rows on each page, since poche has no server-side GROUP BY (same reason messageFieldFacets scans-and-aggregates in Go). Selecting a collapsed row's checkbox selects every message id in that group at once, so bulk star/archive/delete/tag act on the whole conversation. A collapsed row's star icon does the same via a new onStarThread(ids, star) handler that reuses the existing /api/bulk star/unstar action with explicit ids -- no new backend surface, inherits that endpoint's already-fixed ownership check for free. Verified in a real browser: a 2-message inbound thread (+ a separate outbound reply correctly excluded from the Inbox view's direction=in filter) collapsed into one row with a "2" badge; clicking it opened the latest message with the pane's ThreadStrip showing all three messages including the outbound reply; the row checkbox selected both underlying ids (toolbar showed "(2)"); starring the row starred both underlying messages, confirmed via a direct query, without touching the outbound reply that was never part of that row's group. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 55d2fcf commit 60ffcc8

5 files changed

Lines changed: 141 additions & 9 deletions

File tree

AGENTS.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,55 @@ There is still no "Starred" folder/filter in the sidebar (only the
727727
retention-exemption behavior — starred messages are never auto-cleaned).
728728
Not a bug, just not built yet.
729729

730+
## Inbox list collapses by thread, Gmail-style (2026-08-10)
731+
732+
Direct follow-up to conversation threading above: once a reply-back
733+
correctly joins its thread (see that section), it showed up as a SECOND
734+
row in the inbox list — same conversation, two rows, which reads as a
735+
duplicate even though it isn't one. Gmail collapses same-thread rows into
736+
one, with a "(N)" count and the latest message's preview.
737+
738+
`groupByThread` (api.jsx) does this client-side, over whatever page of
739+
messages is already loaded: since `buildListPath` always sorts
740+
`created_at` desc, a single pass is enough — the FIRST message seen for a
741+
`thread_id` is necessarily the latest, so it becomes the representative
742+
row; every later message with the same `thread_id` just increments that
743+
row's count and folds into its `anyUnread`/`anyStarred` flags. A message
744+
with no `thread_id` (a fresh Compose — see the known limitation above)
745+
gets its own single-message group, keyed by its own id, so nothing merges
746+
incorrectly.
747+
748+
**Stated plainly, not silently**: this is a page-local collapse, not a
749+
true thread count. A thread whose messages straddle a page boundary still
750+
shows as separate rows on each page — poche has no server-side GROUP BY
751+
(same reason `messageFieldFacets` scans-and-aggregates in Go, see the
752+
address-badge section above), so a fully page-independent collapse isn't
753+
available without loading the whole mailbox per request.
754+
755+
Selecting a collapsed row's checkbox selects EVERY message id in that
756+
group at once (`m.threadIds`, not just the one shown) — so bulk star/
757+
archive/delete/tag act on the whole conversation, matching what the
758+
toolbar's live "(N)" counts already implied. Clicking a collapsed row's
759+
star icon does the same via a new `onStarThread(ids, star)` handler
760+
(app.jsx) that reuses the EXISTING `/api/bulk` star/unstar action with
761+
explicit ids — no new backend endpoint, and it inherits that endpoint's
762+
already-fixed ownership check for free. `allIds`/`pageOn`/"select page"
763+
still operate over the raw, ungrouped message list, so their semantics
764+
("every message loaded on this page") stay exactly what they were before
765+
grouping existed — a grouped row's checkbox is just a derived view over
766+
the same underlying `checked` array.
767+
768+
Verified in a real browser: seeded a 3-message thread (original inbound +
769+
our reply, direction=out, correctly excluded from the Inbox view's
770+
`direction=in` filter + a follow-up reply-back, direction=in); the Inbox
771+
list correctly showed ONE row for the two inbound messages with a "2"
772+
badge and the follow-up's own preview; clicking it opened the latest
773+
message with the pane's ThreadStrip showing all THREE messages including
774+
the outbound reply; the row checkbox selected both underlying ids at once
775+
(toolbar showed "(2)"); the star icon starred both underlying inbound
776+
messages via `onStarThread`, confirmed via a direct query — and did NOT
777+
touch the outbound reply, which was never part of that row's group.
778+
730779
## Compose formats (v0.3.3+)
731780

732781
`POST /api/compose` takes `format`: `text` (default), `html`, or `markdown`.

ui/js/api.jsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,49 @@ function rowFromItem(it) {
272272
return Object.assign({ id: it.id }, doc);
273273
}
274274

275+
// groupByThread collapses a message list into one row per conversation, so
276+
// a follow-up reply doesn't show up as a second, seemingly-duplicate inbox
277+
// row (see the message pane's ThreadStrip for the full conversation, and
278+
// AGENTS.md's threading section for how thread_id is kept consistent).
279+
// `items` must already be sorted newest-first (buildListPath always sorts
280+
// created_at desc) — that ordering is what lets this collapse in one pass:
281+
// the FIRST message seen for a thread_id is necessarily its latest, so it
282+
// becomes the representative row and everything after just adds to its
283+
// count/unread/starred state. Messages without a thread_id (a fresh
284+
// Compose — see AGENTS.md's known limitation) each form their own
285+
// single-message group, keyed by their own id.
286+
//
287+
// Important limitation, stated plainly rather than silently: this only
288+
// collapses threads within the messages already loaded on the CURRENT
289+
// page. A thread whose messages straddle a page boundary will still show
290+
// as separate rows on each page — poche has no server-side GROUP BY (see
291+
// messageFieldFacets' own comment on this), so a fully page-independent
292+
// collapse isn't available without loading the whole mailbox up front.
293+
function groupByThread(items) {
294+
const order = [];
295+
const byThread = new Map();
296+
for (const m of items) {
297+
const key = m.thread_id || m.id;
298+
let g = byThread.get(key);
299+
if (!g) {
300+
g = Object.assign({}, m, {
301+
threadIds: [m.id],
302+
threadCount: 1,
303+
anyUnread: !!m.unread,
304+
anyStarred: !!m.starred,
305+
});
306+
byThread.set(key, g);
307+
order.push(g);
308+
} else {
309+
g.threadIds.push(m.id);
310+
g.threadCount += 1;
311+
if (m.unread) g.anyUnread = true;
312+
if (m.starred) g.anyStarred = true;
313+
}
314+
}
315+
return order;
316+
}
317+
275318
function tagNamesFromPage(data) {
276319
return (data?.items || [])
277320
.map((it) => {

ui/js/app-layout.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ function AppLayout({
3737
busy,
3838
onToggleUnread,
3939
onStar,
40+
onStarThread,
4041
onArchive,
4142
onUnarchive,
4243
onDelete,
@@ -167,6 +168,7 @@ function AppLayout({
167168
selected={selected}
168169
onSelect={setSelected}
169170
onStar={onStar}
171+
onStarThread={onStarThread}
170172
loading={loading}
171173
checked={checked}
172174
setChecked={setChecked}

ui/js/app.jsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,24 @@ function App() {
301301
.finally(() => setBusy(false));
302302
};
303303

304+
// Stars/unstars every message in a collapsed thread row at once (see
305+
// groupByThread, api.jsx) — matches Gmail's own "starring a conversation
306+
// stars every message in it" behavior. Reuses the bulk endpoint's
307+
// existing per-id star/unstar action rather than adding new backend
308+
// surface: it already accepts explicit ids and is already
309+
// ownership-checked (see messages.go/bulk.go's cross-tenant IDOR fixes).
310+
const onStarThread = (ids, star) => {
311+
if (!token || !ids || !ids.length) return;
312+
setBusy(true);
313+
bulkFetch(token, { action: star ? "star" : "unstar", ids })
314+
.then(refreshAfter)
315+
.catch((e) => {
316+
console.error(e);
317+
alert(String(e.message || e));
318+
})
319+
.finally(() => setBusy(false));
320+
};
321+
304322
const onReply = (text, from, format) => {
305323
if (!msg) return Promise.resolve();
306324
setBusy(true);
@@ -409,6 +427,7 @@ function App() {
409427
busy={busy}
410428
onToggleUnread={onToggleUnread}
411429
onStar={onStar}
430+
onStarThread={onStarThread}
412431
onArchive={() => runOne("archive")}
413432
onUnarchive={() => runOne("unarchive")}
414433
onDelete={() => runOne("delete")}

ui/js/components.jsx

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@ function MessageList({
336336
selected,
337337
onSelect,
338338
onStar,
339+
onStarThread,
339340
loading,
340341
checked,
341342
setChecked,
@@ -344,9 +345,16 @@ function MessageList({
344345
setSelectAllPages,
345346
}) {
346347
const { t, lang } = useI18n();
348+
// "select page"/select-all-pages still operate on every raw message id
349+
// loaded, not one per collapsed row — checking a single grouped row below
350+
// adds/removes its whole threadIds set, so the two stay consistent.
347351
const allIds = items.map((m) => m.id);
348352
const pageOn = allIds.length > 0 && allIds.every((id) => checked.includes(id));
349353
const multiPage = total > allIds.length && allIds.length > 0;
354+
// Collapses same-thread rows (e.g. a received message + your reply to it)
355+
// into one — see groupByThread's own comment (api.jsx) for what this does
356+
// and does NOT do (page-local only, not a true server-side thread count).
357+
const groups = groupByThread(items);
350358

351359
if (loading) return <div className="p-6 text-ink-muted text-sm">{t("loading")}</div>;
352360
if (!items.length) {
@@ -377,9 +385,9 @@ function MessageList({
377385
</label>
378386
)}
379387
</div>
380-
{items.map((m) => {
388+
{groups.map((m) => {
381389
const active = selected === m.id;
382-
const on = selectAllPages || checked.includes(m.id);
390+
const on = selectAllPages || m.threadIds.every((id) => checked.includes(id));
383391
return (
384392
<div
385393
key={m.id}
@@ -396,26 +404,32 @@ function MessageList({
396404
e.stopPropagation();
397405
setSelectAllPages(false);
398406
setChecked(
399-
e.target.checked ? checked.concat([m.id]) : checked.filter((x) => x !== m.id)
407+
e.target.checked
408+
? checked.concat(m.threadIds.filter((id) => !checked.includes(id)))
409+
: checked.filter((x) => !m.threadIds.includes(x))
400410
);
401411
}}
402412
/>
403413
<button
404414
className={
405415
"text-base leading-none shrink-0 " +
406-
(m.starred ? "text-accent" : "text-ink-dim hover:text-accent")
416+
(m.anyStarred ? "text-accent" : "text-ink-dim hover:text-accent")
407417
}
408-
title={m.starred ? "Unstar" : "Star"}
418+
title={m.anyStarred ? "Unstar" : "Star"}
409419
onClick={(e) => {
410420
e.stopPropagation();
411-
onStar(m.id, !m.starred);
421+
if (m.threadCount > 1) {
422+
onStarThread(m.threadIds, !m.anyStarred);
423+
} else {
424+
onStar(m.id, !m.anyStarred);
425+
}
412426
}}
413427
>
414-
{m.starred ? "★" : "☆"}
428+
{m.anyStarred ? "★" : "☆"}
415429
</button>
416430
<button className="flex-1 min-w-0 text-left" onClick={() => onSelect(m.id)}>
417431
<div className="flex items-baseline justify-between gap-2">
418-
<span className={"text-sm truncate " + (m.unread ? "font-semibold text-ink" : "text-ink-muted")}>
432+
<span className={"text-sm truncate " + (m.anyUnread ? "font-semibold text-ink" : "text-ink-muted")}>
419433
{m.direction === "out" ? t("to_prefix", m.to_addr || "") : m.from_addr}
420434
</span>
421435
<span className="text-[0.76rem] font-mono text-ink-dim shrink-0 tabular-nums">
@@ -424,9 +438,14 @@ function MessageList({
424438
</div>
425439
<div className="flex items-baseline gap-1.5 mt-0.5 min-w-0">
426440
{m.direction !== "out" && m.to_addr && <AddressBadge address={m.to_addr} />}
427-
<span className={"text-sm truncate " + (m.unread ? "text-ink" : "text-ink-muted")}>
441+
<span className={"text-sm truncate " + (m.anyUnread ? "text-ink" : "text-ink-muted")}>
428442
{m.subject}
429443
</span>
444+
{m.threadCount > 1 && (
445+
<span className="shrink-0 text-[0.7rem] font-mono text-ink-dim px-1 rounded bg-paper-line/60">
446+
{m.threadCount}
447+
</span>
448+
)}
430449
</div>
431450
<div className="text-xs text-ink-dim truncate mt-0.5">{m.preview}</div>
432451
</button>

0 commit comments

Comments
 (0)