feat: thread campaign follow-ups on the A/B arm each contact was actually sent by recording every send's rendered subject on campaign_tasks.subject (migration 000243) and reusing it verbatim in ThreadParentForLead, and list the opening step's variant subjects in the follow-up composer

This commit is contained in:
Matthew Meszaros
2026-10-01 22:41:11 -07:00
parent bada69a408
commit bce968646f
15 changed files with 364 additions and 28 deletions
@@ -500,7 +500,7 @@ Add a variant to the campaign (or to one step via `step_id`). **Scope** `WRITE_C
| `name` | string | yes | Variant label. |
| `step_id` | uuid | no | Step to scope the variant to, omit for campaign-level. |
| `weight` | integer | no | Relative selection weight (1-100). Shares are these weights normalized across the active arms, so two arms at equal weight split evenly. |
| `subject` | string | no | Variant subject template. |
| `subject` | string | no | Variant subject template. Blank reuses the step's. Ignored when the step replies in the contact's thread (`thread_reply`), which carries the conversation's subject. When the step opens a conversation, the follow-ups threading on it reply under the subject of whichever arm each contact was sent. |
| `body_html` | string | no | Variant HTML body. |
| `body_plain` | string | no | Variant plain-text body. |
| `is_control` | boolean | no | Mark this as the step's control arm. For a step-scoped test, create one `is_control` row to set the Original's share; its `weight` is the Original's share and its content is ignored (the step's own email is sent when the control wins). |
+3 -2
View File
@@ -166,7 +166,8 @@ A follow-up is the next email step, sent as a **reply on the conversation the co
Each email step has a **Reply in thread** switch, on by default. Turn it off for a step that should deliberately start a fresh conversation.
- A step that replies in the thread has no subject of its own: a reply carries the conversation's subject, which is the one the step that opened the thread used. The composer shows that subject instead of a subject box.
- A step that replies in the thread has no subject of its own: a reply carries the conversation's subject, which is the one the email that opened the thread was sent with. The composer shows that subject instead of a subject box.
- The conversation follows what each contact actually received. When the step that opened the thread is A/B tested, a contact who got Variant B is replied to under Variant B's subject and a contact who got the Original under the Original's, on every follow-up. The composer lists each version's subject. A follow-up repeats the subject the opening email was actually sent with, word for word, so editing the opening step or the contact later does not change the subject of a conversation it already started.
- A step with **Reply in thread** off writes its own subject and opens a new conversation. Every threading step after it then replies on *that* conversation.
- The switch does nothing on a contact's first email. There is nothing to reply to, so it opens the thread.
- A/B variants on a threading step vary the body only, for the same reason.
@@ -182,7 +183,7 @@ Open a step's editor and click **A/B test this step**.
- The step's own subject and body are the **Original** control arm; adding a variant makes it one weighted arm among several.
- Up to 5 variants (B through F) on top of the Original.
- The **traffic-split bar** shows one segment per arm. Drag a divider or type an exact percent; shares always total 100%, and **Even split** balances every active arm.
- Select an arm to edit its name, subject, and body. Leave a variant's subject or body blank to reuse the step's own. On a step that replies in the thread, a variant's subject is ignored: the conversation owns it.
- Select an arm to edit its name, subject, and body. Leave a variant's subject or body blank to reuse the step's own. On a step that replies in the thread, a variant's subject is ignored: the conversation owns it. On a step that opens a conversation, the variant's subject becomes the conversation's for every contact it is sent to, so the follow-ups threading on it reply under that subject.
- A first variant starts at 50/50. Marking one **inactive** drops it from the split without deleting it; deleting the last one returns the step to a single send.
Once variants have sent, each card shows Sent, Open rate, Reply rate, and Bounce rate. When Warmbly finds a **winner**, a banner names it with the rule and confidence used, and the winning card gets a trophy.
@@ -0,0 +1,2 @@
ALTER TABLE campaign_tasks
DROP COLUMN IF EXISTS subject;
@@ -0,0 +1,5 @@
-- The subject a campaign send carried, rendered: a follow-up threads on what the
-- contact was actually sent, A/B arm included. NULL on older rows, which fall
-- back to walking the steps.
ALTER TABLE campaign_tasks
ADD COLUMN IF NOT EXISTS subject text;
@@ -250,3 +250,65 @@ func TestLiveThreadParentStopsAtADeletedStep(t *testing.T) {
t.Errorf("conversation subject = %q, want none: the step that sent it is gone", p.Subject)
}
}
// An A/B arm can replace the opening step's subject, so the conversation is
// whichever arm the contact got, read off the send rather than the step
// (issue #774): a contact sent Variant B is replied to under Variant B's.
func TestLiveThreadParentFollowsTheArmTheContactWasSent(t *testing.T) {
_, pool := liveContactDB(t)
f := newThreadParentFixture(t, pool)
first := f.step(0, "Original subject", false)
second := f.step(1, "", true)
opener := f.send(first, f.mailbox, "<one@test.local>", "thr-1", 120)
f.exec(`UPDATE campaign_tasks SET subject = 'Variant B subject' WHERE task_id = $1`, opener)
if p := f.parent(t); p == nil || p.Subject != "Variant B subject" {
t.Fatalf("conversation subject = %+v, want the arm the opener sent", p)
}
// The reply recorded the subject it inherited, so the step after it
// threads on the same arm even once the opener's step is gone.
reply := f.send(second, f.mailbox, "<two@test.local>", "thr-1", 60)
f.exec(`UPDATE campaign_tasks SET subject = 'Variant B subject' WHERE task_id = $1`, reply)
f.exec(`DELETE FROM sequences WHERE id = $1`, first)
p := f.parent(t)
if p == nil || p.MessageID != "<two@test.local>" {
t.Fatalf("parent = %+v, want the reply", p)
}
if p.Subject != "Variant B subject" {
t.Errorf("conversation subject = %q, want the arm carried through the reply", p.Subject)
}
}
// A send from before subjects were recorded is walked through to the step
// that opened the thread, and a recorded opener behind it still wins.
func TestLiveThreadParentWalksUnrecordedSendsToARecordedOpener(t *testing.T) {
_, pool := liveContactDB(t)
f := newThreadParentFixture(t, pool)
first := f.step(0, "Original subject", false)
second := f.step(1, "", true)
opener := f.send(first, f.mailbox, "<one@test.local>", "thr-1", 120)
f.exec(`UPDATE campaign_tasks SET subject = 'Variant B subject' WHERE task_id = $1`, opener)
f.send(second, f.mailbox, "<two@test.local>", "thr-1", 60)
if p := f.parent(t); p == nil || p.Subject != "Variant B subject" {
t.Fatalf("conversation subject = %+v, want the opener's recorded arm", p)
}
}
// A blank recorded subject records nothing, so the walk still reaches the
// step that opened the conversation.
func TestLiveThreadParentPassesABlankRecordedSubject(t *testing.T) {
_, pool := liveContactDB(t)
f := newThreadParentFixture(t, pool)
first := f.step(0, "Quick question", false)
second := f.step(1, "", true)
f.send(first, f.mailbox, "<one@test.local>", "thr-1", 120)
reply := f.send(second, f.mailbox, "<two@test.local>", "thr-1", 60)
f.exec(`UPDATE campaign_tasks SET subject = '' WHERE task_id = $1`, reply)
p := f.parent(t)
if p == nil || p.Subject != "Quick question" || p.SubjectSent {
t.Fatalf("parent = %+v, want the opener's template walked to", p)
}
}
+20 -6
View File
@@ -115,10 +115,14 @@ type ThreadParent struct {
// only means something inside the mailbox that owns it, so a follow-up
// leaving from a different address must not carry it.
SenderID uuid.UUID
// Subject is the conversation's subject, unrendered: the template of the
// step that opened the thread, not the parent's own. A reply carries it,
// and Gmail refuses to file a message in a thread it does not match.
// Subject is the conversation's subject: the one the opening send carried
// (its A/B arm's when one replaced the step's), not the parent's own. A
// reply carries it, and Gmail refuses to file a message in a thread it
// does not match.
Subject string
// SubjectSent marks Subject as recorded off a send, already rendered for
// this contact; otherwise it is the opening step's template.
SubjectSent bool
}
// CampaignProgressRepository defines methods for campaign progress tracking
@@ -680,9 +684,12 @@ const threadParentScan = 50
// Action and wait nodes never reach the tracking stamp that writes a
// campaign_tasks row, and never carry a Message-ID, so they cannot be picked
// as a parent.
//
// The conversation's subject is read off the sends themselves where they
// recorded it, and walked from the steps only for sends from before they did.
func (r *campaignProgressRepository) ThreadParentForLead(ctx context.Context, campaignID, contactID uuid.UUID) (*ThreadParent, error) {
rows, err := r.db.Query(ctx, `
SELECT t.message_id, t.thread_id, t.email_account_id,
SELECT t.message_id, t.thread_id, t.email_account_id, ct.subject,
s.id IS NOT NULL, COALESCE(s.subject, ''), COALESCE(s.thread_reply, true)
FROM campaign_tasks ct
JOIN tasks t ON t.id = ct.task_id
@@ -700,18 +707,25 @@ func (r *campaignProgressRepository) ThreadParentForLead(ctx context.Context, ca
var parent *ThreadParent
var subject string
var recorded bool
for rows.Next() {
var (
messageID, threadID, stepSubject string
sentSubject *string
senderID uuid.UUID
stepKnown, threadReply bool
)
if err := rows.Scan(&messageID, &threadID, &senderID, &stepKnown, &stepSubject, &threadReply); err != nil {
if err := rows.Scan(&messageID, &threadID, &senderID, &sentSubject, &stepKnown, &stepSubject, &threadReply); err != nil {
return nil, err
}
if parent == nil {
parent = &ThreadParent{MessageID: messageID, ThreadID: threadID, SenderID: senderID}
}
// A recorded subject is the conversation's as sent, whichever A/B arm it came from.
if sentSubject != nil && *sentSubject != "" {
subject, recorded = *sentSubject, true
break
}
// A step that has since been deleted (campaign_tasks.sequence_id is
// ON DELETE SET NULL) says nothing about whether it opened a thread or
// joined one, so the walk cannot pass it. Stopping with no subject is
@@ -737,7 +751,7 @@ func (r *campaignProgressRepository) ThreadParentForLead(ctx context.Context, ca
if parent == nil {
return nil, nil
}
parent.Subject = subject
parent.Subject, parent.SubjectSent = subject, recorded
return parent, nil
}
+12 -2
View File
@@ -182,6 +182,9 @@ type TaskRepository interface {
// Update campaign task with contact/sequence IDs (for tracking)
UpdateCampaignTaskTracking(ctx context.Context, taskID, contactID, sequenceID uuid.UUID) error
// UpdateCampaignTaskSubject records the subject a campaign send carries,
// which is what a follow-up threads on. Blank records nothing.
UpdateCampaignTaskSubject(ctx context.Context, taskID uuid.UUID, subject string) error
// ListScheduledInOrg returns every pending email task scheduled
// from the organization's mailboxes, ordered by next-to-fire. Used
@@ -1156,11 +1159,12 @@ func (r *taskRepository) UpdateTaskReplyTo(ctx context.Context, taskID uuid.UUID
// UpdateCampaignTaskTracking updates the campaign task with contact_id and sequence_id
// This is called when the task is processed and we know which contact/sequence to send to
// These IDs are needed for tracking pixel/click events to record progress
// These IDs are needed for tracking pixel/click events to record progress.
// A retry may send a different pair, so the recorded subject is cleared with it.
func (r *taskRepository) UpdateCampaignTaskTracking(ctx context.Context, taskID, contactID, sequenceID uuid.UUID) error {
query := `
UPDATE campaign_tasks
SET contact_id = $2, sequence_id = $3
SET contact_id = $2, sequence_id = $3, subject = NULL
WHERE task_id = $1
`
@@ -1168,6 +1172,12 @@ func (r *taskRepository) UpdateCampaignTaskTracking(ctx context.Context, taskID,
return err
}
// UpdateCampaignTaskSubject records the rendered subject a campaign send carries.
func (r *taskRepository) UpdateCampaignTaskSubject(ctx context.Context, taskID uuid.UUID, subject string) error {
_, err := r.db.Exec(ctx, `UPDATE campaign_tasks SET subject = CASE WHEN btrim($2) = '' THEN NULL ELSE $2 END WHERE task_id = $1`, taskID, subject)
return err
}
// ListScheduledInOrg returns the organization's user-initiated email tasks
// still in 'pending' state, ordered by scheduled_at. Joins tasks → email_tasks
// → email_accounts so callers don't need three lookups per row.
+10
View File
@@ -608,6 +608,10 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) (result *errx
subject := expandSpintax(RenderTemplateWith(rawSubject, *contact, extra))
bodyHTML := expandSpintax(RenderTemplateWith(rawBodyHTML, *contact, extra))
bodyPlain := expandSpintax(RenderTemplateWith(rawBodyPlain, *contact, extra))
// A recorded conversation subject is already rendered: reuse it verbatim.
if threadParent != nil && threadParent.SubjectSent && threadSubject != "" {
subject = threadSubject
}
// If no plain text provided, extract from HTML
if bodyPlain == "" && bodyHTML != "" {
@@ -668,6 +672,12 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) (result *errx
}
}
// Follow-ups thread on exactly this subject; a failed write falls back to the step's.
if err := s.taskRepo.UpdateCampaignTaskSubject(ctx, taskID, subject); err != nil {
log.Warn().Err(err).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).
Msg("Could not record the subject this send carries")
}
// STEP 10.6: A plain-text campaign ships no HTML part at all. Tracking
// below only rewrites HTML, so dropping it here is what makes the
// setting's "disables tracking" promise true.
+101 -2
View File
@@ -239,12 +239,16 @@ func TestLiveThreadHandleIsDroppedWhenTheConversationSubjectIsGone(t *testing.T)
f.tick(t, svc)
f.confirmSend(t, handle.Pool, "<opener@test.local>", "gmail-thread-1")
// The step that opened the conversation is gone, so its subject cannot be
// read off anything.
// The opener was sent before sends recorded their subject, and its step's
// has since been blanked, so the subject cannot be read off anything.
if _, err := handle.Pool.Exec(context.Background(),
`UPDATE sequences SET subject = '' WHERE id = $1`, f.step); err != nil {
t.Fatalf("blank the opener's subject: %v", err)
}
if _, err := handle.Pool.Exec(context.Background(),
`UPDATE campaign_tasks SET subject = NULL WHERE campaign_id = $1`, f.campaign); err != nil {
t.Fatalf("forget the opener's recorded subject: %v", err)
}
f.tick(t, svc)
followUp := sender.message(t, 1)
@@ -255,3 +259,98 @@ func TestLiveThreadHandleIsDroppedWhenTheConversationSubjectIsGone(t *testing.T)
t.Errorf("follow-up thread = %q, want none: the subject cannot be matched", followUp.ThreadID)
}
}
// The subject a send recorded outlives an edit to the step that wrote it: the
// recipient already has the old one, so the reply keeps it and the thread.
func TestLiveThreadFollowUpKeepsTheSubjectTheOpenerWasSentWith(t *testing.T) {
handle := liveCampaignDB(t)
sender := &recordingSender{}
svc := liveCampaignService(t, handle, sender)
f := newCampaignSendFixture(t, handle.Pool)
f.addFollowUp(t, handle.Pool, "", true)
f.tick(t, svc)
f.confirmSend(t, handle.Pool, "<opener@test.local>", "gmail-thread-1")
if _, err := handle.Pool.Exec(context.Background(),
`UPDATE sequences SET subject = 'Edited later' WHERE id = $1`, f.step); err != nil {
t.Fatalf("edit the opener's subject: %v", err)
}
f.tick(t, svc)
followUp := sender.message(t, 1)
if followUp.Subject != "Hi" {
t.Errorf("follow-up subject = %q, want the one the contact was sent", followUp.Subject)
}
if followUp.ThreadID != "gmail-thread-1" {
t.Errorf("follow-up thread = %q, want the conversation the opener landed in", followUp.ThreadID)
}
}
// A contact sent the opener's Variant B is replied to under Variant B's
// subject, not the step's own (issue #774). The Original's weight is 1 against
// a million so the deterministic split puts the fixture's contact on B.
func TestLiveThreadFollowUpRepliesUnderTheArmTheContactGot(t *testing.T) {
handle := liveCampaignDB(t)
sender := &recordingSender{}
svc := liveCampaignService(t, handle, sender)
f := newCampaignSendFixture(t, handle.Pool)
f.addFollowUp(t, handle.Pool, "", true)
if _, err := handle.Pool.Exec(context.Background(), `
INSERT INTO campaign_ab_variants (campaign_id, sequence_id, name, weight, subject, body_html, body_plain, is_control, is_active)
VALUES ($1, $2, 'Original', 1, '', '', '', true, true),
($1, $2, 'Variant B', 1000000, 'Variant B subject', '<p>B</p>', 'B', false, true)`,
f.campaign, f.step); err != nil {
t.Fatalf("variants: %v", err)
}
f.tick(t, svc)
if got := sender.message(t, 0).Subject; got != "Variant B subject" {
t.Fatalf("opener subject = %q, want Variant B's", got)
}
f.confirmSend(t, handle.Pool, "<opener@test.local>", "gmail-thread-1")
f.tick(t, svc)
followUp := sender.message(t, 1)
if followUp.Subject != "Variant B subject" {
t.Errorf("follow-up subject = %q, want the arm the contact was sent", followUp.Subject)
}
if followUp.InReplyTo != "<opener@test.local>" {
t.Errorf("follow-up in_reply_to = %q, want the opener's Message-ID", followUp.InReplyTo)
}
if followUp.ThreadID != "gmail-thread-1" {
t.Errorf("follow-up thread = %q, want the conversation the opener landed in", followUp.ThreadID)
}
}
// A follow-up repeats the opener's subject as it was rendered, so a merge field
// that changed since (or a spintax group re-rolled) cannot break the match
// Gmail threads on.
func TestLiveThreadFollowUpRepeatsTheRenderedSubject(t *testing.T) {
handle := liveCampaignDB(t)
sender := &recordingSender{}
svc := liveCampaignService(t, handle, sender)
f := newCampaignSendFixture(t, handle.Pool)
f.addFollowUp(t, handle.Pool, "", true)
ctx := context.Background()
if _, err := handle.Pool.Exec(ctx, `UPDATE sequences SET subject = 'Hi {{.FirstName}}' WHERE id = $1`, f.step); err != nil {
t.Fatalf("templated subject: %v", err)
}
f.tick(t, svc)
if got := sender.message(t, 0).Subject; got != "Hi Live" {
t.Fatalf("opener subject = %q, want it rendered", got)
}
f.confirmSend(t, handle.Pool, "<opener@test.local>", "gmail-thread-1")
if _, err := handle.Pool.Exec(ctx, `UPDATE contacts SET first_name = 'Renamed' WHERE id = $1`, f.contact); err != nil {
t.Fatalf("rename contact: %v", err)
}
f.tick(t, svc)
followUp := sender.message(t, 1)
if followUp.Subject != "Hi Live" {
t.Errorf("follow-up subject = %q, want the opener's as it was sent", followUp.Subject)
}
if followUp.ThreadID != "gmail-thread-1" {
t.Errorf("follow-up thread = %q, want the conversation the opener landed in", followUp.ThreadID)
}
}
@@ -98,7 +98,7 @@ import buildError from "@/lib/helper/buildError";
import EntryDelayPicker from "@/components/app/campaigns/schedule/EntryDelayPicker";
import { entryDelayLabel } from "@/components/app/campaigns/schedule/entryDelay";
import StepEmailArms from "./StepEmailArms";
import { conversationSubjectFor } from "./threading";
import { conversationOpenerFor, conversationSubjectFor } from "./threading";
import CategoryPicker from "@/components/app/contacts/CategoryPicker";
import { SegmentMultiPicker } from "@/components/app/segments/SegmentPickers";
import type { ActionKV, AITagRef, SequenceAction, SequenceActionType } from "@/lib/api/models/app/campaigns/sequences/Action";
@@ -2223,6 +2223,7 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) {
sequence={editStep}
index={editIndex}
conversationSubject={conversationSubjectFor(sequences, editIndex)}
conversationOpener={conversationOpenerFor(sequences, editIndex)}
/>
)}
</div>
@@ -44,6 +44,7 @@ import { VARIABLES, htmlToPlain, linkifyUnsubscribe, promptToHtml, renderPreview
import { LINK_VARIABLES, UNSUBSCRIBE_TOKEN } from "@/lib/templateVars";
import useCampaign from "@/lib/api/hooks/app/campaigns/useCampaign";
import { isDocumentBody } from "@/lib/email/pastedEmail";
import type { ArmSubject } from "./threading";
export default function EmailContentEditor({
subject,
@@ -72,8 +73,10 @@ export default function EmailContentEditor({
subjectPlaceholder?: string;
// A step that replies in the contact's thread has no subject of its own:
// a reply carries the conversation's. Pass the conversation's subject to
// show it read-only in place of the field.
subjectLocked?: { subject: string; note: string };
// show it read-only in place of the field. `alternates` are the subjects
// the opening email's A/B variants carry, which a contact sent one of is
// replied to under instead.
subjectLocked?: { subject: string; note: string; alternates?: ArmSubject[] };
bodyPlaceholder?: string;
// When set, the preview renders for a chosen lead and mailbox and shows the
// campaign's opt-out footer, signature and attachments.
@@ -283,10 +286,26 @@ export default function EmailContentEditor({
</div>
{subjectLocked ? (
<>
<div className="h-9 px-3 flex items-center rounded-md border border-slate-200 bg-slate-50 text-[13.5px] text-slate-500">
<div className="h-9 px-3 flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50 text-[13.5px] text-slate-500">
{!!subjectLocked.alternates?.length && (
<span className="shrink-0 text-[11px] font-medium text-slate-400">Original</span>
)}
<span className="truncate">{subjectLocked.subject || "No subject"}</span>
</div>
<p className="mt-1.5 text-[10.5px] text-slate-400">{subjectLocked.note}</p>
{subjectLocked.alternates?.map((arm) => (
<div
key={arm.subject}
className="mt-1 h-9 px-3 flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50 text-[13.5px] text-slate-500"
>
<span className="shrink-0 text-[11px] font-medium text-slate-400">{arm.name}</span>
<span className="truncate">{arm.subject}</span>
</div>
))}
<p className="mt-1.5 text-[10.5px] text-slate-400">
{subjectLocked.note}
{!!subjectLocked.alternates?.length &&
" The email that opened the thread is A/B tested, so each contact is replied to under the subject of the version they got."}
</p>
</>
) : (
<TextInput value={subject} onChange={onSubjectChange} placeholder={subjectPlaceholder} className="w-full h-9 px-3 text-[13.5px]" />
@@ -10,6 +10,7 @@ import toast from "react-hot-toast";
import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence";
import EmailContentEditor from "./EmailContentEditor";
import StepAttachments from "./StepAttachments";
import type { ArmSubject } from "./threading";
import { Label, TextInput } from "@/components/ui/field";
import { SettingRow, Toggle } from "@/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox";
import useUpdateSequence from "@/lib/api/hooks/app/campaigns/sequences/useUpdateSequence";
@@ -38,6 +39,7 @@ export default function SequenceView({
sequence,
index,
conversationSubject = null,
conversationArms,
embedded = false,
headerExtra,
}: {
@@ -49,6 +51,8 @@ export default function SequenceView({
// replies in the thread, because that is what the recipient reads. `null`
// means there is no earlier email to reply to, so the switch is hidden.
conversationSubject?: string | null;
// The other subjects the opener's A/B variants carry; see openerArmSubjects.
conversationArms?: ArmSubject[];
// When embedded inside the tabbed arms editor, drop the outer card chrome
// and the "Step N" eyebrow (the tab already provides that context).
embedded?: boolean;
@@ -175,6 +179,7 @@ export default function SequenceView({
? {
subject: conversationSubject ?? "",
note: "A reply carries the conversation's subject. Turn off Reply in thread to write your own.",
alternates: conversationArms,
}
: undefined
}
@@ -18,6 +18,7 @@ import EmailContentEditor from "./EmailContentEditor";
import { htmlToPlain } from "./emailPreview";
import SequenceView from "./SequenceView";
import StepSplitAllocator, { type SplitArm } from "./StepSplitAllocator";
import { openerArmSubjects, type ArmSubject } from "./threading";
import {
useCampaignABVariants,
useCampaignABAnalysis,
@@ -42,14 +43,22 @@ export default function StepEmailArms({
sequence,
index,
conversationSubject = null,
conversationOpener = null,
}: {
campaignId: string;
sequence: Sequence;
index: number;
// The subject of the conversation this step replies on; see SequenceView.
conversationSubject?: string | null;
// The step that opened that conversation, whose A/B variants can carry
// other subjects a contact is replied to under.
conversationOpener?: Sequence | null;
}) {
const { data: all } = useCampaignABVariants(campaignId);
const conversationArms = React.useMemo(
() => openerArmSubjects(conversationOpener, all ?? []),
[conversationOpener, all],
);
const stepRows = (all ?? []).filter((v) => v.step_id === sequence.id);
const controlRow = stepRows.find((v) => v.is_control) ?? null;
const variants = stepRows.filter((v) => !v.is_control);
@@ -198,6 +207,7 @@ export default function StepEmailArms({
sequence={sequence}
index={index}
conversationSubject={conversationSubject}
conversationArms={conversationArms}
headerExtra={
variants.length === 0 ? (
<button
@@ -227,6 +237,7 @@ export default function StepEmailArms({
onTogglePause={(active) => togglePause(selectedVariant.id, active)}
onDelete={() => deleteArm(selectedVariant.id)}
inheritedSubject={sequence.thread_reply ? conversationSubject || null : null}
inheritedArms={conversationArms}
/>
)}
</div>
@@ -242,6 +253,7 @@ function VariantEditor({
onTogglePause,
onDelete,
inheritedSubject,
inheritedArms,
}: {
campaignId: string;
variant: ABVariant;
@@ -254,6 +266,7 @@ function VariantEditor({
// to the conversation, so no arm can carry one of its own and the send
// path ignores the column. Null when the step writes its own subject.
inheritedSubject?: string | null;
inheritedArms?: ArmSubject[];
}) {
const update = useUpdateABVariant(campaignId);
@@ -345,6 +358,7 @@ function VariantEditor({
? {
subject: inheritedSubject,
note: "This step replies in the contact's thread, so every arm carries the conversation's subject and varies the body only.",
alternates: inheritedArms,
}
: undefined
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence";
import type ABVariant from "@/lib/api/models/app/campaigns/ABVariant";
import { conversationOpenerFor, conversationSubjectFor, openerArmSubjects } from "./threading";
const step = (id: string, subject: string, thread_reply = true, kind: Sequence["kind"] = "email") =>
({ id, subject, thread_reply, kind }) as Sequence;
const variant = (step_id: string | null, name: string, subject: string, extra: Partial<ABVariant> = {}) =>
({ id: name, step_id, name, subject, is_active: true, is_control: false, ...extra }) as ABVariant;
describe("conversationOpenerFor", () => {
const steps = [step("a", "Opener", false), step("w", "", true, "wait"), step("b", ""), step("c", "Fresh", false), step("d", "")];
it("walks back past replies and control nodes to the step that opened the thread", () => {
expect(conversationOpenerFor(steps, 2)?.id).toBe("a");
expect(conversationOpenerFor(steps, 4)?.id).toBe("c");
expect(conversationSubjectFor(steps, 2)).toBe("Opener");
});
it("has no opener for the first email", () => {
expect(conversationOpenerFor(steps, 0)).toBeNull();
expect(conversationSubjectFor(steps, 0)).toBeNull();
});
});
describe("openerArmSubjects", () => {
const opener = step("a", "Opener", false);
it("lists the subjects the opener's own variants send instead of its own", () => {
const arms = openerArmSubjects(opener, [
variant("a", "Variant B", "Other angle"),
variant("a", "Variant C", ""),
variant("a", "Variant D", "Opener"),
variant("a", "Paused", "Paused angle", { is_active: false }),
variant("a", "Original", "Ignored", { is_control: true }),
variant("b", "Elsewhere", "Another step"),
variant(null, "Campaign-wide", "Shadowed by the step's own"),
]);
expect(arms).toEqual([{ name: "Variant B", subject: "Other angle" }]);
});
it("lets the opener's own control row shadow campaign-wide variants", () => {
const arms = openerArmSubjects(opener, [
variant("a", "Original", "", { is_control: true }),
variant(null, "Variant B", "Campaign angle"),
]);
expect(arms).toEqual([]);
});
it("falls back to campaign-wide variants when the opener has none", () => {
expect(openerArmSubjects(opener, [variant(null, "Variant B", "Campaign angle")])).toEqual([
{ name: "Variant B", subject: "Campaign angle" },
]);
});
it("is empty without an opener", () => {
expect(openerArmSubjects(null, [variant("a", "Variant B", "Other angle")])).toEqual([]);
});
});
@@ -1,14 +1,13 @@
import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence";
import type ABVariant from "@/lib/api/models/app/campaigns/ABVariant";
/**
* conversationSubjectFor is the subject a step at `index` would send when it
* replies in the contact's thread: the subject of the step that opened that
* conversation. Walking back, every step that also replies passes the question
* on; the first that does not is the one that opened the thread.
* conversationOpenerFor is the step that opened the conversation a step at
* `index` would reply on. Walking back, every step that also replies passes
* the question on; the first that does not is the one that opened the thread.
*
* `null` means there is no earlier email step, so this one opens the
* conversation whatever its switch says. An empty string means the opener
* itself has no subject yet.
* conversation whatever its switch says.
*
* It mirrors the walk the send path does over the contact's actual sends, so
* the composer shows what the recipient will read. It follows the canvas order
@@ -17,13 +16,48 @@ import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence";
* send path resolves it per contact from what they were actually sent, so this
* is a preview, and for a linear sequence the two always agree.
*/
export function conversationSubjectFor(steps: Sequence[], index: number): string | null {
let subject: string | null = null;
export function conversationOpenerFor(steps: Sequence[], index: number): Sequence | null {
let opener: Sequence | null = null;
for (let i = index - 1; i >= 0; i--) {
const step = steps[i];
if (step.kind !== "email") continue;
subject = step.subject;
opener = step;
if (!step.thread_reply) break;
}
return subject;
return opener;
}
/**
* conversationSubjectFor is the subject a step at `index` would send when it
* replies in the contact's thread: the opener's own. `null` when there is no
* earlier email step; an empty string when the opener has no subject yet.
*/
export function conversationSubjectFor(steps: Sequence[], index: number): string | null {
return conversationOpenerFor(steps, index)?.subject ?? null;
}
export interface ArmSubject {
name: string;
subject: string;
}
/**
* openerArmSubjects lists the subjects the opener's A/B variants send in place
* of its own, which each contact sent one is replied to under. Mirrors
* SelectVariant: any active row on the opener (its control included) shadows
* the campaign-wide variants, and a blank variant subject reuses the step's.
*/
export function openerArmSubjects(opener: Sequence | null, variants: ABVariant[]): ArmSubject[] {
if (!opener) return [];
const active = variants.filter((v) => v.is_active);
const own = active.filter((v) => v.step_id === opener.id);
const arms = (own.length > 0 ? own : active.filter((v) => !v.step_id)).filter((v) => !v.is_control);
const seen = new Set([opener.subject]);
const out: ArmSubject[] = [];
for (const v of arms) {
if (!v.subject || seen.has(v.subject)) continue;
seen.add(v.subject);
out.push({ name: v.name, subject: v.subject });
}
return out;
}