From e4988e3e5f7bd7533b4a2132018033e3c8afe891 Mon Sep 17 00:00:00 2001 From: Skylinerffm97 Date: Tue, 29 Sep 2026 15:12:08 +0200 Subject: [PATCH 01/24] web(unibox): fold the scope rail sections and let people hide Mail and Views rows Every rail section (Mail, Views, Mailboxes, Labels, Tags) now folds through one shared header, and the fold is remembered in the persisted store under stable ids. A folded section still shows the scope you are on and flags hidden unread mail with a dot. Mail and Views get a three-dot menu with Edit rows: in edit mode each row is a checkbox, Done or Escape ends it, and hidden rows are only taken off the rail, so shortcuts, URLs and the active scope keep working. Co-Authored-By: Claude Sonnet 5.5 --- docs/content/docs/guides/unibox.mdx | 1 + web/src/components/app/unibox/ScopeRail.tsx | 518 +++++++++++++----- .../components/app/unibox/scopeRail.test.tsx | 272 +++++++++ web/src/stores/slices/uiSlice.ts | 31 ++ web/src/stores/useAppStore.ts | 7 +- 5 files changed, 701 insertions(+), 128 deletions(-) create mode 100644 web/src/components/app/unibox/scopeRail.test.tsx diff --git a/docs/content/docs/guides/unibox.mdx b/docs/content/docs/guides/unibox.mdx index a2735f1bc..d626dfc9d 100644 --- a/docs/content/docs/guides/unibox.mdx +++ b/docs/content/docs/guides/unibox.mdx @@ -16,6 +16,7 @@ The columns are yours to size. Every choice here is remembered in the browser yo | The divider between the list and the thread | Drag it to widen the conversation list so long subjects fit, or to give the space back to the thread. It also takes the keyboard once it has focus: arrow keys nudge it (hold `Shift` for a bigger step), `Home` and `End` go to the narrowest and widest the window allows, `Enter` (or a double-click) puts it back to the default. | | **Collapse** at the bottom of the left navigation, or `b` | Shrinks Warmbly's own navigation to an icon rail and gives the width to the page. Row labels become tooltips; unread mail keeps its count badge and an open [Advisor](/guides/advisor/) finding shows as a coloured dot on the icon. The same control expands it again. | | The section headers in the left navigation (Email, CRM, Resources) | Click one to fold its section away, and again to bring it back. Like the other layout choices, it is remembered in this browser. A folded section still shows the page you are on, and a coloured dot beside its header flags an open [Advisor](/guides/advisor/) finding on one of the pages it hides. The icon rail follows the same choice. | +| The section headers in the scope rail (Mail, Views, Mailboxes, Labels, Tags) | Click one to fold its section away, and again to bring it back. It is remembered in this browser. A folded section still shows the scope you are looking at, and a small blue dot beside its header means unread mail is folded away. The three-dot button on **Mail** and **Views** has **Edit rows**, to choose which rows appear: untick a row to hide it, then press **Done** (or `Escape`). Hiding a row only takes it off the rail, so its shortcuts and links keep working, and the scope you are in always stays visible. | | The contact button in the thread header | Shows or hides the contact panel on the right. It starts closed when no preference is saved. Opening or closing it sticks across conversations, and existing preferences are preserved. | How wide the list can get depends on the window: the thread always keeps enough room to read a message, and the contact panel counts toward that when it is open, so the widest setting is narrower on a laptop than on a large monitor. diff --git a/web/src/components/app/unibox/ScopeRail.tsx b/web/src/components/app/unibox/ScopeRail.tsx index bf8151b19..e32c95e07 100644 --- a/web/src/components/app/unibox/ScopeRail.tsx +++ b/web/src/components/app/unibox/ScopeRail.tsx @@ -4,17 +4,22 @@ // language for every row: a quiet icon, the label, a bare tabular count. // // Compose drafts (only when there are any) -// All mail / Inbox / Unread / Awaiting reply / Agent drafts / Snoozed -// Drafts / Sent / Scheduled / Archive / Spam / Trash +// Mail: All mail / Inbox / Unread / Awaiting reply / Agent drafts / Snoozed +// Drafts / Sent / Scheduled / Archive / Spam / Trash // Views (premade, over the automatic labels) // Mailboxes / Labels / Tags (collapsible, searchable past 8 items) // +// Every section header folds, and the fold is remembered. Mail and Views also +// have an options menu (Edit rows) to hide rows from the rail, Apple-Mail style. +// Hiding is only visual; the scope, its URL and its shortcuts keep working. +// // Today and This week are not rows: the filter sheet's date range covers // them, and the rail is for the places mail lives, not for every slice of it. import React from "react"; import { ArchiveIcon, + CheckIcon, ChevronDownIcon, ChevronRightIcon, FileTextIcon, @@ -28,6 +33,7 @@ import { ReplyIcon, SearchIcon, SendIcon, + SlidersHorizontalIcon, SparklesIcon, Trash2Icon, ClockIcon, @@ -37,6 +43,7 @@ import { BanIcon, TriangleAlertIcon, } from "lucide-react"; +import { useAppStore } from "@/stores"; import useUniboxOverview from "@/lib/api/hooks/app/unibox/useUniboxOverview"; import useMarkSeen from "@/lib/api/hooks/app/unibox/useMarkSeen"; import AnimatedNumber from "@/components/ui/AnimatedNumber"; @@ -132,28 +139,134 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) { return m; }, [data?.folders]); + // One descriptor per row so a section can fold, hide and edit them without + // knowing what each one is. `key` is the scopeKey, which is also what is + // stored when a row is hidden. `group` only decides where the small gap goes. + const scopeRow = ( + target: UniboxScope, + label: string, + icon: React.ReactNode, + count: number | undefined, + { group = 0, accent = false }: { group?: number; accent?: boolean } = {}, + ): RailRow => { + const key = scopeKey(target); + return { + key, + label, + icon, + group, + accent, + node: ( + onChange(target)} + /> + ), + }; + }; + const folderRow = ( folder: UniboxFolder, label: string, icon: React.ReactNode, - ) => { + group: number, + ): RailRow => { const counts = folderCounts.get(folder); // Drafts reads better as a total; everywhere else the number is unread. const count = folder === "drafts" ? counts?.total : counts?.unread; - return ( - onChange({ kind: "folder", folder })} - onMarkAllRead={() => markSeen.mutate({ folder, seen: true })} - /> - ); + const accent = folder !== "drafts" && !!count; + return { + key: `folder:${folder}`, + label, + icon, + group, + accent, + node: ( + onChange({ kind: "folder", folder })} + onMarkAllRead={() => markSeen.mutate({ folder, seen: true })} + /> + ), + }; }; + const mailRows: RailRow[] = [ + scopeRow({ kind: "all" }, "All mail", , data?.total), + folderRow("inbox", "Inbox", , 0), + scopeRow({ kind: "unread" }, "Unread", , data?.unread, { + accent: !!data?.unread, + }), + scopeRow( + { kind: "awaiting" }, + "Awaiting reply", + , + data?.awaiting_reply, + { accent: !!data?.awaiting_reply }, + ), + scopeRow( + { kind: "agent_drafts" }, + "Agent drafts", + , + data?.awaiting_agent_draft, + { accent: !!data?.awaiting_agent_draft }, + ), + scopeRow({ kind: "snoozed" }, "Snoozed", , data?.snoozed), + ...MAIL_FOLDERS.slice(0, 2).map((f) => folderRow(f.folder, f.label, f.icon, 1)), + scopeRow( + { kind: "scheduled" }, + "Scheduled", + , + data?.scheduled_pending, + { group: 1, accent: !!data?.scheduled_pending }, + ), + ...MAIL_FOLDERS.slice(2).map((f) => folderRow(f.folder, f.label, f.icon, 1)), + ]; + + const viewRows: RailRow[] = data + ? UNIBOX_VIEWS.map((v) => { + // Unread across the member labels. A thread wearing two of them + // counts twice, which is close enough for a rail number. + const unread = v.automated + ? data.automated_unread + : viewCategories(v, data.categories ?? []).reduce((n, c) => n + c.unread, 0); + return { + key: `view:${v.id}`, + label: v.label, + icon: VIEW_ICONS[v.id], + accent: unread > 0, + node: ( + + +
+ 0} + active={active === `view:${v.id}`} + onClick={() => onChange({ kind: "view", view: v.id })} + /> +
+
+ + {v.meaning} + +
+ ), + }; + }) + : []; + return (