Seth commited on
Commit
b187543
·
1 Parent(s): 187f183
backend/app/models.py CHANGED
@@ -1,5 +1,5 @@
1
  from pydantic import BaseModel, Field, field_validator
2
- from typing import Optional, List, Dict, Literal
3
  from datetime import datetime
4
 
5
 
@@ -14,7 +14,7 @@ class PromptSaveRequest(BaseModel):
14
  prompts: Dict[str, str]
15
  products: List[str]
16
  linkedin_prompts: Dict[str, str] = Field(default_factory=dict)
17
- sequence_plan: Optional[List[Dict]] = Field(default=None)
18
 
19
 
20
  class SequenceResponse(BaseModel):
 
1
  from pydantic import BaseModel, Field, field_validator
2
+ from typing import Optional, List, Dict, Literal, Any, Union
3
  from datetime import datetime
4
 
5
 
 
14
  prompts: Dict[str, str]
15
  products: List[str]
16
  linkedin_prompts: Dict[str, str] = Field(default_factory=dict)
17
+ sequence_plan: Optional[Union[List[Dict], Dict[str, Any]]] = Field(default=None)
18
 
19
 
20
  class SequenceResponse(BaseModel):
backend/app/outreach_routes.py CHANGED
@@ -156,22 +156,109 @@ def _unipile_call(method: str, path: str, payload: Optional[dict] = None) -> Tup
156
  return resp.status_code, data
157
 
158
 
159
- def _normalize_plan_steps(raw: Any) -> List[dict]:
160
- if raw is None:
161
- return []
162
- if isinstance(raw, dict) and isinstance(raw.get("steps"), list):
163
- return [x for x in raw["steps"] if isinstance(x, dict)]
164
- if isinstance(raw, list):
165
- return [x for x in raw if isinstance(x, dict)]
166
- return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
 
168
 
169
- def build_ordered_actions(steps: List[dict]) -> List[dict]:
170
  """Flatten sequence plan to sendable actions with global step_order (matches GeneratedSequence.step_order).
171
 
172
- ``wait_days_before`` is the sum of wizard ``wait`` steps immediately preceding this action (calendar days
173
- after the previous slot completes before this slot may send).
 
174
  """
 
 
175
  out: List[dict] = []
176
  ord_global = 0
177
  wait_accum = 0
@@ -200,6 +287,7 @@ def build_ordered_actions(steps: List[dict]) -> List[dict]:
200
  "action": act,
201
  "account_ref_id": ref_i,
202
  "wait_days_before": int(wait_accum),
 
203
  }
204
  )
205
  wait_accum = 0
@@ -267,6 +355,8 @@ def _wait_eligible_for_slot(
267
  slot: int,
268
  prev_rec: Optional[OutreachSendReceipt],
269
  now: datetime,
 
 
270
  ) -> bool:
271
  try:
272
  wd = int(act.get("wait_days_before") or 0)
@@ -274,7 +364,10 @@ def _wait_eligible_for_slot(
274
  wd = 0
275
  if wd <= 0:
276
  return True
277
- if slot <= 0:
 
 
 
278
  anchor = _delivery_started_at_naive(ex) or now
279
  return now >= anchor + timedelta(days=wd)
280
  if not prev_rec or not prev_rec.created_at:
@@ -492,6 +585,8 @@ def _find_next_send(
492
  contacts: List[Contact],
493
  actions: List[dict],
494
  ex: FileOutreachExecution,
 
 
495
  ) -> Optional[Tuple[Contact, int, dict]]:
496
  n_actions = len(actions)
497
  if not contacts or not actions:
@@ -505,19 +600,30 @@ def _find_next_send(
505
  for slot in range(n_actions):
506
  if _receipt_for(db, tenant_id, file_id, c.id, slot):
507
  continue
 
 
508
  ok = True
509
  prev_rec: Optional[OutreachSendReceipt] = None
510
  for ps in range(slot):
511
  pr = _receipt_for(db, tenant_id, file_id, c.id, ps)
512
- if not pr or pr.status not in ("sent", "failed", "skipped"):
513
- ok = False
514
- break
515
- if ps == slot - 1:
 
 
 
 
516
  prev_rec = pr
 
 
 
 
 
 
517
  if not ok:
518
  continue
519
- act = actions[slot]
520
- if not _wait_eligible_for_slot(ex, act, slot, prev_rec, now):
521
  continue
522
  if act.get("channel") == "linkedin" and act.get("action") == "linkedin_dm":
523
  if not _linkedin_dm_interval_eligible(c, li_h, now):
@@ -751,12 +857,13 @@ def start_campaign_execution(
751
 
752
  raw_plan = getattr(uf, "sequence_plan_json", None)
753
  steps: List[dict] = []
 
754
  if raw_plan and str(raw_plan).strip():
755
  try:
756
- steps = _normalize_plan_steps(json.loads(raw_plan))
757
  except Exception:
758
- steps = []
759
- actions = build_ordered_actions(steps)
760
  contacts = (
761
  db.query(Contact)
762
  .filter(
@@ -866,14 +973,15 @@ def tick_campaign_execution(
866
  if ex.status != "running":
867
  return get_campaign_execution(file_id, tc)
868
 
869
- steps = []
 
870
  raw = getattr(uf, "sequence_plan_json", None)
871
  if raw and str(raw).strip():
872
  try:
873
- steps = _normalize_plan_steps(json.loads(raw))
874
  except Exception:
875
- steps = []
876
- actions = build_ordered_actions(steps)
877
  contacts = (
878
  db.query(Contact)
879
  .filter(
@@ -893,7 +1001,9 @@ def tick_campaign_execution(
893
 
894
  n = max(1, min(int(limit or 3), 25))
895
  for _ in range(n):
896
- nxt = _find_next_send(db, int(tc.tenant_id), file_id, contacts, actions, ex)
 
 
897
  if not nxt:
898
  break
899
  c, slot, act = nxt
 
156
  return resp.status_code, data
157
 
158
 
159
+ def load_campaign_plan_from_json(raw_plan: Optional[Any]) -> Tuple[List[dict], bool]:
160
+ """Parse uploaded_files.sequence_plan_json: steps list + optional parallel_tracks flag."""
161
+ if raw_plan is None:
162
+ return [], False
163
+ if isinstance(raw_plan, str):
164
+ if not raw_plan.strip():
165
+ return [], False
166
+ try:
167
+ parsed: Any = json.loads(raw_plan)
168
+ except Exception:
169
+ return [], False
170
+ else:
171
+ parsed = raw_plan
172
+ parallel = False
173
+ if isinstance(parsed, dict):
174
+ v = parsed.get("parallel_tracks")
175
+ if v is True or (isinstance(v, (int, float)) and int(v) == 1):
176
+ parallel = True
177
+ elif isinstance(v, str) and v.strip().lower() in ("1", "true", "yes"):
178
+ parallel = True
179
+ steps = [x for x in (parsed.get("steps") or []) if isinstance(x, dict)]
180
+ return steps, parallel
181
+ if isinstance(parsed, list):
182
+ return [x for x in parsed if isinstance(x, dict)], False
183
+ return [], False
184
+
185
+
186
+ def _action_lane(channel: str) -> str:
187
+ return "gmail" if (channel or "").strip().lower() == "gmail" else "linkedin"
188
+
189
+
190
+ def _build_ordered_actions_parallel(steps: List[dict]) -> List[dict]:
191
+ """Same global step_order as serial; waits only stack within each channel (LinkedIn ignores cross-email waits)."""
192
+ events: List[Tuple[str, Any]] = []
193
+ for s in steps:
194
+ if not isinstance(s, dict):
195
+ continue
196
+ if s.get("type") == "wait":
197
+ try:
198
+ d = int(s.get("days") if s.get("days") is not None else 1)
199
+ except (TypeError, ValueError):
200
+ d = 1
201
+ events.append(("wait", max(0, min(int(d), 365))))
202
+ elif s.get("type") == "action":
203
+ events.append(("action", s))
204
+ action_positions = [i for i, e in enumerate(events) if e[0] == "action"]
205
+ out: List[dict] = []
206
+ ord_global = 0
207
+ for ai in action_positions:
208
+ s = events[ai][1]
209
+ if not isinstance(s, dict):
210
+ continue
211
+ ch = (s.get("channel") or "").strip()
212
+ act = (s.get("action") or "").strip()
213
+ ref = s.get("unipile_account_ref_id")
214
+ try:
215
+ ref_i = int(ref) if ref is not None and str(ref).strip() != "" else None
216
+ except (TypeError, ValueError):
217
+ ref_i = None
218
+ lane = _action_lane(ch)
219
+ ord_global += 1
220
+ wd = 0
221
+ prev_pos: Optional[int] = None
222
+ for q in reversed([x for x in action_positions if x < ai]):
223
+ sq = events[q][1]
224
+ if not isinstance(sq, dict):
225
+ continue
226
+ lq = _action_lane((sq.get("channel") or "").strip())
227
+ if lq == lane:
228
+ prev_pos = q
229
+ break
230
+ if prev_pos is None:
231
+ for k in range(0, ai):
232
+ if events[k][0] == "wait":
233
+ wd += int(events[k][1])
234
+ if lane == "linkedin":
235
+ wd = 0
236
+ else:
237
+ for k in range(prev_pos + 1, ai):
238
+ if events[k][0] == "wait":
239
+ wd += int(events[k][1])
240
+ out.append(
241
+ {
242
+ "order": ord_global,
243
+ "channel": ch,
244
+ "action": act,
245
+ "account_ref_id": ref_i,
246
+ "wait_days_before": int(wd),
247
+ "lane": lane,
248
+ }
249
+ )
250
+ return out
251
 
252
 
253
+ def build_ordered_actions(steps: List[dict], *, parallel_tracks: bool = False) -> List[dict]:
254
  """Flatten sequence plan to sendable actions with global step_order (matches GeneratedSequence.step_order).
255
 
256
+ ``wait_days_before`` is calendar days after the dependency anchor:
257
+ - Serial: after the previous step (any channel) completes.
258
+ - ``parallel_tracks``: per channel only — waits between Gmail and LinkedIn do not delay the other channel.
259
  """
260
+ if parallel_tracks:
261
+ return _build_ordered_actions_parallel(steps)
262
  out: List[dict] = []
263
  ord_global = 0
264
  wait_accum = 0
 
287
  "action": act,
288
  "account_ref_id": ref_i,
289
  "wait_days_before": int(wait_accum),
290
+ "lane": _action_lane(ch),
291
  }
292
  )
293
  wait_accum = 0
 
355
  slot: int,
356
  prev_rec: Optional[OutreachSendReceipt],
357
  now: datetime,
358
+ *,
359
+ parallel_tracks: bool = False,
360
  ) -> bool:
361
  try:
362
  wd = int(act.get("wait_days_before") or 0)
 
364
  wd = 0
365
  if wd <= 0:
366
  return True
367
+ use_anchor = slot <= 0
368
+ if parallel_tracks and prev_rec is None:
369
+ use_anchor = True
370
+ if use_anchor:
371
  anchor = _delivery_started_at_naive(ex) or now
372
  return now >= anchor + timedelta(days=wd)
373
  if not prev_rec or not prev_rec.created_at:
 
585
  contacts: List[Contact],
586
  actions: List[dict],
587
  ex: FileOutreachExecution,
588
+ *,
589
+ parallel_tracks: bool = False,
590
  ) -> Optional[Tuple[Contact, int, dict]]:
591
  n_actions = len(actions)
592
  if not contacts or not actions:
 
600
  for slot in range(n_actions):
601
  if _receipt_for(db, tenant_id, file_id, c.id, slot):
602
  continue
603
+ act = actions[slot]
604
+ lane_cur = _action_lane(str(act.get("channel") or ""))
605
  ok = True
606
  prev_rec: Optional[OutreachSendReceipt] = None
607
  for ps in range(slot):
608
  pr = _receipt_for(db, tenant_id, file_id, c.id, ps)
609
+ act_ps = actions[ps]
610
+ lane_ps = _action_lane(str(act_ps.get("channel") or ""))
611
+ if parallel_tracks:
612
+ if lane_ps != lane_cur:
613
+ continue
614
+ if not pr or pr.status not in ("sent", "failed", "skipped"):
615
+ ok = False
616
+ break
617
  prev_rec = pr
618
+ else:
619
+ if not pr or pr.status not in ("sent", "failed", "skipped"):
620
+ ok = False
621
+ break
622
+ if ps == slot - 1:
623
+ prev_rec = pr
624
  if not ok:
625
  continue
626
+ if not _wait_eligible_for_slot(ex, act, slot, prev_rec, now, parallel_tracks=parallel_tracks):
 
627
  continue
628
  if act.get("channel") == "linkedin" and act.get("action") == "linkedin_dm":
629
  if not _linkedin_dm_interval_eligible(c, li_h, now):
 
857
 
858
  raw_plan = getattr(uf, "sequence_plan_json", None)
859
  steps: List[dict] = []
860
+ parallel_flag = False
861
  if raw_plan and str(raw_plan).strip():
862
  try:
863
+ steps, parallel_flag = load_campaign_plan_from_json(json.loads(raw_plan))
864
  except Exception:
865
+ steps, parallel_flag = [], False
866
+ actions = build_ordered_actions(steps, parallel_tracks=parallel_flag)
867
  contacts = (
868
  db.query(Contact)
869
  .filter(
 
973
  if ex.status != "running":
974
  return get_campaign_execution(file_id, tc)
975
 
976
+ steps: List[dict] = []
977
+ parallel_flag = False
978
  raw = getattr(uf, "sequence_plan_json", None)
979
  if raw and str(raw).strip():
980
  try:
981
+ steps, parallel_flag = load_campaign_plan_from_json(json.loads(raw))
982
  except Exception:
983
+ steps, parallel_flag = [], False
984
+ actions = build_ordered_actions(steps, parallel_tracks=parallel_flag)
985
  contacts = (
986
  db.query(Contact)
987
  .filter(
 
1001
 
1002
  n = max(1, min(int(limit or 3), 25))
1003
  for _ in range(n):
1004
+ nxt = _find_next_send(
1005
+ db, int(tc.tenant_id), file_id, contacts, actions, ex, parallel_tracks=parallel_flag
1006
+ )
1007
  if not nxt:
1008
  break
1009
  c, slot, act = nxt
frontend/src/components/campaigns/CampaignSequenceBuilder.jsx CHANGED
@@ -274,7 +274,14 @@ function ActionCard({
274
  /**
275
  * Vertical sequence builder: START → steps (+ inserts) → END with dashed spine.
276
  */
277
- export default function CampaignSequenceBuilder({ value, onChange, linkedinDefaults, mailboxDefaults }) {
 
 
 
 
 
 
 
278
  const steps = value;
279
  const setSteps = onChange;
280
  const [pickerIndex, setPickerIndex] = useState(null);
@@ -375,6 +382,28 @@ export default function CampaignSequenceBuilder({ value, onChange, linkedinDefau
375
  </div>
376
  ) : null}
377
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  <div className="relative flex flex-col items-center">
379
  {/* Vertical dashed spine */}
380
  <div
 
274
  /**
275
  * Vertical sequence builder: START → steps (+ inserts) → END with dashed spine.
276
  */
277
+ export default function CampaignSequenceBuilder({
278
+ value,
279
+ onChange,
280
+ parallelTracks = false,
281
+ onParallelTracksChange,
282
+ linkedinDefaults,
283
+ mailboxDefaults,
284
+ }) {
285
  const steps = value;
286
  const setSteps = onChange;
287
  const [pickerIndex, setPickerIndex] = useState(null);
 
382
  </div>
383
  ) : null}
384
 
385
+ {onParallelTracksChange ? (
386
+ <div className="mb-4 rounded-lg border border-slate-200 bg-slate-50/90 px-3 py-2.5">
387
+ <label className="flex cursor-pointer items-start gap-2.5 text-left">
388
+ <input
389
+ type="checkbox"
390
+ className="mt-0.5 h-4 w-4 shrink-0 rounded border-slate-300 text-violet-600 focus:ring-violet-500"
391
+ checked={!!parallelTracks}
392
+ onChange={(e) => onParallelTracksChange(e.target.checked)}
393
+ />
394
+ <span>
395
+ <span className="text-sm font-medium text-slate-800">Parallel Gmail and LinkedIn</span>
396
+ <span className="mt-0.5 block text-xs leading-snug text-slate-600">
397
+ When enabled, waits only pace steps within the same channel — LinkedIn is not held up by
398
+ email delays (and vice versa). The list order is unchanged; execution uses two timelines.
399
+ This is saved to the server when you click <strong className="font-semibold">Generate</strong> on the
400
+ next step.
401
+ </span>
402
+ </span>
403
+ </label>
404
+ </div>
405
+ ) : null}
406
+
407
  <div className="relative flex flex-col items-center">
408
  {/* Vertical dashed spine */}
409
  <div
frontend/src/components/campaigns/CampaignsDashboardTab.jsx CHANGED
@@ -85,6 +85,7 @@ function normalizeCampaign(c) {
85
  Math.round(c.generationProgressPercent ?? (genDone ? 100 : hasFile ? 0 : 100))
86
  ),
87
  sequence: Array.isArray(c.sequence) ? c.sequence : [],
 
88
  executionProgressPercent: Math.min(
89
  100,
90
  Math.round(Number(c.executionProgressPercent) || 0)
@@ -660,6 +661,7 @@ export default function CampaignsDashboardTab() {
660
  fileId: payload.fileId || null,
661
  prospectFileName: payload.prospectFileName || '',
662
  sequence: payload.sequence || [],
 
663
  selectedProducts: payload.selectedProducts || [],
664
  generationComplete: !!payload.generationComplete,
665
  generationProgressPercent: Math.min(
@@ -732,6 +734,10 @@ export default function CampaignsDashboardTab() {
732
  fileId: payload.fileId ?? base.fileId ?? null,
733
  prospectFileName: payload.prospectFileName ?? base.prospectFileName ?? '',
734
  sequence: Array.isArray(payload.sequence) ? payload.sequence : base.sequence || [],
 
 
 
 
735
  selectedProducts: Array.isArray(payload.selectedProducts)
736
  ? payload.selectedProducts
737
  : base.selectedProducts || [],
 
85
  Math.round(c.generationProgressPercent ?? (genDone ? 100 : hasFile ? 0 : 100))
86
  ),
87
  sequence: Array.isArray(c.sequence) ? c.sequence : [],
88
+ parallelTracks: !!c.parallelTracks,
89
  executionProgressPercent: Math.min(
90
  100,
91
  Math.round(Number(c.executionProgressPercent) || 0)
 
661
  fileId: payload.fileId || null,
662
  prospectFileName: payload.prospectFileName || '',
663
  sequence: payload.sequence || [],
664
+ parallelTracks: !!payload.parallelTracks,
665
  selectedProducts: payload.selectedProducts || [],
666
  generationComplete: !!payload.generationComplete,
667
  generationProgressPercent: Math.min(
 
734
  fileId: payload.fileId ?? base.fileId ?? null,
735
  prospectFileName: payload.prospectFileName ?? base.prospectFileName ?? '',
736
  sequence: Array.isArray(payload.sequence) ? payload.sequence : base.sequence || [],
737
+ parallelTracks:
738
+ typeof payload.parallelTracks === 'boolean'
739
+ ? payload.parallelTracks
740
+ : !!base.parallelTracks,
741
  selectedProducts: Array.isArray(payload.selectedProducts)
742
  ? payload.selectedProducts
743
  : base.selectedProducts || [],
frontend/src/components/campaigns/CreateCampaignWizard.jsx CHANGED
@@ -63,7 +63,7 @@ function mailboxLabelForAppendixStep(step, mailboxDefaults) {
63
  return ((a?.display_name || a?.label || '') || '').trim();
64
  }
65
 
66
- function buildCampaignSequenceAppendix(steps, mailboxDefaults = null) {
67
  const lines = [
68
  WIZARD_SEQUENCE_TAG,
69
  '(AUTHORITATIVE — OVERRIDES ANY FIXED “N EMAILS” OR “N MESSAGES” COUNT IN THIS PROMPT)',
@@ -72,6 +72,12 @@ function buildCampaignSequenceAppendix(steps, mailboxDefaults = null) {
72
  'Respect wait steps as pacing context between touches (do not invent extra touches for waits).',
73
  '',
74
  ];
 
 
 
 
 
 
75
  let n = 0;
76
  (steps || []).forEach((s) => {
77
  if (s.type === 'wait') {
@@ -266,12 +272,19 @@ function WizardSequencePreview({
266
  );
267
  }
268
 
269
- function ReviewSequenceTimeline({ steps }) {
270
  if (!steps?.length) {
271
  return <p className="text-sm text-slate-500">No sequence steps.</p>;
272
  }
273
  return (
274
- <div className="flex flex-wrap items-center gap-1 py-2">
 
 
 
 
 
 
 
275
  {steps.map((s, i) => (
276
  <React.Fragment key={s.id || i}>
277
  {i > 0 ? <ChevronRight className="mx-0.5 h-4 w-4 shrink-0 text-slate-300" /> : null}
@@ -301,6 +314,7 @@ function ReviewSequenceTimeline({ steps }) {
301
  )}
302
  </React.Fragment>
303
  ))}
 
304
  </div>
305
  );
306
  }
@@ -320,6 +334,7 @@ export default function CreateCampaignWizard({
320
  const [dragOver, setDragOver] = useState(false);
321
  const [estimatedContacts, setEstimatedContacts] = useState(0);
322
  const [sequenceSteps, setSequenceSteps] = useState(() => createDefaultSequenceSteps());
 
323
  const [linkedinDefaults, setLinkedinDefaults] = useState(null);
324
  const [mailboxDefaults, setMailboxDefaults] = useState(null);
325
 
@@ -362,6 +377,7 @@ export default function CreateCampaignWizard({
362
  setEstimatedContacts(0);
363
  setDragOver(false);
364
  setSequenceSteps(createDefaultSequenceSteps());
 
365
  setSelectedProducts([]);
366
  setPrompts({});
367
  setLinkedinPrompts({});
@@ -424,6 +440,7 @@ export default function CreateCampaignWizard({
424
  } else {
425
  setSequenceSteps(createDefaultSequenceSteps());
426
  }
 
427
  setSelectedProducts(
428
  Array.isArray(initialCampaign.selectedProducts) ? initialCampaign.selectedProducts : []
429
  );
@@ -574,7 +591,7 @@ export default function CreateCampaignWizard({
574
 
575
  useEffect(() => {
576
  if (step !== 3 || selectedProducts.length === 0) return;
577
- const appendix = buildCampaignSequenceAppendix(sequenceSteps, mailboxDefaults);
578
  setPrompts((prev) => {
579
  const next = { ...prev };
580
  let changed = false;
@@ -606,7 +623,7 @@ export default function CreateCampaignWizard({
606
  });
607
  return changed ? next : prev;
608
  });
609
- }, [step, sequenceSteps, selectedProducts, sequenceHasLinkedin, mailboxDefaults]);
610
 
611
  useEffect(() => {
612
  if (!genRunning || !wizardUpload?.fileId) return;
@@ -760,6 +777,7 @@ export default function CreateCampaignWizard({
760
  prospectFileName: wizardUpload?.name || prospectFile?.name || '',
761
  fileId: wizardUpload?.fileId || null,
762
  sequence: sequenceSteps,
 
763
  selectedProducts,
764
  generationComplete: genComplete,
765
  generationProgressPercent: Math.min(100, Math.round(genProgress || 0)),
@@ -788,7 +806,9 @@ export default function CreateCampaignWizard({
788
  prompts,
789
  products: selectedProducts.map((p) => p.name),
790
  linkedin_prompts: sequenceHasLinkedin ? linkedinPrompts : {},
791
- sequence_plan: sequenceSteps,
 
 
792
  }),
793
  });
794
  if (!res.ok) {
@@ -816,6 +836,7 @@ export default function CreateCampaignWizard({
816
  prospectFileName: wizardUpload?.name || prospectFile?.name || '',
817
  fileId: wizardUpload?.fileId || null,
818
  sequence: sequenceSteps,
 
819
  selectedProducts,
820
  generationComplete: genComplete,
821
  generationProgressPercent: Math.min(100, Math.round(genProgress || 0)),
@@ -987,6 +1008,8 @@ export default function CreateCampaignWizard({
987
  <CampaignSequenceBuilder
988
  value={sequenceSteps}
989
  onChange={setSequenceSteps}
 
 
990
  linkedinDefaults={linkedinDefaults}
991
  mailboxDefaults={mailboxDefaults}
992
  />
@@ -1045,7 +1068,7 @@ export default function CreateCampaignWizard({
1045
  <div className="space-y-6">
1046
  <div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
1047
  <p className="text-sm font-medium text-slate-800">Sequence</p>
1048
- <ReviewSequenceTimeline steps={sequenceSteps} />
1049
  </div>
1050
  <div>
1051
  <p className="mb-2 text-sm font-medium text-slate-800">Sample preview</p>
 
63
  return ((a?.display_name || a?.label || '') || '').trim();
64
  }
65
 
66
+ function buildCampaignSequenceAppendix(steps, mailboxDefaults = null, parallelTracks = false) {
67
  const lines = [
68
  WIZARD_SEQUENCE_TAG,
69
  '(AUTHORITATIVE — OVERRIDES ANY FIXED “N EMAILS” OR “N MESSAGES” COUNT IN THIS PROMPT)',
 
72
  'Respect wait steps as pacing context between touches (do not invent extra touches for waits).',
73
  '',
74
  ];
75
+ if (parallelTracks) {
76
+ lines.push(
77
+ 'Execution note: Gmail and LinkedIn run on separate timelines — a wait after an email does not delay LinkedIn steps (and vice versa).',
78
+ ''
79
+ );
80
+ }
81
  let n = 0;
82
  (steps || []).forEach((s) => {
83
  if (s.type === 'wait') {
 
272
  );
273
  }
274
 
275
+ function ReviewSequenceTimeline({ steps, parallelTracks }) {
276
  if (!steps?.length) {
277
  return <p className="text-sm text-slate-500">No sequence steps.</p>;
278
  }
279
  return (
280
+ <div>
281
+ {parallelTracks ? (
282
+ <p className="mb-3 text-xs text-slate-600">
283
+ Parallel tracks: Gmail and LinkedIn advance on separate timers — waits only apply within the same
284
+ channel.
285
+ </p>
286
+ ) : null}
287
+ <div className="flex flex-wrap items-center gap-1 py-2">
288
  {steps.map((s, i) => (
289
  <React.Fragment key={s.id || i}>
290
  {i > 0 ? <ChevronRight className="mx-0.5 h-4 w-4 shrink-0 text-slate-300" /> : null}
 
314
  )}
315
  </React.Fragment>
316
  ))}
317
+ </div>
318
  </div>
319
  );
320
  }
 
334
  const [dragOver, setDragOver] = useState(false);
335
  const [estimatedContacts, setEstimatedContacts] = useState(0);
336
  const [sequenceSteps, setSequenceSteps] = useState(() => createDefaultSequenceSteps());
337
+ const [parallelTracks, setParallelTracks] = useState(false);
338
  const [linkedinDefaults, setLinkedinDefaults] = useState(null);
339
  const [mailboxDefaults, setMailboxDefaults] = useState(null);
340
 
 
377
  setEstimatedContacts(0);
378
  setDragOver(false);
379
  setSequenceSteps(createDefaultSequenceSteps());
380
+ setParallelTracks(false);
381
  setSelectedProducts([]);
382
  setPrompts({});
383
  setLinkedinPrompts({});
 
440
  } else {
441
  setSequenceSteps(createDefaultSequenceSteps());
442
  }
443
+ setParallelTracks(!!initialCampaign.parallelTracks);
444
  setSelectedProducts(
445
  Array.isArray(initialCampaign.selectedProducts) ? initialCampaign.selectedProducts : []
446
  );
 
591
 
592
  useEffect(() => {
593
  if (step !== 3 || selectedProducts.length === 0) return;
594
+ const appendix = buildCampaignSequenceAppendix(sequenceSteps, mailboxDefaults, parallelTracks);
595
  setPrompts((prev) => {
596
  const next = { ...prev };
597
  let changed = false;
 
623
  });
624
  return changed ? next : prev;
625
  });
626
+ }, [step, sequenceSteps, selectedProducts, sequenceHasLinkedin, mailboxDefaults, parallelTracks]);
627
 
628
  useEffect(() => {
629
  if (!genRunning || !wizardUpload?.fileId) return;
 
777
  prospectFileName: wizardUpload?.name || prospectFile?.name || '',
778
  fileId: wizardUpload?.fileId || null,
779
  sequence: sequenceSteps,
780
+ parallelTracks,
781
  selectedProducts,
782
  generationComplete: genComplete,
783
  generationProgressPercent: Math.min(100, Math.round(genProgress || 0)),
 
806
  prompts,
807
  products: selectedProducts.map((p) => p.name),
808
  linkedin_prompts: sequenceHasLinkedin ? linkedinPrompts : {},
809
+ sequence_plan: parallelTracks
810
+ ? { parallel_tracks: true, steps: sequenceSteps }
811
+ : sequenceSteps,
812
  }),
813
  });
814
  if (!res.ok) {
 
836
  prospectFileName: wizardUpload?.name || prospectFile?.name || '',
837
  fileId: wizardUpload?.fileId || null,
838
  sequence: sequenceSteps,
839
+ parallelTracks,
840
  selectedProducts,
841
  generationComplete: genComplete,
842
  generationProgressPercent: Math.min(100, Math.round(genProgress || 0)),
 
1008
  <CampaignSequenceBuilder
1009
  value={sequenceSteps}
1010
  onChange={setSequenceSteps}
1011
+ parallelTracks={parallelTracks}
1012
+ onParallelTracksChange={setParallelTracks}
1013
  linkedinDefaults={linkedinDefaults}
1014
  mailboxDefaults={mailboxDefaults}
1015
  />
 
1068
  <div className="space-y-6">
1069
  <div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
1070
  <p className="text-sm font-medium text-slate-800">Sequence</p>
1071
+ <ReviewSequenceTimeline steps={sequenceSteps} parallelTracks={parallelTracks} />
1072
  </div>
1073
  <div>
1074
  <p className="mb-2 text-sm font-medium text-slate-800">Sample preview</p>