Compose: a dock of windows that survive the app around them

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
2026-09-21 21:32:41 +02:00
co-authored by Claude Opus 5
parent e4a521ae20
commit 9f9e959a05
7 changed files with 987 additions and 249 deletions
+289 -209
View File
@@ -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<string | null>(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 */}
<div className="flex items-center gap-1.5 border-b border-app-border bg-app-surface px-3 py-2">
<button
type="button"
onClick={send}
disabled={status === "sending" || (!to && !cc)}
className="flex items-center gap-2 rounded-md bg-app-accent px-3 py-1.5 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 1.5L7.2 8.8" />
<path d="M14.5 1.5L9.8 14.5 7.2 8.8 1.5 6.2 14.5 1.5z" />
</svg>
{status === "sending" ? t("compose.sending") : t("compose.send")}
</button>
<HeaderButton onClick={() => fileInputRef.current?.click()} title={t("compose.attach")}>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.5 6.5l-5.2 5.2a2.5 2.5 0 0 1-3.5-3.5l5.4-5.4a1.6 1.6 0 0 1 2.3 2.3l-5.3 5.3a0.8 0.8 0 0 1-1.1-1.1l4.9-4.9" />
</svg>
{t("compose.attach")}
</HeaderButton>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files && e.target.files.length) void addFiles(e.target.files);
e.target.value = "";
}}
/>
<HeaderButton
onClick={() => setShowToolbar((v) => !v)}
title={t("compose.fmtBold")}
active={showToolbar}
>
<span className="text-sm font-semibold underline">A</span>
</HeaderButton>
<div className="relative" ref={moreRef}>
<HeaderButton onClick={() => 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.
<button
type="button"
onClick={onMinimize}
title={t("compose.restore")}
className="min-w-0 flex-1 truncate py-1.5 text-left text-sm font-medium text-app-fg"
>
{subject.trim() || t("compose.newMessage")}
</button>
) : (
<>
<button
type="button"
onClick={send}
disabled={status === "sending" || (!to && !cc)}
className="flex items-center gap-2 rounded-md bg-app-accent px-3 py-1.5 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 1.5L7.2 8.8" />
<path d="M14.5 1.5L9.8 14.5 7.2 8.8 1.5 6.2 14.5 1.5z" />
</svg>
{status === "sending" ? t("compose.sending") : t("compose.send")}
</button>
<HeaderButton onClick={() => fileInputRef.current?.click()} title={t("compose.attach")}>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.5 6.5l-5.2 5.2a2.5 2.5 0 0 1-3.5-3.5l5.4-5.4a1.6 1.6 0 0 1 2.3 2.3l-5.3 5.3a0.8 0.8 0 0 1-1.1-1.1l4.9-4.9" />
</svg>
{t("compose.attach")}
</HeaderButton>
{moreOpen && (
<div className="absolute left-0 top-full z-30 mt-1 w-44 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 text-sm shadow-xl">
<button
className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
onClick={() => {
setMoreOpen(false);
setShowCc(true);
}}
>
{t("compose.cc")} / {t("compose.bcc")}
</button>
<button
className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover disabled:opacity-40"
disabled={!signature}
onClick={() => {
setMoreOpen(false);
editorRef.current?.insertSignature(signature);
}}
>
{t("compose.insertSignature")}
</button>
<button
className="block w-full px-3 py-1.5 text-left text-red-500 hover:bg-app-hover"
onClick={() => {
setMoreOpen(false);
void discard();
}}
>
{t("compose.discard")}
</button>
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files && e.target.files.length) void addFiles(e.target.files);
e.target.value = "";
}}
/>
<HeaderButton
onClick={() => setShowToolbar((v) => !v)}
title={t("compose.fmtBold")}
active={showToolbar}
>
<span className="text-sm font-semibold underline">A</span>
</HeaderButton>
<div className="relative" ref={moreRef}>
<HeaderButton onClick={() => setMoreOpen((v) => !v)} title={t("compose.more")}>
{t("compose.more")}
</HeaderButton>
{moreOpen && (
<div className="absolute left-0 top-full z-30 mt-1 w-44 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 text-sm shadow-xl">
<button
className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
onClick={() => {
setMoreOpen(false);
setShowCc(true);
}}
>
{t("compose.cc")} / {t("compose.bcc")}
</button>
<button
className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover disabled:opacity-40"
disabled={!signature}
onClick={() => {
setMoreOpen(false);
editorRef.current?.insertSignature(signature);
}}
>
{t("compose.insertSignature")}
</button>
<button
className="block w-full px-3 py-1.5 text-left text-red-500 hover:bg-app-hover"
onClick={() => {
setMoreOpen(false);
void discard();
}}
>
{t("compose.discard")}
</button>
</div>
)}
</div>
</>
)}
<div className="ml-auto flex items-center gap-1 text-xs text-app-faint">
{status === "saving" && <span className="mr-1">{t("compose.savingDraft")}</span>}
{status === "saved" && <span className="mr-1">{t("compose.draftSaved")}</span>}
{status === "error" && <span className="mr-1 text-red-500">{t("compose.sendFailed")}</span>}
{!minimized && status === "saving" && <span className="mr-1">{t("compose.savingDraft")}</span>}
{!minimized && status === "saved" && <span className="mr-1">{t("compose.draftSaved")}</span>}
{!minimized && status === "error" && (
<span className="mr-1 text-red-500">{t("compose.sendFailed")}</span>
)}
{!fullWindow && onMinimize && (
<HeaderButton
onClick={onMinimize}
title={minimized ? t("compose.restore") : t("compose.minimize")}
>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
{minimized ? (
<path d="M4 10l4-4 4 4" strokeLinejoin="round" />
) : (
<path d="M3 12h10" />
)}
</svg>
</HeaderButton>
)}
{!fullWindow && onToggleExpand && (
<HeaderButton
onClick={onToggleExpand}
@@ -636,7 +704,11 @@ export function Composer({
active={expanded}
>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 2H2v4M10 2h4v4M6 14H2v-4M10 14h4v-4" />
{expanded ? (
<path d="M2 6h4V2M14 6h-4V2M2 10h4v4M14 10h-4v4" />
) : (
<path d="M6 2H2v4M10 2h4v4M6 14H2v-4M10 14h4v-4" />
)}
</svg>
</HeaderButton>
)}
@@ -655,138 +727,146 @@ export function Composer({
</div>
</div>
{/* From */}
<div
className="relative flex items-center gap-2 border-b border-app-border px-3 py-2"
ref={fromRef}
>
<span className="w-10 shrink-0 text-xs font-semibold text-app-fg">{t("compose.from")}</span>
<button
type="button"
onClick={() => accounts.length > 1 && setFromOpen((v) => !v)}
className="flex items-center gap-1.5 rounded px-1 py-0.5 text-sm hover:bg-app-hover"
{/* Hidden rather than unmounted while collapsed: the editor seeds its
contentEditable once, so dropping this subtree would lose the body. */}
<div className={minimized ? "hidden" : "contents"}>
{/* From */}
<div
className="relative flex items-center gap-2 border-b border-app-border px-3 py-2"
ref={fromRef}
>
{fromName && <span className="font-medium text-app-accent">{fromName}</span>}
<span className="text-app-muted">{fromName ? `<${fromEmail}>` : fromEmail}</span>
{accounts.length > 1 && <span className="text-app-faint">{caret}</span>}
</button>
<div className="ml-auto flex items-center gap-1 text-xs text-app-muted">
<Icon name="reply" size={13} />
{t("compose.replyLabel")}
</div>
{fromOpen && (
<div className="absolute left-12 top-full z-30 mt-1 w-80 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 shadow-xl">
{accounts.map((a) => (
<button
key={a.id}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-app-hover"
onClick={() => {
markDirty();
setAccountId(a.id);
setFromOpen(false);
}}
>
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: a.color }} />
{a.display_name && <span className="font-medium text-app-fg">{a.display_name}</span>}
<span className="text-app-muted">{a.email}</span>
</button>
))}
<span className="w-10 shrink-0 text-xs font-semibold text-app-fg">{t("compose.from")}</span>
<button
type="button"
onClick={() => accounts.length > 1 && setFromOpen((v) => !v)}
className="flex items-center gap-1.5 rounded px-1 py-0.5 text-sm hover:bg-app-hover"
>
{fromName && <span className="font-medium text-app-accent">{fromName}</span>}
<span className="text-app-muted">{fromName ? `<${fromEmail}>` : fromEmail}</span>
{accounts.length > 1 && <span className="text-app-faint">{caret}</span>}
</button>
<div className="ml-auto flex items-center gap-1 text-xs text-app-muted">
<Icon name="reply" size={13} />
{t("compose.replyLabel")}
</div>
)}
</div>
{fromOpen && (
<div className="absolute left-12 top-full z-30 mt-1 w-80 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 shadow-xl">
{accounts.map((a) => (
<button
key={a.id}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-app-hover"
onClick={() => {
markDirty();
setAccountId(a.id);
setFromOpen(false);
}}
>
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: a.color }} />
{a.display_name && <span className="font-medium text-app-fg">{a.display_name}</span>}
<span className="text-app-muted">{a.email}</span>
</button>
))}
</div>
)}
</div>
{/* Recipients */}
<RecipientChips
label={t("compose.to")}
value={to}
onChange={(v) => {
markDirty();
setTo(v);
}}
autoFocus={init.kind === "new" || init.kind === "forward"}
rightSlot={
!showCc ? (
<button
type="button"
className="text-xs text-app-muted hover:text-app-fg"
onClick={() => setShowCc(true)}
>
{t("compose.cc")} {t("compose.bcc")}
</button>
) : undefined
}
/>
{showCc && (
<>
<RecipientChips
label={t("compose.cc")}
value={cc}
onChange={(v) => {
markDirty();
setCc(v);
}}
/>
<RecipientChips
label={t("compose.bcc")}
value={bcc}
onChange={(v) => {
markDirty();
setBcc(v);
}}
/>
</>
)}
<input
className="border-b border-app-border bg-transparent px-3 py-2 text-sm text-app-fg outline-none placeholder:text-app-faint"
placeholder={t("compose.subject")}
value={subject}
onChange={(e) => {
markDirty();
setSubject(e.target.value);
}}
/>
{(attachments.length > 0 || attachError) && (
<div className="flex flex-wrap items-center gap-2 border-b border-app-border px-3 py-2">
{attachments.map((att) => (
<span
key={att.id}
className="flex items-center gap-1.5 rounded border border-app-border bg-app-hover px-2 py-1 text-xs text-app-fg"
>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.5 6.5l-5.2 5.2a2.5 2.5 0 0 1-3.5-3.5l5.4-5.4a1.6 1.6 0 0 1 2.3 2.3l-5.3 5.3a0.8 0.8 0 0 1-1.1-1.1l4.9-4.9" />
</svg>
<span className="max-w-[12rem] truncate">{att.filename}</span>
<span className="text-app-faint">{formatBytes(att.size_bytes)}</span>
{/* Recipients */}
<RecipientChips
label={t("compose.to")}
value={to}
onChange={(v) => {
markDirty();
setTo(v);
}}
autoFocus={init.kind === "new" || init.kind === "forward"}
rightSlot={
!showCc ? (
<button
type="button"
onClick={() => removeAttachment(att.id)}
className="text-app-faint hover:text-app-fg"
aria-label={`remove ${att.filename}`}
className="text-xs text-app-muted hover:text-app-fg"
onClick={() => setShowCc(true)}
>
✕
{t("compose.cc")} {t("compose.bcc")}
</button>
</span>
))}
{attachError && <span className="text-xs text-red-500">{attachError}</span>}
</div>
)}
{/* Body — wrapped so a drop here is detected as the writing area. */}
<div ref={editorAreaRef} className="flex min-h-0 flex-1 flex-col">
{bodyReady ? (
<RichTextEditor
ref={editorRef}
initialHtml={initialHtml}
onInput={(html) => {
markDirty();
setBodyHtml(html);
}}
showToolbar={showToolbar}
/>
) : (
<div className="min-h-[250px] flex-1 px-3 py-3 text-sm text-app-faint">…</div>
) : undefined
}
/>
{showCc && (
<>
<RecipientChips
label={t("compose.cc")}
value={cc}
onChange={(v) => {
markDirty();
setCc(v);
}}
/>
<RecipientChips
label={t("compose.bcc")}
value={bcc}
onChange={(v) => {
markDirty();
setBcc(v);
}}
/>
</>
)}
<input
className="border-b border-app-border bg-transparent px-3 py-2 text-sm text-app-fg outline-none placeholder:text-app-faint"
placeholder={t("compose.subject")}
value={subject}
onChange={(e) => {
markDirty();
setSubject(e.target.value);
}}
/>
{(attachments.length > 0 || attachError) && (
<div className="flex flex-wrap items-center gap-2 border-b border-app-border px-3 py-2">
{attachments.map((att) => (
<span
key={att.id}
className="flex items-center gap-1.5 rounded border border-app-border bg-app-hover px-2 py-1 text-xs text-app-fg"
>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.5 6.5l-5.2 5.2a2.5 2.5 0 0 1-3.5-3.5l5.4-5.4a1.6 1.6 0 0 1 2.3 2.3l-5.3 5.3a0.8 0.8 0 0 1-1.1-1.1l4.9-4.9" />
</svg>
<span className="max-w-[12rem] truncate">{att.filename}</span>
<span className="text-app-faint">{formatBytes(att.size_bytes)}</span>
<button
type="button"
onClick={() => removeAttachment(att.id)}
className="text-app-faint hover:text-app-fg"
aria-label={`remove ${att.filename}`}
>
✕
</button>
</span>
))}
{attachError && <span className="text-xs text-red-500">{attachError}</span>}
</div>
)}
{/* Body — wrapped so a drop here is detected as the writing area. */}
<div ref={editorAreaRef} className="flex min-h-0 flex-1 flex-col">
{bodyReady ? (
<RichTextEditor
ref={editorRef}
initialHtml={initialHtml}
onInput={(html) => {
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}
/>
) : (
<div className="min-h-[250px] flex-1 px-3 py-3 text-sm text-app-faint">…</div>
)}
</div>
</div>
</div>
);