diff --git a/web/src/styles/app.css b/web/src/styles/app.css
index 5c8bdbb..d5a9c32 100644
--- a/web/src/styles/app.css
+++ b/web/src/styles/app.css
@@ -607,6 +607,8 @@ img { max-width: 100%; }
.rule-card.disabled { opacity: .6; }
.rule-row { display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: 8px; align-items: center; margin-bottom: 8px; }
.rule-row.actions { grid-template-columns: 1fr 2fr auto; }
+/* A header typed by hand needs a box of its own, alongside the comparator. */
+.rule-row.named-header { grid-template-columns: 1fr 1fr 1fr 1fr auto; }
.code { font-family: var(--font-mono); font-size: 12.5px; line-height: 1.5; white-space: pre; overflow: auto; background: var(--bg-sunken); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px; min-height: 240px; width: 100%; resize: vertical; tab-size: 2; }
.shortcut-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px 32px; }
.shortcut-grid h3 { margin: 0 0 6px; font-size: .9em; text-transform: uppercase; letter-spacing: .05em; color: var(--fg-faint); }
diff --git a/web/src/views/settings/RuleDialog.tsx b/web/src/views/settings/RuleDialog.tsx
index f134877..4f20cbd 100644
--- a/web/src/views/settings/RuleDialog.tsx
+++ b/web/src/views/settings/RuleDialog.tsx
@@ -45,41 +45,47 @@ export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault
- {r.tests.map((t, i) => (
-
-
- {t.type === "header" && !HEADER_CHOICES.some((h) => h.value === t.header && h.value !== "__custom__") ? (
-
setTest(i, { ...t, header: e.target.value })} />
- ) : t.type === "size" ? (
-
- ) : t.type === "body" ? (
-
- ) : t.type === "true" ?
: (
-
+ );
+ })}
Then
diff --git a/web/src/views/settings/__tests__/rule-dialog-header.test.tsx b/web/src/views/settings/__tests__/rule-dialog-header.test.tsx
new file mode 100644
index 0000000..8022f4b
--- /dev/null
+++ b/web/src/views/settings/__tests__/rule-dialog-header.test.tsx
@@ -0,0 +1,67 @@
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { RuleDialog } from "../RuleDialog";
+import { newRule } from "@/lib/sieve";
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+/**
+ * Choosing "Other header…" used to put the header-name box in the column the
+ * comparator lived in, so the comparator vanished: whatever it happened to be
+ * (contains) was what you were stuck with. Both belong in the row.
+ */
+describe("RuleDialog custom headers", () => {
+ let host: HTMLDivElement;
+ let root: Root;
+
+ /** The condition row's own selects: [field, comparator]. */
+ const selects = () => Array.from(document.querySelectorAll(".rule-row:not(.actions) select"));
+ const find = (sel: string) => document.querySelector(sel);
+ const pick = (el: HTMLSelectElement, value: string) => act(() => {
+ el.value = value;
+ el.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+
+ beforeEach(() => {
+ host = document.createElement("div");
+ document.body.appendChild(host);
+ root = createRoot(host);
+ });
+ afterEach(() => {
+ act(() => root.unmount());
+ host.remove();
+ });
+
+ const render = () => act(() => {
+ root.render( undefined} onSave={() => undefined} />);
+ });
+
+ it("keeps the comparator when a header is typed by hand", () => {
+ render();
+ // [field, comparator] — the rule starts on "from contains".
+ expect(selects()).toHaveLength(2);
+ pick(selects()[0]!, "__custom__");
+
+ const header = find('input[aria-label="Header name"]') as HTMLInputElement | null;
+ expect(header).not.toBeNull();
+ expect(header!.value).toBe("");
+ const ops = selects()[1]!;
+ expect(ops.value).toBe("contains");
+ expect(Array.from(ops.options).map((o) => o.value)).toContain("matches");
+
+ pick(ops, "matches");
+ expect(selects()[1]!.value).toBe("matches");
+ // The header box is still there, and still has a column of its own.
+ expect(find('input[aria-label="Header name"]')).not.toBeNull();
+ expect(find(".rule-row.named-header")).not.toBeNull();
+ });
+
+ it("leaves a listed header alone", () => {
+ render();
+ expect(find('input[aria-label="Header name"]')).toBeNull();
+ expect(find(".rule-row.named-header")).toBeNull();
+ pick(selects()[1]!, "is");
+ expect(selects()[1]!.value).toBe("is");
+ });
+});