From 9f9e959a0566c119a9861f41d6a75723d0e097bc Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Mon, 21 Sep 2026 21:32:41 +0200 Subject: [PATCH] Compose: a dock of windows that survive the app around them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking Compose with a window already open cleared what you had typed. MailLayout bumped a React key on every open to force a fresh editor, and switching folders ran an effect that closed the composer outright. Both were deliberate when only one composer could exist at a time. Underneath them sat a sharper constraint: RichTextEditor seeds its contentEditable exactly once, so ANY remount silently discards the body. Every part of this change is shaped by keeping a composer mounted. Windows now live in one ordered list, each with a stable id used as its React key, rendered by ComposeDock as a single keyed array — open windows and collapsed title bars together. Splitting the open one into a child slot of its own would hand its state to whichever window took its place, which is exactly what happened the first time around. - A minimize control, first of the four window icons, collapses a window to a title bar showing its subject (or "New message"). The subtree is hidden, never unmounted. - Clicking Compose parks the open windows and appends a new one, so each click pushes the previous window one place left. - Restoring a window touches only that window; several can be open at once. When they stop fitting, the least recently reached-for ones are DRAWN as bars — state untouched, so they spring back open when room returns. - Expanding matches the reading pane, measured live, and moves the window to the end of the row: the row is right-anchored, so a bar after it would shove an expanded window off the pane and across the list and sidebar. - A window is given its height rather than sizing to its content. Left to itself it grew with the body until the top ran off the screen, and the `flex-1` body never became scrollable because there was no leftover space to divide. - Leaving a folder un-expands a window instead of closing it. - The dock row is pointer-events-none — its box spans the tallest window in it, and as a bare div it shielded the mail list beside it. - Drafts opened from the Drafts folder now load in the reading pane, keyed by draft id, with the open one marked in the list. Also fixes a blank draft saved for every composer opened: the signature effect guarded itself with a "first run" flag, which StrictMode's double invocation spends on the first pass. The second pass rewrote the signature of an untouched composer, marking it dirty. Keyed on the account instead, so it is idempotent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo --- .../src/components/compose/ComposeDock.tsx | 150 ++++++ frontend/src/components/compose/Composer.tsx | 498 ++++++++++-------- frontend/src/components/compose/windows.ts | 209 ++++++++ frontend/src/components/list/DraftsList.tsx | 15 +- frontend/src/i18n/en.ts | 3 + frontend/src/routes/MailLayout.tsx | 145 +++-- frontend/src/test/composeWindows.test.ts | 216 ++++++++ 7 files changed, 987 insertions(+), 249 deletions(-) create mode 100644 frontend/src/components/compose/ComposeDock.tsx create mode 100644 frontend/src/components/compose/windows.ts create mode 100644 frontend/src/test/composeWindows.test.ts diff --git a/frontend/src/components/compose/ComposeDock.tsx b/frontend/src/components/compose/ComposeDock.tsx new file mode 100644 index 0000000..02bcbd0 --- /dev/null +++ b/frontend/src/components/compose/ComposeDock.tsx @@ -0,0 +1,150 @@ +import { useEffect, useRef, useState } from "react"; + +import { useT } from "../../i18n"; +import { Composer } from "./Composer"; +import { CHIP_W, type ComposeWin, DOCK_INSET, layoutDock } from "./windows"; + +function useViewportSize() { + const read = () => + typeof window === "undefined" + ? { vw: 1280, vh: 800 } + : { vw: window.innerWidth, vh: window.innerHeight }; + const [size, setSize] = useState(read); + useEffect(() => { + const onResize = () => setSize(read()); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + return size; +} + +/** + * The bottom-of-screen dock: composers side by side, each either a full window + * or a collapsed title bar, in a row that never reorders. + * + * Every window here stays mounted for as long as it is open — collapsed, and + * even pushed off the end of the row into the overflow menu, it is hidden with + * CSS and never unmounted. That is not a detail: `RichTextEditor` seeds its + * contentEditable exactly once, so an unmount-and-remount silently throws away + * whatever the user had typed. Stable `id`s as React keys keep it that way when + * the list around a window changes. + */ +export function ComposeDock({ + windows, + pane, + onMinimize, + onRestore, + onToggleExpand, + onClose, +}: { + windows: ComposeWin[]; + /** Live geometry of the reading pane; an expanded window is laid over it. */ + pane: { width: number; right: number }; + onMinimize: (id: string) => void; + onRestore: (id: string) => void; + onToggleExpand: (id: string) => void; + onClose: (id: string) => void; +}) { + const t = useT(); + const { vw, vh } = useViewportSize(); + const [menuOpen, setMenuOpen] = useState(false); + const [titles, setTitles] = useState>({}); + const menuRef = useRef(null); + + useEffect(() => { + if (!menuOpen) return; + const onDown = (e: MouseEvent) => { + if (!menuRef.current?.contains(e.target as Node)) setMenuOpen(false); + }; + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, [menuOpen]); + + if (windows.length === 0) return null; + + const { items, overflow } = layoutDock(vw, vh, windows, pane.width); + + const title = (w: ComposeWin) => + (titles[w.id] ?? w.init.subject ?? "").trim() || t("compose.newMessage"); + const noteTitle = (id: string, s: string) => + setTitles((prev) => (prev[id] === s ? prev : { ...prev, [id]: s })); + + return ( + // The row's box spans the tallest window in it, so a bare div here would be + // an invisible shield over the mail list beside it. Only the windows + // themselves take the pointer. +
+ {overflow > 0 && ( +
+ {menuOpen && ( +
+ {items + .filter((i) => i.hidden) + .map(({ win }) => ( + + ))} +
+ )} + +
+ )} + {/* ONE keyed array for every window, open and collapsed alike. React + reconciles by key within a single child list, so a window keeps its + component instance — and its typed body — as it moves along the row. + Splitting the open window into a child slot of its own would hand its + state to whatever window took its place. */} + {items.map(({ win, open, hidden, width, height }) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/compose/Composer.tsx b/frontend/src/components/compose/Composer.tsx index ed7d6dd..f6b57b8 100644 --- a/frontend/src/components/compose/Composer.tsx +++ b/frontend/src/components/compose/Composer.tsx @@ -113,14 +113,33 @@ export function Composer({ onClose, expanded = false, onToggleExpand, + minimized = false, + onMinimize, + onTitle, + fitContainer = false, fullWindow = false, }: { init: ComposerInit; onClose: () => void; expanded?: boolean; onToggleExpand?: () => void; + // Collapsed to a title bar in the dock. The whole subtree stays mounted — + // everything below the header is hidden with CSS, never unmounted, because + // RichTextEditor seeds its contentEditable once and a remount would throw the + // typed body away. + minimized?: boolean; + onMinimize?: () => void; + // Reports the live subject so the dock's overflow menu can name a window by + // what was typed into it, not by the (static) subject it was opened with. + onTitle?: (title: string) => void; + // The container fixes this composer's height, so a long body scrolls instead + // of pushing the window taller. False only for the inline reply inside a + // thread, which grows with what you write and scrolls with the thread. + fitContainer?: boolean; fullWindow?: boolean; }) { + // Whether something other than the content decides how tall this is. + const sized = fullWindow || fitContainer; const t = useT(); const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts }); const accounts: AccountOut[] = accountsQuery.data ?? []; @@ -154,6 +173,13 @@ export function Composer({ return () => window.clearInterval(id); }, [undo]); const [showClosePrompt, setShowClosePrompt] = useState(false); + // Through a ref so a fresh callback identity on every parent render can't turn + // this into a render loop. + const onTitleRef = useRef(onTitle); + onTitleRef.current = onTitle; + useEffect(() => { + onTitleRef.current?.(subject); + }, [subject]); const draftId = useRef(init.draftId ?? null); const sentRef = useRef(false); // send initiated -> don't mirror to Drafts on close // Whether the user actually edited anything. Merely opening a draft to view it @@ -168,7 +194,7 @@ export function Composer({ // Close the From / More dropdowns on any click outside them. useEffect(() => { - if (!moreOpen && !fromOpen) return; + if (minimized || (!moreOpen && !fromOpen)) return; const onDown = (e: MouseEvent) => { const target = e.target as Node; if (moreOpen && moreRef.current && !moreRef.current.contains(target)) setMoreOpen(false); @@ -176,7 +202,7 @@ export function Composer({ }; document.addEventListener("mousedown", onDown); return () => document.removeEventListener("mousedown", onDown); - }, [moreOpen, fromOpen]); + }, [moreOpen, fromOpen, minimized]); const account = accounts.find((a) => a.id === accountId); const fromName = account?.display_name || ""; @@ -184,12 +210,15 @@ export function Composer({ const signature = account?.signature ?? ""; // When the From account changes, swap the signature block for the new one. - const firstAccount = useRef(true); + // + // Keyed on the account rather than a "first run" flag: under StrictMode the + // mount effect runs twice, so a flag is already spent by the second pass, + // which then rewrites the signature of an untouched composer — marking it + // dirty and leaving a blank draft behind for every window opened. + const lastAccount = useRef(init.accountId); useEffect(() => { - if (firstAccount.current) { - firstAccount.current = false; - return; - } + if (lastAccount.current === accountId) return; + lastAccount.current = accountId; editorRef.current?.replaceSignature(accounts.find((a) => a.id === accountId)?.signature ?? ""); // eslint-disable-next-line react-hooks/exhaustive-deps }, [accountId]); @@ -447,8 +476,14 @@ export function Composer({ // X button: if there's a draft worth keeping, ask before closing; otherwise // (nothing typed, no draft) just close. const requestClose = () => { - if (dirtyRef.current || draftId.current) setShowClosePrompt(true); - else onClose(); + if (!dirtyRef.current && !draftId.current) { + onClose(); + return; + } + // The prompt is an overlay on this window; in a collapsed title bar there is + // nowhere to draw it. Restore first, then ask. + if (minimized) onMinimize?.(); + setShowClosePrompt(true); }; const saveDraftAndClose = async () => { @@ -511,7 +546,9 @@ export function Composer({ className={`relative flex flex-col overflow-hidden border-app-border bg-app-elevated ${ fullWindow ? "h-full" - : `rounded-xl border shadow-2xl ${expanded ? "h-full" : ""}` + : minimized + ? "rounded-t-xl border-x border-t shadow-2xl" + : `rounded-xl border shadow-2xl ${sized ? "h-full" : ""}` }`} > {dragKind && ( @@ -553,82 +590,113 @@ export function Composer({ )} {/* Header: Send · Attach · formatting toggle · More · window controls */}
- - fileInputRef.current?.click()} title={t("compose.attach")}> - - - - {t("compose.attach")} - - { - if (e.target.files && e.target.files.length) void addFiles(e.target.files); - e.target.value = ""; - }} - /> - setShowToolbar((v) => !v)} - title={t("compose.fmtBold")} - active={showToolbar} - > - A - -
- setMoreOpen((v) => !v)} title={t("compose.more")}> - {t("compose.more")} + {minimized ? ( + // Collapsed: the subject stands in for the action buttons, and the whole + // bar is the restore target. + + ) : ( + <> + + fileInputRef.current?.click()} title={t("compose.attach")}> + + + + {t("compose.attach")} - {moreOpen && ( -
- - - -
- )} -
+ { + if (e.target.files && e.target.files.length) void addFiles(e.target.files); + e.target.value = ""; + }} + /> + setShowToolbar((v) => !v)} + title={t("compose.fmtBold")} + active={showToolbar} + > + A + +
+ setMoreOpen((v) => !v)} title={t("compose.more")}> + {t("compose.more")} + + {moreOpen && ( +
+ + + +
+ )} +
+ + )}
- {status === "saving" && {t("compose.savingDraft")}} - {status === "saved" && {t("compose.draftSaved")}} - {status === "error" && {t("compose.sendFailed")}} + {!minimized && status === "saving" && {t("compose.savingDraft")}} + {!minimized && status === "saved" && {t("compose.draftSaved")}} + {!minimized && status === "error" && ( + {t("compose.sendFailed")} + )} + {!fullWindow && onMinimize && ( + + + {minimized ? ( + + ) : ( + + )} + + + )} {!fullWindow && onToggleExpand && ( - + {expanded ? ( + + ) : ( + + )} )} @@ -655,138 +727,146 @@ export function Composer({
- {/* From */} -
- {t("compose.from")} - -
- - {t("compose.replyLabel")} -
- {fromOpen && ( -
- {accounts.map((a) => ( - - ))} + {t("compose.from")} + +
+ + {t("compose.replyLabel")}
- )} -
+ {fromOpen && ( +
+ {accounts.map((a) => ( + + ))} +
+ )} +
- {/* Recipients */} - { - markDirty(); - setTo(v); - }} - autoFocus={init.kind === "new" || init.kind === "forward"} - rightSlot={ - !showCc ? ( - - ) : undefined - } - /> - {showCc && ( - <> - { - markDirty(); - setCc(v); - }} - /> - { - markDirty(); - setBcc(v); - }} - /> - - )} - { - markDirty(); - setSubject(e.target.value); - }} - /> - - {(attachments.length > 0 || attachError) && ( -
- {attachments.map((att) => ( - - - - - {att.filename} - {formatBytes(att.size_bytes)} + {/* Recipients */} + { + markDirty(); + setTo(v); + }} + autoFocus={init.kind === "new" || init.kind === "forward"} + rightSlot={ + !showCc ? ( - - ))} - {attachError && {attachError}} -
- )} - - {/* Body — wrapped so a drop here is detected as the writing area. */} -
- {bodyReady ? ( - { - markDirty(); - setBodyHtml(html); - }} - showToolbar={showToolbar} - /> - ) : ( -
…
+ ) : undefined + } + /> + {showCc && ( + <> + { + markDirty(); + setCc(v); + }} + /> + { + markDirty(); + setBcc(v); + }} + /> + )} + { + markDirty(); + setSubject(e.target.value); + }} + /> + + {(attachments.length > 0 || attachError) && ( +
+ {attachments.map((att) => ( + + + + + {att.filename} + {formatBytes(att.size_bytes)} + + + ))} + {attachError && {attachError}} +
+ )} + + {/* Body — wrapped so a drop here is detected as the writing area. */} +
+ {bodyReady ? ( + { + markDirty(); + setBodyHtml(html); + }} + showToolbar={showToolbar} + // With a height to divide, the body takes what is left and + // scrolls. A floor here would instead push the window past that + // height — and `overflow-hidden` would clip the difference. + minHeight={sized ? "min-h-0" : undefined} + /> + ) : ( +
…
+ )} +
); diff --git a/frontend/src/components/compose/windows.ts b/frontend/src/components/compose/windows.ts new file mode 100644 index 0000000..012429d --- /dev/null +++ b/frontend/src/components/compose/windows.ts @@ -0,0 +1,209 @@ +// The compose-window list and the dock's layout arithmetic, kept out of the +// components so both can be reasoned about — and tested — on their own. + +import type { ComposerInit } from "./Composer"; + +export interface ComposeWin { + id: string; + init: ComposerInit; + minimized: boolean; + expanded: boolean; + /** + * Bumped whenever the window is opened or restored. Several windows can be + * open at once; when they no longer fit side by side, the least recently + * activated ones give way — so the window you just reached for is the one + * that stays on screen. + */ + activated: number; +} + +/** + * Add a window, parking whatever was open beside it rather than replacing it. + * + * Reopening a draft that is already in the dock brings that window back instead + * of starting a second editor: two composers on one draft would race each + * other's autosave. + */ +export function openWindow( + prev: ComposeWin[], + id: string, + init: ComposerInit, + expand: boolean, + seq: number, +): ComposeWin[] { + const already = init.draftId ? prev.find((w) => w.init.draftId === init.draftId) : undefined; + if (already) { + return prev.map((w) => + w.id === already.id + ? { ...w, minimized: false, expanded: w.expanded || expand, activated: seq } + : w, + ); + } + return [ + ...prev.map((w) => ({ ...w, minimized: true, expanded: false })), + { id, init, minimized: false, expanded: expand, activated: seq }, + ]; +} + +/** + * Bring one window back. Deliberately the only window this touches: whatever + * else was on the row — open or collapsed — is left exactly as the user had it. + */ +export function restoreWindow(prev: ComposeWin[], id: string, seq: number): ComposeWin[] { + return prev.map((w) => (w.id === id ? { ...w, minimized: false, activated: seq } : w)); +} + +export function minimizeWindow(prev: ComposeWin[], id: string): ComposeWin[] { + return prev.map((w) => (w.id === id ? { ...w, minimized: true, expanded: false } : w)); +} + +/** + * Expanding is a focus mode — the window claims the row, so the others park. + * Collapsing back out of it leaves everyone alone. + * + * Expanding also moves the window to the end of the row. The row is anchored to + * the right edge, so any title bar sitting after a window pushes it leftwards: + * expanded from the middle it would slide off the reading pane and sprawl over + * the list and sidebar. Last is the only position where an expanded window + * lands where it belongs. It keeps that place after collapsing rather than + * springing back, so the row stays where you last left it. + */ +export function toggleExpand(prev: ComposeWin[], id: string, seq: number): ComposeWin[] { + const target = prev.find((w) => w.id === id); + if (!target) return prev; + const expanded = !target.expanded; + const next = prev.map((w) => { + if (w.id === id) return { ...w, minimized: false, expanded, activated: seq }; + return expanded ? { ...w, minimized: true, expanded: false } : w; + }); + if (!expanded) return next; + const i = next.findIndex((w) => w.id === id); + return i === next.length - 1 ? next : [...next.slice(0, i), ...next.slice(i + 1), next[i]]; +} + +/** Escape, and anything else that wants the windows out of the way but kept. */ +export function minimizeAll(prev: ComposeWin[]): ComposeWin[] { + return prev.map((w) => (w.minimized ? w : { ...w, minimized: true, expanded: false })); +} + +/** + * Leaving a folder un-expands an expanded window so the reading pane shows the + * folder you navigated to. Returns `prev` untouched when there is nothing to do, + * so it is safe to call from an effect. + */ +export function collapseExpanded(prev: ComposeWin[]): ComposeWin[] { + return prev.some((w) => w.expanded) + ? prev.map((w) => (w.expanded ? { ...w, expanded: false, minimized: true } : w)) + : prev; +} + +// ── dock layout ───────────────────────────────────────────────────────────── + +const WINDOW_W = 760; // 47.5rem, the width the composer has always had +const WINDOW_H = 520; // 32.5rem +// Fallback for an expanded window before the reading pane has been measured. +const EXPANDED_W = 1112; // 69.5rem +const EDGE = 8; // the sliver of desktop left above an expanded window +const OPEN_MIN = 420; // below this a composer is too cramped to write in +// A bar carries all four window icons (~140px) plus the header's own padding, +// so anything under ~16rem truncates the subject to a letter or two. Below that +// floor a bar is worth less than a slot in the overflow menu. +const BAR_MAX = 320; +const BAR_MIN = 256; +const GAP = 8; +/** The dock's inset from the right edge of the screen (Tailwind `right-4`). */ +export const DOCK_INSET = 16; +const GUTTER = 2 * DOCK_INSET; // that much air on each side of the row +export const CHIP_W = 56; + +export interface DockItem { + win: ComposeWin; + /** Drawn as a full composer rather than a title bar. */ + open: boolean; + /** Tucked into the overflow menu — still mounted, just not on the row. */ + hidden: boolean; + width: number; + /** + * Fixed pixel height for an open window, `null` for a title bar (which is + * as tall as its own header). A window must be told its height: left to size + * itself it grows with the body until the top runs off the screen, and the + * editor — sized with `flex-1` — never becomes scrollable, because there is + * no leftover space for it to divide. + */ + height: number | null; +} + +export interface DockLayout { + /** Every window, in row order. Positions never move as windows open and close. */ + items: DockItem[]; + overflow: number; +} + +/** + * @param paneW Live width of the reading pane. An expanded window matches it, + * so expanding tracks the sidebar/list resizers and the viewport + * instead of standing at one hard-coded width. + */ +export function layoutDock( + vw: number, + vh: number, + windows: ComposeWin[], + paneW = 0, +): DockLayout { + const budget = Math.max(0, vw - GUTTER); + const wantOpen = windows.filter((w) => !w.minimized); + + // How many open windows fit side by side at their floor width? The rest are + // drawn as title bars with their state untouched, so they spring back open as + // soon as one closes or the window is widened. + let fitting = wantOpen.length; + while (fitting > 1 && fitting * OPEN_MIN + (fitting - 1) * GAP > budget) fitting -= 1; + const open = new Set( + [...wantOpen] + .sort((a, b) => b.activated - a.activated) + .slice(0, fitting) + .map((w) => w.id), + ); + + const shown = wantOpen.filter((w) => open.has(w.id)); + // Anything that didn't make the cut needs a chip to reach it by, and on a + // narrow screen that room has to come out of the windows themselves — on a + // wide one it costs nothing, since a window never wants more than WINDOW_W. + const reserve = windows.length > fitting ? CHIP_W + GAP : 0; + const openBudget = Math.max(0, budget - reserve); + const expandedW = paneW > 0 ? paneW : EXPANDED_W; + const want = (w: ComposeWin) => Math.min(w.expanded ? expandedW : WINDOW_W, openBudget); + const gaps = Math.max(0, shown.length - 1) * GAP; + const roomy = shown.reduce((n, w) => n + want(w), 0) + gaps <= openBudget; + const share = shown.length > 0 ? Math.floor((openBudget - gaps) / shown.length) : 0; + const widthOf = (w: ComposeWin) => (roomy ? want(w) : Math.min(want(w), share)); + const openTotal = shown.reduce((n, w) => n + widthOf(w), 0) + gaps; + + // Whatever width is left belongs to the title bars; the leftmost ones fall + // into the overflow menu when even that runs out. + const bars = windows.filter((w) => !open.has(w.id)); + const barFit = (space: number) => Math.max(0, Math.floor((space + GAP) / (BAR_MIN + GAP))); + let avail = budget - (shown.length > 0 ? openTotal + GAP : 0); + let barsShown = Math.min(bars.length, barFit(avail)); + if (barsShown < bars.length) { + avail -= CHIP_W + GAP; + barsShown = Math.min(bars.length, barFit(avail)); + } + const hidden = new Set(bars.slice(0, bars.length - barsShown).map((w) => w.id)); + const barW = + barsShown > 0 ? Math.min(BAR_MAX, Math.floor((avail - GAP * (barsShown - 1)) / barsShown)) : 0; + + const heightOf = (w: ComposeWin) => + Math.max(0, Math.min(w.expanded ? vh : WINDOW_H, vh - EDGE)); + + return { + items: windows.map((w) => ({ + win: w, + open: open.has(w.id), + hidden: hidden.has(w.id), + width: open.has(w.id) ? widthOf(w) : barW, + height: open.has(w.id) ? heightOf(w) : null, + })), + overflow: hidden.size, + }; +} diff --git a/frontend/src/components/list/DraftsList.tsx b/frontend/src/components/list/DraftsList.tsx index 6fcb95b..dea93c8 100644 --- a/frontend/src/components/list/DraftsList.tsx +++ b/frontend/src/components/list/DraftsList.tsx @@ -15,6 +15,7 @@ function DraftRow({ preview, time, badge, + selected, onClick, }: { recipients: string; @@ -22,12 +23,15 @@ function DraftRow({ preview: string; time: string; badge?: string; + selected: boolean; onClick: () => void; }) { return (