/* global React, ReactDOM, Button, Icon, LoadingSpinner, StatusBadge, ModelBadge, EmptyState, Select, Pill, Sparkline, useApiResource, apiFetch, formatDuration, formatRelative, formatPercent, formatNumber, shortId, displayModelName, DashboardTimePicker, dashboardTimeRangeKey, buildDashboardRangeQuery, parseDashboardTimeParam, useToast, useAuth, requirePaidAction, canUseTenantAction, tenantActionDisabledReason, getPlanLimitState, workflowMinIntervalSeconds, cronIntervalSeconds, cronAllowedByPlan, AlertRulesButton, AlertRulesForTargetModal */
const { useEffect: useEffectW, useLayoutEffect: useLayoutEffectW, useMemo: useMemoW, useRef: useRefW, useState: useStateW } = React;

function AutoTextarea({ value, onChange, minRows = 1, maxRows = 12, ...rest }) {
  const ref = useRefW(/** @type {HTMLTextAreaElement | null} */ (null));
  useLayoutEffectW(() => {
    const el = ref.current;
    if (!el) return;
    const cs = window.getComputedStyle(el);
    const lh = parseFloat(cs.lineHeight) || 18;
    const padding = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
    const border = parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);
    const max = lh * maxRows + padding + border;
    el.style.height = 'auto';
    const next = Math.min(el.scrollHeight, max);
    el.style.height = `${next}px`;
    el.style.overflowY = el.scrollHeight > max ? 'auto' : 'hidden';
  }, [value, minRows, maxRows]);
  return <textarea ref={ref} value={value} onChange={onChange} rows={minRows} {...rest} />;
}

/** @returns {[string, boolean, () => void]} */
function useTypewriter(text, { speed = 22, enabled = true } = {}) {
  const [out, setOut] = useStateW('');
  const [done, setDone] = useStateW(false);
  useEffectW(() => {
    if (!enabled) { setOut(text || ''); setDone(true); return undefined; }
    setOut('');
    setDone(false);
    if (!text) { setDone(true); return undefined; }
    let i = 0;
    let cancelled = false;
    const tick = () => {
      if (cancelled) return;
      i += 1;
      setOut(text.slice(0, i));
      if (i >= text.length) { setDone(true); return; }
      setTimeout(tick, speed);
    };
    const t = setTimeout(tick, speed);
    return () => { cancelled = true; clearTimeout(t); };
  }, [text, enabled, speed]);
  return [out, done, () => { setOut(text || ''); setDone(true); }];
}

function useCyclingStatus(messages, { charSpeed = 30, holdMs = 600, enabled = true } = {}) {
  const [text, setText] = useStateW('');
  useEffectW(() => {
    if (!enabled || messages.length === 0) {
      setText(messages[0] || '');
      return undefined;
    }
    let cancelled = false;
    let mIdx = 0;
    let cIdx = 0;
    let mode = 'type'; // 'type' | 'hold' | 'erase'
    const tick = () => {
      if (cancelled) return;
      const current = messages[mIdx];
      if (mode === 'type') {
        cIdx += 1;
        setText(current.slice(0, cIdx));
        if (cIdx >= current.length) { mode = 'hold'; setTimeout(tick, holdMs); return; }
        setTimeout(tick, charSpeed);
      } else if (mode === 'hold') {
        mode = 'erase';
        setTimeout(tick, charSpeed);
      } else {
        cIdx -= 1;
        setText(current.slice(0, Math.max(0, cIdx)));
        if (cIdx <= 0) {
          mIdx = (mIdx + 1) % messages.length;
          mode = 'type';
        }
        setTimeout(tick, charSpeed * 0.6);
      }
    };
    const t = setTimeout(tick, charSpeed);
    return () => { cancelled = true; clearTimeout(t); };
  }, [messages.join('|'), enabled, charSpeed, holdMs]);
  return text;
}

function prefersReducedMotion() {
  try {
    return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  } catch {
    return false;
  }
}

function newWorkflowRunsPath(workflowId) {
  const params = new URLSearchParams();
  if (workflowId) params.set('workflowId', workflowId);
  params.set('status', 'running');
  return `/runs?${params.toString()}`;
}

/**
 * @typedef {Object} WorkflowHarnessOption
 * @property {string} key
 * @property {string} label
 * @property {string} sdkKey
 * @property {string} providerKey
 * @property {string} logoSrc
 * @property {string} description
 * @property {boolean} [disabled]
 * @property {string} [badge]
 */

/** @type {WorkflowHarnessOption[]} */
const WORKFLOW_HARNESS_OPTIONS = [
  {
    key: 'claude_code',
    label: 'Claude Code',
    sdkKey: 'claude',
    providerKey: 'anthropic',
    logoSrc: '/frontend/assets/logos/claude-code.png',
    description: 'Run with Claude Code.',
  },
  {
    key: 'claude',
    label: 'Claude',
    sdkKey: 'anthropic_api',
    providerKey: 'anthropic',
    logoSrc: '/frontend/assets/logos/anthropic.svg.png',
    description: 'Run with Claude (consumer harness via Anthropic Messages API + hosted MCP).',
  },
  {
    key: 'chatgpt',
    label: 'ChatGPT',
    sdkKey: 'openai_api',
    providerKey: 'openai',
    logoSrc: '/frontend/assets/logos/chatgpt.png',
    description: 'Run with ChatGPT (consumer harness via OpenAI Responses API + hosted MCP).',
  },
  {
    key: 'codex',
    label: 'Codex',
    sdkKey: 'codex',
    providerKey: 'openai',
    logoSrc: '/frontend/assets/logos/codex.png',
    description: 'Run with Codex.',
  },
  {
    key: 'gemini',
    label: 'Gemini CLI',
    sdkKey: 'gemini',
    providerKey: 'google',
    logoSrc: '/frontend/assets/logos/gemini.png',
    description: 'Run with Google Gemini CLI.',
  },
  {
    key: 'cursor',
    label: 'Cursor',
    sdkKey: 'cursor',
    providerKey: 'cursor',
    logoSrc: '/frontend/assets/logos/cursor.png',
    description: 'Run with Cursor (headless cursor-agent CLI).',
  },
  {
    key: 'openclaw',
    label: 'Openclaw',
    sdkKey: 'openclaw',
    providerKey: 'openclaw',
    logoSrc: '/frontend/assets/logos/openclaw.svg',
    description: 'Run with Openclaw (open-source agent framework).',
  },
  {
    key: 'opencode',
    label: 'OpenCode',
    sdkKey: 'opencode',
    providerKey: 'opencode',
    logoSrc: '/frontend/assets/logos/opencode.svg',
    description: 'Run with OpenCode (headless opencode CLI, multi-provider router).',
  },
];

function getWorkflowHarnessOptions() {
  return WORKFLOW_HARNESS_OPTIONS.map((option) => ({ ...option }));
}

function getWorkflowHarnessOption(harnessKey) {
  return WORKFLOW_HARNESS_OPTIONS.find((option) => option.key === harnessKey)
    || WORKFLOW_HARNESS_OPTIONS[0];
}

function getWorkflowHarnessLogoBySdkKey(sdkKey) {
  if (!sdkKey) return null;
  return WORKFLOW_HARNESS_OPTIONS.find((option) => option.sdkKey === sdkKey) || null;
}

function getWorkflowHarnessForModel(model) {
  if (model?.sdk_key) {
    if (model.sdk_key === 'codex') return 'codex';
    if (model.sdk_key === 'claude') return 'claude_code';
    if (model.sdk_key === 'gemini') return 'gemini';
    if (model.sdk_key === 'openai_api') return 'chatgpt';
    if (model.sdk_key === 'anthropic_api') return 'claude';
    if (model.sdk_key === 'openclaw') return 'openclaw';
    if (model.sdk_key === 'opencode') return 'opencode';
    if (model.sdk_key === 'cursor') return 'cursor';
    const harness = WORKFLOW_HARNESS_OPTIONS.find((option) => !option.disabled && option.sdkKey === model.sdk_key);
    if (harness) return harness.key;
    return model.sdk_key;
  }
  if (model?.provider_key === 'openai') return 'codex';
  if (model?.provider_key === 'google') return 'gemini';
  if (model?.provider_key === 'cursor') return 'cursor';
  if (model?.provider_key === 'opencode') return 'opencode';
  return 'claude_code';
}

function getModelsForWorkflowHarness(modelRows, harnessKey) {
  const harness = getWorkflowHarnessOption(harnessKey);
  if (harness.disabled) return [];
  return (modelRows || []).filter((model) => {
    if (!model.is_active) return false;
    if (model.sdk_key) return model.sdk_key === harness.sdkKey;
    return getWorkflowHarnessForModel(model) === harness.key;
  });
}

function rankModelsByProvider(modelRows, providerKey) {
  const matches = (modelRows || []).filter((m) => m.is_active && m.provider_key === providerKey);
  matches.sort((a, b) => {
    const sa = a.sdk_key || '';
    const sb = b.sdk_key || '';
    if (sa !== sb) return sb.localeCompare(sa);
    return (b.display_name || '').localeCompare(a.display_name || '');
  });
  return matches;
}

function pickLatestByProvider(modelRows, providerKey) {
  const ranked = rankModelsByProvider(modelRows, providerKey);
  return ranked[0] || null;
}

// Pick a sensible default model for `harnessKey`, preferring a model that is
// not already used by another row. Without the exclude list, picking the same
// "latest" model the user already has would create a duplicate row that the
// normalize step then dedupes — which made it impossible to add a second row
// of the same harness through the harness dropdown.
function pickDefaultModelForHarness(modelRows, harnessKey, excludeModelIds = []) {
  const matches = getModelsForWorkflowHarness(modelRows, harnessKey);
  if (matches.length === 0) return null;
  const excluded = new Set(excludeModelIds);
  const ranked = rankModelsByProvider(matches, matches[0]?.provider_key);
  return ranked.find((m) => !excluded.has(m.id)) || ranked[0] || null;
}

function normalizeWorkflowModelIds(modelRows, modelIds) {
  const activeIds = new Set((modelRows || []).filter((model) => model.is_active).map((model) => model.id));
  return [...new Set((modelIds || []).filter((id) => activeIds.has(id)))];
}

function normalizeWorkflowTargetRows(modelRows, targets) {
  const activeModels = new Map((modelRows || []).filter((model) => model.is_active).map((model) => [model.id, model]));
  const rows = (targets || []).map((target) => (
    typeof target === 'string'
      ? { modelId: target, harness: '' }
      : {
        modelId: target?.modelId || target?.model_id || '',
        harness: target?.harness || '',
        skillMode: target?.skillMode || target?.skill_mode || (target?.skillVersionId || target?.skill_version_id ? 'uploaded' : 'none'),
        skillVersionId: target?.skillVersionId || target?.skill_version_id || null,
        skillName: target?.skillName || target?.skill_name || '',
        skillVersionNumber: target?.skillVersionNumber || target?.skill_version_number || null,
      }
  ));
  const seen = new Set();
  const normalized = [];
  for (const row of rows) {
    const model = activeModels.get(row.modelId);
    const wantsUploadedSkill = row.skillMode === 'uploaded';
    const skillVersionId = wantsUploadedSkill ? row.skillVersionId : null;
    const key = `${row.modelId}:${wantsUploadedSkill ? (skillVersionId || `pending-${normalized.length}`) : 'none'}`;
    if (!model || seen.has(key)) continue;
    const requestedHarness = getWorkflowHarnessOption(row.harness);
    const requestedModels = getModelsForWorkflowHarness(modelRows, requestedHarness.key);
    const harness = requestedModels.some((candidate) => candidate.id === row.modelId)
      ? requestedHarness.key
      : getWorkflowHarnessForModel(model);
    normalized.push({
      harness,
      modelId: row.modelId,
      skillMode: wantsUploadedSkill ? 'uploaded' : 'none',
      skillVersionId,
      skillName: skillVersionId ? row.skillName || '' : '',
      skillVersionNumber: skillVersionId ? row.skillVersionNumber || null : null,
    });
    seen.add(key);
  }
  return normalized;
}

function workflowTargetRowsKey(rows) {
  return (rows || []).map((row) => `${row.harness || ''}:${row.modelId || ''}:${row.skillVersionId || 'none'}`).join('|');
}

function getWorkflowTargetModelIds(targetRows) {
  return (targetRows || []).map((row) => row.modelId).filter(Boolean);
}

function getDefaultWorkflowModelIds(modelRows) {
  const codexDefault = pickDefaultModelForHarness(modelRows, 'codex');
  if (codexDefault) return [codexDefault.id];
  const firstHarness = getWorkflowHarnessOptions()
    .filter((option) => !option.disabled)
    .find((option) => getModelsForWorkflowHarness(modelRows, option.key).length > 0);
  const fallback = firstHarness ? pickDefaultModelForHarness(modelRows, firstHarness.key) : null;
  return fallback ? [fallback.id] : [];
}

// Seed the new-workflow target picker. Order of preference:
//   1) The workspace's onboarding-time selection
//      (`organizations.workflow_default_targets`, exposed on auth.me as
//      `auth.me.organization.workflowDefaultTargets`). This is what the
//      owner picked in the onboarding RolePage and represents the
//      workspace-level intent.
//   2) The hard-coded harness-aware fallback (currently: latest Codex
//      model, else first available harness's default).
// Stale rows in the org default — model deactivated, harness removed —
// are filtered out by `normalizeWorkflowTargetRows`. If that filter
// leaves nothing, we fall back to (2) so onboarding never produces an
// editor with zero rows.
function getDefaultWorkflowTargetRows(modelRows, orgDefaultTargets) {
  if (orgDefaultTargets && orgDefaultTargets.length > 0) {
    const fromOrg = normalizeWorkflowTargetRows(modelRows, orgDefaultTargets);
    if (fromOrg.length > 0) return fromOrg;
  }
  return normalizeWorkflowTargetRows(modelRows, getDefaultWorkflowModelIds(modelRows));
}

// Pick the next model when the user clicks "Add target". If a preferred
// harness is provided (typically the harness of the most recently edited row),
// try that harness first so adding a second row after picking, say, Claude
// Code + Sonnet 4.6 gives another Claude Code model rather than a Codex one
// that the user then has to switch — and would hit the duplicate-model dedup
// when switching the harness landed back on the already-used default.
function pickAddedWorkflowTargetModel(modelRows, modelIds = [], preferredHarnessKey = null) {
  const selectedIds = new Set(modelIds || []);
  const enabledHarnesses = getWorkflowHarnessOptions()
    .filter((option) => !option.disabled && getModelsForWorkflowHarness(modelRows, option.key).length > 0);
  const preferredHarness = preferredHarnessKey
    ? enabledHarnesses.find((option) => option.key === preferredHarnessKey)
    : null;
  const codexHarness = enabledHarnesses.find((option) => option.key === 'codex');
  const orderedHarnesses = [
    ...(preferredHarness ? [preferredHarness] : []),
    ...(codexHarness && codexHarness.key !== preferredHarness?.key ? [codexHarness] : []),
    ...enabledHarnesses.filter((option) => option.key !== 'codex' && option.key !== preferredHarness?.key),
  ];
  for (const harness of orderedHarnesses) {
    const unselected = getModelsForWorkflowHarness(modelRows, harness.key)
      .find((model) => !selectedIds.has(model.id));
    if (unselected) return unselected;
  }
  return null;
}

function buildWorkflowTesterTargets(targets, modelRows) {
  return normalizeWorkflowTargetRows(modelRows, targets).map((row) => ({
    harness: row.harness,
    modelId: row.modelId,
    skillMode: row.skillMode === 'uploaded' && row.skillVersionId ? 'uploaded' : 'none',
    skillVersionId: row.skillMode === 'uploaded' && row.skillVersionId ? row.skillVersionId : null,
  }));
}

function hasPendingWorkflowSkillVariant(rows) {
  return (rows || []).some((row) => row?.skillMode === 'uploaded' && !row?.skillVersionId);
}

function getFixedEvaluatorModelId(modelRows) {
  const openAiModels = (modelRows || []).filter((model) => model.is_active && model.provider_key === 'openai');
  if (openAiModels.length === 0) return '';
  const ranked = [...openAiModels].sort((a, b) => {
    const rank = (model) => {
      const haystack = [
        model.model_key,
        model.provider_model_name,
        model.display_name,
      ].map((value) => String(value || '').toLowerCase()).join(' ');
      if (haystack.includes('gpt-5-mini')) return 0;
      if (haystack.includes('gpt-5-nano')) return 1;
      if (haystack.includes('mini')) return 2;
      if (haystack.includes('nano')) return 3;
      return 4;
    };
    const rankDiff = rank(a) - rank(b);
    if (rankDiff !== 0) return rankDiff;
    return String(a.display_name || '').localeCompare(String(b.display_name || ''));
  });
  return ranked[0]?.id || '';
}

function WorkflowHarnessLogo({ harness, size = 28 }) {
  if (harness.logoSrc) {
    return <img className="workflow-harness-logo-img" src={harness.logoSrc} alt="" style={{ width: size, height: size }} />;
  }
  return (
    <span className="workflow-harness-logo-fallback" style={{ width: size, height: size }}>
      {harness.label.slice(0, 2)}
    </span>
  );
}

function WorkflowHarnessSelect({ value, onChange, disabled = false }) {
  const [open, setOpen] = useStateW(false);
  const rootRef = useRefW(/** @type {HTMLDivElement | null} */ (null));
  const selected = getWorkflowHarnessOption(value);
  const options = getWorkflowHarnessOptions();

  useEffectW(() => {
    if (!open) return undefined;
    function onDocMouseDown(event) {
      if (!rootRef.current?.contains(event.target)) setOpen(false);
    }
    function onDocKeyDown(event) {
      if (event.key === 'Escape') {
        event.stopPropagation();
        setOpen(false);
      }
    }
    document.addEventListener('mousedown', onDocMouseDown);
    document.addEventListener('keydown', onDocKeyDown);
    return () => {
      document.removeEventListener('mousedown', onDocMouseDown);
      document.removeEventListener('keydown', onDocKeyDown);
    };
  }, [open]);

  function pick(option) {
    if (disabled || option.disabled) return;
    onChange?.(option.key);
    setOpen(false);
  }

  return (
    <div ref={rootRef} className={`workflow-harness-select${open ? ' open' : ''}${disabled ? ' is-disabled' : ''}`}>
      <button
        type="button"
        className="workflow-harness-select-trigger"
        disabled={disabled}
        aria-haspopup="listbox"
        aria-expanded={open}
        onClick={() => setOpen((current) => !current)}>
        <WorkflowHarnessLogo harness={selected} size={22} />
        <span className="workflow-harness-select-value">
          <span className="workflow-harness-select-label">{selected.label}</span>
          {selected.badge && <span className="workflow-harness-select-badge">{selected.badge}</span>}
        </span>
        <Icon name="chevronDown" size={12} className="workflow-harness-select-chevron" />
      </button>
      {open && (
        <div className="workflow-harness-select-menu" role="listbox" aria-label="Harness">
          {options.map((option) => (
            <button
              key={option.key}
              type="button"
              className={`workflow-harness-select-option${value === option.key ? ' is-selected' : ''}`}
              role="option"
              aria-selected={value === option.key}
              aria-disabled={option.disabled ? 'true' : undefined}
              disabled={option.disabled}
              onClick={() => pick(option)}>
              <WorkflowHarnessLogo harness={option} size={22} />
              <span className="workflow-harness-select-option-copy">
                <span className="workflow-harness-select-option-label">{option.label}</span>
                {option.badge && <span className="workflow-harness-select-badge">{option.badge}</span>}
              </span>
              {value === option.key && <Icon name="check" size={12} className="workflow-harness-select-check" />}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

function WorkflowModelSelect({ value, onChange, models, disabled = false }) {
  const options = (models || []).map((model) => ({
    value: model.id,
    label: model.display_name,
  }));
  return (
    <Select
      className="workflow-model-select"
      value={value || ''}
      placeholder={options.length === 0 ? 'No models available' : 'Pick a model…'}
      disabled={disabled || options.length === 0}
      options={options}
      onChange={onChange} />
  );
}

function WorkflowSkillPickerModal({ open, onClose, onSelect }) {
  const [skills, setSkills] = useStateW([]);
  const [loading, setLoading] = useStateW(false);
  const [name, setName] = useStateW('');
  const [filename, setFilename] = useStateW('SKILL.md');
  const [content, setContent] = useStateW('');
  const [error, setError] = useStateW('');
  const [editingSkill, setEditingSkill] = useStateW(null);
  const modalRef = useRefW(null);
  const nameInputRef = useRefW(null);
  const skillsRequestRef = useRefW(0);

  function resetUploadForm() {
    setEditingSkill(null);
    setName('');
    setFilename('SKILL.md');
    setContent('');
  }

  async function refreshSkills() {
    const requestId = skillsRequestRef.current + 1;
    skillsRequestRef.current = requestId;
    setLoading(true);
    setError('');
    try {
      const data = await apiFetch('/api/workflow-skills');
      if (skillsRequestRef.current !== requestId) return;
      setSkills(data.skills || []);
    } catch (err) {
      if (skillsRequestRef.current !== requestId) return;
      setError(err.message || 'Could not load skills');
    } finally {
      if (skillsRequestRef.current === requestId) setLoading(false);
    }
  }

  useEffectW(() => {
    if (!open) return undefined;
    let cancelled = false;
    setError('');
    window.setTimeout(() => {
      if (!cancelled) nameInputRef.current?.focus?.();
    }, 0);
    refreshSkills();
    return () => {
      cancelled = true;
      skillsRequestRef.current += 1;
    };
  }, [open]);

  useEffectW(() => {
    if (!open) return undefined;
    function onKeyDown(event) {
      if (event.key === 'Escape') onClose();
      if (event.key === 'Tab' && modalRef.current) {
        const focusable = Array.from(modalRef.current.querySelectorAll('button, [href], input, textarea, [tabindex]:not([tabindex="-1"])'))
          .filter((element) => !element.disabled && element.getAttribute('aria-hidden') !== 'true');
        if (focusable.length === 0) return;
        const first = focusable[0];
        const last = focusable[focusable.length - 1];
        if (event.shiftKey && document.activeElement === first) {
          event.preventDefault();
          last.focus();
        } else if (!event.shiftKey && document.activeElement === last) {
          event.preventDefault();
          first.focus();
        }
      }
    }
    document.addEventListener('keydown', onKeyDown);
    return () => document.removeEventListener('keydown', onKeyDown);
  }, [open, onClose]);

  if (!open) return null;

  async function uploadSkill() {
    setError('');
    try {
      const created = await apiFetch(editingSkill ? `/api/workflow-skills/${editingSkill.skillId}/versions` : '/api/workflow-skills', {
        method: 'POST',
        body: JSON.stringify(editingSkill ? { filename, content } : { name, filename, content }),
      });
      onSelect({
        skillVersionId: created.version.id,
        skillName: editingSkill ? editingSkill.skillName : created.skill.name,
        skillVersionNumber: created.version.version_number,
      });
      await refreshSkills();
      resetUploadForm();
      onClose();
    } catch (err) {
      setError(err.message || 'Could not upload skill');
    }
  }

  async function editSkillVersion(skill) {
    setError('');
    try {
      const data = await apiFetch(`/api/workflow-skills/${skill.id}/versions/${skill.latest_version.id}/content`);
      setEditingSkill({
        skillId: skill.id,
        skillName: skill.name,
        nextVersionNumber: Number(skill.latest_version.version_number || 0) + 1,
      });
      setName(skill.name);
      setFilename(data.version?.filename || skill.latest_version.filename || 'SKILL.md');
      setContent(data.version?.content || '');
      nameInputRef.current?.focus?.();
    } catch (err) {
      setError(err.message || 'Could not load skill content');
    }
  }

  async function archiveSkill(skill) {
    if (!window.confirm(`Archive ${skill.name}? Existing workflows and runs keep their pinned versions.`)) return;
    setError('');
    try {
      await apiFetch(`/api/workflow-skills/${skill.id}`, { method: 'DELETE' });
      await refreshSkills();
    } catch (err) {
      setError(err.message || 'Could not archive skill');
    }
  }

  return (
    <div className="modal-backdrop" role="presentation">
      <div ref={modalRef} className="modal workflow-skill-modal" role="dialog" aria-modal="true" aria-label="Choose skill" tabIndex={-1}>
        <div className="modal-header">
          <h3>Choose skill</h3>
          <Button variant="ghost" onClick={() => { resetUploadForm(); onClose(); }}>Close</Button>
        </div>
        {error && <div className="form-error">{error}</div>}
        <div className="workflow-skill-list">
          {loading ? <div className="muted">Loading...</div> : skills.map((skill) => (
            <div key={skill.id} className="workflow-skill-option-row">
              <button
                type="button"
                className="workflow-skill-option"
                onClick={() => {
                  onSelect({
                    skillVersionId: skill.latest_version.id,
                    skillName: skill.name,
                    skillVersionNumber: skill.latest_version.version_number,
                  });
                  resetUploadForm();
                  onClose();
                }}>
                <strong>{skill.name}</strong>
                <span>{skill.latest_version.filename} - v{skill.latest_version.version_number}</span>
              </button>
              <button type="button" className="workflow-skill-row-action" title={`Create v${Number(skill.latest_version.version_number || 0) + 1}`} aria-label={`Edit ${skill.name}`} onClick={() => editSkillVersion(skill)}>
                <Icon name="edit" size={13} />
              </button>
              <button type="button" className="workflow-skill-row-action" title="Archive skill" aria-label={`Archive ${skill.name}`} onClick={() => archiveSkill(skill)}>
                <Icon name="trash" size={13} />
              </button>
            </div>
          ))}
        </div>
        <div className="workflow-skill-upload">
          <input ref={nameInputRef} value={name} onChange={(e) => setName(e.target.value)} placeholder="Skill name" disabled={Boolean(editingSkill)} />
          <input value={filename} onChange={(e) => setFilename(e.target.value)} placeholder="SKILL.md" />
          <textarea value={content} onChange={(e) => setContent(e.target.value)} rows={10} placeholder="# Skill" />
          <div className="workflow-skill-upload-actions">
            {editingSkill && <Button variant="ghost" onClick={resetUploadForm}>New skill</Button>}
            <Button onClick={uploadSkill} disabled={!content.trim() || !filename.trim() || (!editingSkill && !name.trim())}>
              {editingSkill ? `Create v${editingSkill.nextVersionNumber}` : 'Upload skill'}
            </Button>
          </div>
        </div>
      </div>
    </div>
  );
}

function WorkflowTargetPicker({ modelIds, targetRows, onChange, onChangeTargets, models, disabled = false }) {
  const normalizedTargetRows = normalizeWorkflowTargetRows(models, (targetRows && targetRows.length > 0) ? targetRows : (modelIds || []));
  const normalizedModelIds = normalizedTargetRows.map((row) => row.modelId);
  const preferredAddHarness = normalizedTargetRows[normalizedTargetRows.length - 1]?.harness || null;
  const nextAddModel = pickAddedWorkflowTargetModel(models, normalizedTargetRows.map((row) => row.modelId), preferredAddHarness);
  const hasAvailableModels = getWorkflowHarnessOptions()
    .some((harness) => !harness.disabled && getModelsForWorkflowHarness(models, harness.key).length > 0);
  const hasPendingSkill = hasPendingWorkflowSkillVariant(normalizedTargetRows);
  const [activeSkillRowIndex, setActiveSkillRowIndex] = useStateW(null);

  function commitRows(nextRows) {
    const normalized = normalizeWorkflowTargetRows(models, nextRows);
    onChangeTargets?.(normalized);
    onChange?.(getWorkflowTargetModelIds(normalized));
  }

  function updateRowModel(index, nextModelId) {
    if (disabled) return;
    const nextModel = (models || []).find((row) => row.id === nextModelId && row.is_active);
    if (!nextModel) return;
    const currentRow = normalizedTargetRows[index] || { harness: '', modelId: '', skillMode: 'none', skillVersionId: null, skillName: '', skillVersionNumber: null };
    const harnessModels = getModelsForWorkflowHarness(models, currentRow.harness);
    const nextHarness = harnessModels.some((model) => model.id === nextModelId)
      ? currentRow.harness
      : getWorkflowHarnessForModel(nextModel);
    const next = [...normalizedTargetRows];
    next[index] = { ...currentRow, harness: nextHarness, modelId: nextModelId };
    commitRows(next);
  }

  function updateRowHarness(index, nextHarnessKey) {
    if (disabled) return;
    const currentRow = normalizedTargetRows[index] || { harness: '', modelId: '', skillMode: 'none', skillVersionId: null, skillName: '', skillVersionNumber: null };
    const harnessModels = getModelsForWorkflowHarness(models, nextHarnessKey);
    const currentModel = harnessModels.find((model) => model.id === currentRow.modelId);
    // Other rows' model ids are excluded so picking the harness default does
    // not silently collide with an existing row and get dropped by the
    // normalize step's modelId-based dedup.
    const otherModelIds = normalizedTargetRows
      .filter((_, rowIndex) => rowIndex !== index)
      .map((row) => row.modelId);
    const nextModel = currentModel || pickDefaultModelForHarness(models, nextHarnessKey, otherModelIds);
    if (!nextModel) return;
    const next = [...normalizedTargetRows];
    next[index] = { ...currentRow, harness: nextHarnessKey, modelId: nextModel.id };
    commitRows(next);
  }

  function addRow() {
    if (disabled || !nextAddModel) return;
    commitRows([...normalizedTargetRows, { harness: getWorkflowHarnessForModel(nextAddModel), modelId: nextAddModel.id }]);
  }

  function updateTargetSkill(index, patch) {
    if (disabled) return;
    const next = [...normalizedTargetRows];
    next[index] = { ...next[index], ...patch };
    commitRows(next);
  }

  function addSkillVariant(index) {
    if (disabled) return;
    const currentRow = normalizedTargetRows[index];
    if (!currentRow) return;
    const nextIndex = index + 1;
    const next = [...normalizedTargetRows];
    next.splice(nextIndex, 0, {
      ...currentRow,
      skillMode: 'uploaded',
      skillVersionId: null,
      skillName: '',
      skillVersionNumber: null,
    });
    commitRows(next);
    setActiveSkillRowIndex(nextIndex);
  }

  function openSkillModalFor(index) {
    if (disabled) return;
    setActiveSkillRowIndex(index);
  }

  function removeRow(index) {
    if (disabled || normalizedTargetRows.length <= 1) return;
    commitRows(normalizedTargetRows.filter((_, rowIndex) => rowIndex !== index));
  }

  if (!hasAvailableModels) {
    return (
      <div className="workflow-target-picker workflow-target-picker-empty">
        <span className="text-sm muted">No active tester models are available.</span>
      </div>
    );
  }

  return (
    <div className="workflow-target-picker">
      {normalizedModelIds.map((modelId, index) => {
        const harnessKey = normalizedTargetRows[index]?.harness || 'codex';
        const harnessModels = getModelsForWorkflowHarness(models, harnessKey);
        return (
          <div key={`${harnessKey}-${modelId}-${index}`} className="workflow-target-row">
            <WorkflowHarnessSelect
              value={harnessKey}
              disabled={disabled}
              onChange={(nextHarnessKey) => updateRowHarness(index, nextHarnessKey)} />
            <WorkflowModelSelect
              value={modelId}
              models={harnessModels}
              disabled={disabled}
              onChange={(nextModelId) => updateRowModel(index, nextModelId)} />
            <div className="workflow-target-skill">
              {normalizedTargetRows[index]?.skillMode === 'uploaded' && normalizedTargetRows[index]?.skillVersionId ? (
                <Button
                  variant="secondary"
                  title={`${normalizedTargetRows[index]?.skillName || 'Specific skill'} v${normalizedTargetRows[index]?.skillVersionNumber || ''}`.trim()}
                  onClick={() => openSkillModalFor(index)}>
                  {normalizedTargetRows[index]?.skillName ? `${normalizedTargetRows[index].skillName} v${normalizedTargetRows[index].skillVersionNumber || ''}`.trim() : 'Specific skill'}
                </Button>
              ) : (
                <Button
                  className="ui-tooltip workflow-target-skill-help"
                  data-tooltip="Test the workflow with this skill explicitly."
                  title="Test the workflow with this skill explicitly."
                  onClick={() => openSkillModalFor(index)}>
                  Bring your own skill
                </Button>
              )}
              {normalizedTargetRows[index]?.skillMode === 'uploaded' && (
                <Button variant="ghost" onClick={() => updateTargetSkill(index, { skillMode: 'none', skillVersionId: null, skillName: '', skillVersionNumber: null })}>None</Button>
              )}
            </div>
            <button
              type="button"
              className="workflow-target-variant"
              aria-label="Add skill variant"
              title="Add skill variant"
              disabled={disabled}
              onClick={() => addSkillVariant(index)}>
              <Icon name="copy" size={13} />
            </button>
            <button
              type="button"
              className="workflow-target-remove"
              aria-label="Remove target"
              disabled={disabled || normalizedTargetRows.length <= 1}
              onClick={() => removeRow(index)}>
              <Icon name="x" size={13} />
            </button>
          </div>
        );
      })}
      <button
        type="button"
        className="workflow-target-add"
        disabled={disabled || !nextAddModel}
        onClick={addRow}>
        <Icon name="plus" size={13} /> Add target
      </button>
      {hasPendingSkill && (
        <div className="form-error workflow-target-error">Choose a skill for each skill variant before saving.</div>
      )}
      <WorkflowSkillPickerModal
        open={activeSkillRowIndex !== null}
        onClose={() => setActiveSkillRowIndex(null)}
        onSelect={(selection) => {
          if (activeSkillRowIndex === null) return;
          updateTargetSkill(activeSkillRowIndex, {
            skillMode: 'uploaded',
            skillVersionId: selection.skillVersionId,
            skillName: selection.skillName,
            skillVersionNumber: selection.skillVersionNumber,
          });
        }} />
    </div>
  );
}

// Inline "Run" control on each workflow row.
//
// - 0 or 1 configured target → renders the legacy "Run now" button so
//   single-model workflows don't carry extra UI noise.
// - 2+ configured targets → split button: "Run all (N)" primary + caret
//   that opens a checkbox menu for picking a subset. The popover always
//   includes a "Run all" toggle at the top so users can jump back to the
//   original behavior with one click after selecting individual targets.
//
// Targets come from `workflow.tester_targets` which getWorkflows aggregates
// from the active workflow_schedules rows (one entry per (harness, model)).
function WorkflowRunControl({ workflow, busy, onRun }) {
  const targets = Array.isArray(workflow.tester_targets) ? workflow.tester_targets : [];
  const hasMultipleTargets = targets.length > 1;
  const isActive = Boolean(workflow.is_active);
  const disabled = !isActive || busy;
  const baseTitle = isActive
    ? 'Trigger a manual run now'
    : 'Resume the workflow before running it manually';
  const [open, setOpen] = useStateW(false);
  const [selectedKeys, setSelectedKeys] = useStateW(/** @type {string[]} */ ([]));
  // Inline style for the menu's fixed-position layout. Recomputed every time
  // the picker opens (and on scroll/resize while open) from the caret's
  // bounding rect so the popover always anchors to the caret button even
  // when the page is scrolled. Stays null until the first measurement so we
  // can render the menu hidden until we know where to put it.
  const [menuStyle, setMenuStyle] = useStateW(/** @type {Record<string, any> | null} */ (null));
  const rootRef = useRefW(/** @type {HTMLDivElement | null} */ (null));
  const caretRef = useRefW(/** @type {HTMLButtonElement | null} */ (null));
  const menuRef = useRefW(/** @type {HTMLDivElement | null} */ (null));

  // Single source of truth for closing the picker. Every dismiss path
  // (Escape, outside click, clicking Run all / Run selected) routes here so
  // the next time the user opens the picker they always start from a clean
  // state. Without this, dismissing via Escape or an outside click left
  // `selectedIds` populated and surfaced a stale checkbox state on reopen.
  function closePicker() {
    setOpen(false);
    setSelectedKeys([]);
    setMenuStyle(null);
  }

  useEffectW(() => {
    if (!open) return undefined;
    function onDocMouseDown(event) {
      const inRoot = rootRef.current?.contains(event.target);
      const inMenu = menuRef.current?.contains(event.target);
      if (!inRoot && !inMenu) closePicker();
    }
    function onDocKeyDown(event) {
      if (event.key === 'Escape') {
        event.stopPropagation();
        closePicker();
      }
    }
    document.addEventListener('mousedown', onDocMouseDown);
    document.addEventListener('keydown', onDocKeyDown);
    return () => {
      document.removeEventListener('mousedown', onDocMouseDown);
      document.removeEventListener('keydown', onDocKeyDown);
    };
  }, [open]);

  // Compute the menu's fixed-position coords from the caret button. We use
  // position: fixed (rather than position: absolute relative to the split
  // wrapper) because the menu lives inside `.runs-table-wrap`, which has
  // `overflow-x: auto` — that clip context would otherwise crop the popover
  // to the row's visible height and make the options unreachable.
  useLayoutEffectW(() => {
    if (!open) return undefined;
    function updatePosition() {
      const caret = caretRef.current;
      if (!caret || typeof window === 'undefined') return;
      const rect = caret.getBoundingClientRect();
      const edge = 8;
      const gap = 4;
      const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
      const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
      const menuWidth = Math.max(240, menuRef.current?.offsetWidth || 240);
      const menuHeight = menuRef.current?.offsetHeight || 0;
      // Prefer right-aligning the menu with the caret (matches the legacy
      // absolute-positioned layout) but clamp inside the viewport so the
      // popover never escapes off-screen on narrow viewports.
      const desiredLeft = rect.right - menuWidth;
      const left = Math.max(edge, Math.min(desiredLeft, viewportWidth - edge - menuWidth));
      // Flip above the caret when there isn't enough room below.
      const below = viewportHeight - rect.bottom - gap - edge;
      const above = rect.top - gap - edge;
      const openUp = menuHeight > 0 && below < menuHeight && above > below;
      setMenuStyle(openUp
        ? { left, bottom: viewportHeight - rect.top + gap }
        : { left, top: rect.bottom + gap });
    }
    updatePosition();
    window.addEventListener('resize', updatePosition);
    window.addEventListener('scroll', updatePosition, true);
    return () => {
      window.removeEventListener('resize', updatePosition);
      window.removeEventListener('scroll', updatePosition, true);
    };
  }, [open]);

  if (!hasMultipleTargets) {
    const targetRefs = targets.length === 1 ? targets.map(workflowRunTargetRef) : undefined;
    return (
      <button
        type="button"
        className="btn btn-sm workflows-action workflows-action-run"
        data-row-action="true"
        disabled={disabled}
        onClick={() => onRun(workflow, targetRefs)}
        title={baseTitle}>
        <Icon name="play" size={12} />
        {busy ? 'Starting…' : 'Run now'}
      </button>
    );
  }

  function toggleKey(key) {
    setSelectedKeys((prev) => (
      prev.includes(key) ? prev.filter((x) => x !== key) : [...prev, key]
    ));
  }

  function runAll() {
    // Pass every configured target explicitly so same-model skill variants
    // stay distinct instead of collapsing to a legacy testerModelIds list.
    const allTargets = targets.map(workflowRunTargetRef).filter((target) => target.modelId);
    closePicker();
    onRun(workflow, allTargets);
  }

  function runSelected() {
    if (selectedKeys.length === 0) return;
    const selected = new Set(selectedKeys);
    const selectedTargets = targets
      .filter((target) => selected.has(workflowRunTargetKey(target)))
      .map(workflowRunTargetRef)
      .filter((target) => target.modelId);
    closePicker();
    onRun(workflow, selectedTargets);
  }

  return (
    <div ref={rootRef} className={`workflows-run-split${open ? ' is-open' : ''}`}>
      <button
        type="button"
        className="btn btn-sm workflows-action workflows-action-run workflows-run-split-primary"
        data-row-action="true"
        disabled={disabled}
        onClick={runAll}
        title={isActive ? `Trigger a manual run on all ${targets.length} configured targets` : baseTitle}>
        <Icon name="play" size={12} />
        {busy ? 'Starting…' : `Run all (${targets.length})`}
      </button>
      <button
        ref={caretRef}
        type="button"
        className="btn btn-sm workflows-action workflows-run-split-caret"
        data-row-action="true"
        aria-haspopup="menu"
        aria-expanded={open}
        aria-label="Pick which targets to run"
        disabled={disabled}
        onClick={() => {
          // Route the close path through closePicker so menuStyle is cleared
          // alongside open/selectedIds. Without this, toggling the caret
          // closed keeps the previous coordinates on the (unmounted) menu's
          // state, so the next open would briefly mount with stale coords
          // before useLayoutEffect repositions it. Today useLayoutEffect
          // beats paint so the flash is invisible, but keeping the
          // invariant (no menuStyle while closed) avoids latent bugs.
          if (open) closePicker();
          else setOpen(true);
        }}
        title="Pick which targets to run">
        <Icon name="chevronDown" size={12} />
      </button>
      {open && (
        <div
          ref={menuRef}
          className="workflows-run-split-menu"
          role="menu"
          data-row-action="true"
          style={menuStyle || { visibility: 'hidden' }}>
          <div className="workflows-run-split-menu-head">
            Pick which targets to run
          </div>
          <ul className="workflows-run-split-menu-list">
            {targets.map((target) => {
              const targetKey = workflowRunTargetKey(target);
              const checked = selectedKeys.includes(targetKey);
              const harnessLabel = formatTesterTargetHarness(target);
              const harnessLogo = getWorkflowHarnessLogoBySdkKey(target.sdkKey);
              return (
                <li key={targetKey} className="workflows-run-split-menu-item">
                  <label className="workflows-run-split-menu-row">
                    <input
                      type="checkbox"
                      checked={checked}
                      onChange={() => toggleKey(targetKey)} />
                    {harnessLogo
                      ? (
                        <img
                          src={harnessLogo.logoSrc}
                          alt=""
                          aria-hidden="true"
                          className="workflows-run-split-menu-logo"
                          width="14"
                          height="14" />
                      )
                      : <span className="workflows-run-split-menu-logo workflows-run-split-menu-logo-placeholder" aria-hidden="true" />}
                    <span className="workflows-run-split-menu-label">
                      {harnessLabel && <span className="workflows-run-split-menu-harness">{harnessLabel}</span>}
                      <span className="workflows-run-split-menu-model mono">
                        {displayModelName(target.modelName) || target.modelId}
                      </span>
                      {target.skillMode === 'uploaded' && (
                        <span className="workflows-run-split-menu-skill">
                          {target.skillName ? `${target.skillName} v${target.skillVersionNumber || ''}`.trim() : 'Uploaded skill'}
                        </span>
                      )}
                    </span>
                  </label>
                </li>
              );
            })}
          </ul>
          <div className="workflows-run-split-menu-foot">
            <button
              type="button"
              className="btn btn-sm"
              onClick={runAll}>
              Run all ({targets.length})
            </button>
            <button
              type="button"
              className="btn btn-sm btn-primary"
              onClick={runSelected}
              disabled={selectedKeys.length === 0}>
              Run selected ({selectedKeys.length})
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

function workflowRunTargetKey(target) {
  return `${target?.modelId || ''}:${target?.skillVersionId || 'none'}`;
}

function workflowRunTargetRef(target) {
  return {
    modelId: target?.modelId || '',
    skillVersionId: target?.skillMode === 'uploaded' && target?.skillVersionId ? target.skillVersionId : null,
  };
}

function formatTesterTargetHarness(target) {
  if (!target) return '';
  if (target.sdkKey === 'codex') return 'Codex';
  if (target.sdkKey === 'claude') return 'Claude Code';
  if (target.sdkKey === 'anthropic_api') return 'Claude';
  if (target.sdkKey === 'openai_api') return 'ChatGPT';
  if (target.sdkKey === 'openclaw') return 'Openclaw';
  if (target.sdkKey === 'opencode') return 'OpenCode';
  if (target.sdkKey === 'gemini') return 'Gemini CLI';
  if (target.sdkKey === 'cursor') return 'Cursor';
  if (target.sdkKey) return target.sdkKey;
  if (target.providerKey === 'openai') return 'OpenAI';
  if (target.providerKey === 'anthropic') return 'Anthropic';
  if (target.providerKey === 'google') return 'Google';
  return target.providerKey || '';
}

function WorkflowsPage({ navigate, queryString = '' }) {
  const auth = useAuth();
  const toast = useToast();
  const workflows = useApiResource('/api/workflows');
  const alerts = useApiResource('/api/alerts');
  const alertRows = alerts.data?.rows || [];
  // Index alert rules by their workflow target so each row's Alerts chip can
  // render a count (and the modal can scope its list) without re-scanning the
  // full rules array per row.
  const alertsByWorkflow = useMemoW(() => {
    const out = {};
    for (const rule of alertRows) {
      if (!rule.workflowId) continue;
      if (!out[rule.workflowId]) out[rule.workflowId] = [];
      out[rule.workflowId].push(rule);
    }
    return out;
  }, [alertRows]);
  // Workflow whose alert rules are open in the modal. Tracked as the whole
  // workflow object so the modal header keeps showing the name even after a
  // reload removes the row from `workflows.data`.
  const [manageAlertsWorkflow, setManageAlertsWorkflow] = useStateW(null);
  const workflowCreateParams = useMemoW(() => new URLSearchParams(queryString || ''), [queryString]);
  const [filter, setFilter] = useStateW('all');
  const [search, setSearch] = useStateW('');
  const [onboardingOpen, setOnboardingOpen] = useStateW(false);
  const [initialWorkflowServerId, setInitialWorkflowServerId] = useStateW('');
  const handledWorkflowCreateQueryRef = useRefW('');
  // Per-workflow dispatch lock for the inline "Run now" action. Stored as a
  // Set so two adjacent rows can be triggered concurrently without one button
  // blocking the other; we just need to disable the originating row while its
  // POST is in flight.
  const [dispatchingIds, setDispatchingIds] = useStateW(() => new Set());
  const rows = workflows.data?.rows || [];
  const showInitialLoading = workflows.loading && !workflows.data;
  const workflowCreateLimitState = getPlanLimitState(auth, 'scheduledWorkflows', 1);
  const canManageWorkflows = canUseTenantAction(auth);
  const workflowActionDisabledReason = tenantActionDisabledReason(auth);
  const workflowCreateDisabled = !canManageWorkflows || !workflowCreateLimitState.allowed;
  const workflowCreateDisabledReason = !canManageWorkflows
    ? workflowActionDisabledReason
    : !workflowCreateLimitState.allowed
      ? workflowCreateLimitState.message
      : undefined;
  // Mirror the API allowlist in `api/workflows/[id]/runs.js`. Backend always
  // re-enforces this; the frontend gate is purely about avoiding a button
  // that's guaranteed to 403.
  const canRunWorkflow = canManageWorkflows;

  useEffectW(() => {
    if (workflowCreateParams.get('new') !== '1') return;
    const serverId = workflowCreateParams.get('server') || '';
    const requestKey = `${queryString || ''}:${serverId}`;
    if (handledWorkflowCreateQueryRef.current === requestKey) return;
    if (!auth?.me) return;
    if (!canManageWorkflows || !workflowCreateLimitState.allowed) {
      handledWorkflowCreateQueryRef.current = requestKey;
      if (workflowCreateDisabledReason) {
        toast.show({ tone: 'bad', title: 'Could not create workflow', description: workflowCreateDisabledReason });
      }
      if (typeof navigate === 'function') navigate('/workflows', { replace: true });
      return;
    }
    handledWorkflowCreateQueryRef.current = requestKey;
    setInitialWorkflowServerId(serverId);
    setOnboardingOpen(true);
    if (typeof navigate === 'function') navigate('/workflows', { replace: true });
  }, [queryString, workflowCreateParams, auth?.me, canManageWorkflows, workflowCreateLimitState.allowed, workflowCreateDisabledReason]); // eslint-disable-line

  const filtered = rows.filter((w) => {
    if (filter === 'active' && !w.is_active) return false;
    if (filter === 'paused' && w.is_active) return false;
    if (search) {
      const q = search.toLowerCase();
      if (!(w.name || '').toLowerCase().includes(q)
        && !(w.description || '').toLowerCase().includes(q)) return false;
    }
    return true;
  });

  const counts = {
    all: rows.length,
    active: rows.filter((w) => w.is_active).length,
    paused: rows.filter((w) => !w.is_active).length,
  };

  function startNewWorkflow() {
    if (!requirePaidAction(auth, navigate, { requiredRole: 'editor' })) return;
    if (!workflowCreateLimitState.allowed) {
      toast.show({ tone: 'bad', title: 'Plan limit reached', description: workflowCreateLimitState.message });
      return;
    }
    setInitialWorkflowServerId('');
    setOnboardingOpen(true);
  }

  function workflowRowClickHitsAction(event) {
    const target = event.target;
    return Boolean(target && typeof target.closest === 'function' && target.closest(
      'button, a, input, select, textarea, [role="button"], [data-row-action]',
    ));
  }

  function openWorkflowRunHistory(workflow, event) {
    if (workflowRowClickHitsAction(event)) return;
    navigate(`/workflows/${workflow.id}/analytics`);
  }

  function handleWorkflowRowKeyDown(workflow, event) {
    if (workflowRowClickHitsAction(event) || (event.key !== 'Enter' && event.key !== ' ')) return;
    event.preventDefault();
    navigate(`/workflows/${workflow.id}/analytics`);
  }

  async function runWorkflowNow(workflow, testerTargets) {
    if (!requirePaidAction(auth, navigate, { requiredRole: 'editor' })) return;
    if (dispatchingIds.has(workflow.id)) return;
    setDispatchingIds((prev) => {
      const next = new Set(prev);
      next.add(workflow.id);
      return next;
    });
    try {
      const targets = Array.isArray(testerTargets)
        ? testerTargets
          .map((target) => {
            if (typeof target === 'string') return { modelId: target, skillVersionId: null };
            return {
              modelId: target?.modelId || '',
              skillVersionId: target?.skillVersionId || null,
            };
          })
          .filter((target) => target.modelId)
        : [];
      const body = targets.length > 0 ? JSON.stringify({ testerTargets: targets }) : undefined;
      const result = await apiFetch(`/api/workflows/${workflow.id}/runs`, { method: 'POST', body });
      // Multi-target dispatch returns { runs: [...], summary: { queued, deduped, failed } }.
      // Single-target dispatch returns the legacy run-summary shape with
      // `deduped` at the top level.
      if (result?.summary) {
        const { queued, deduped, failed, total } = result.summary;
        // Numerator counts every run that _did_ fire in some form (newly
        // queued + deduped against an in-flight run). The previous numerator
        // (just `queued`) under-counted successes when both `failed > 0` and
        // `deduped > 0` overlapped — e.g. (1 queued, 1 deduped, 1 failed)
        // rendered "Started 1 of 3 runs" which implied 2 failures even
        // though only 1 actually failed.
        const succeeded = queued + deduped;
        if (failed > 0 && succeeded === 0) {
          toast.show({
            tone: 'bad',
            title: 'Could not start runs',
            description: result.runs?.[0]?.error?.message || 'All targets failed to dispatch.',
          });
        } else if (failed > 0) {
          toast.show({
            tone: 'warn',
            title: `Started ${succeeded} of ${total} runs`,
            description: `${failed} failed to dispatch${deduped ? `, ${deduped} reused an in-flight run` : ''}.`,
          });
        } else if (queued === 0 && deduped > 0) {
          toast.show({
            tone: 'warn',
            title: 'Runs already in progress',
            description: `Reused ${deduped} in-flight run${deduped === 1 ? '' : 's'} instead of dispatching new ones.`,
          });
        } else {
          toast.show({
            tone: 'ok',
            title: queued === 1 ? 'Run started' : `${queued} runs started`,
            description: deduped > 0
              ? `${deduped} reused an in-flight run. View them in run history.`
              : 'View them in run history.',
          });
        }
      } else if (result?.deduped && !result.terminal) {
        toast.show({
          tone: 'warn',
          title: 'Run already in progress',
          description: 'A matching run is already queued; reused that run instead of starting a new one.',
        });
      } else if (result?.deduped) {
        // Edge case: a run with the same default idempotency key just finished
        // within the dedupe bucket (1 minute). We surface the existing terminal
        // run and let the user retry once the bucket rolls over.
        toast.show({
          tone: 'warn',
          title: 'Recent run reused',
          description: 'A matching run finished moments ago; try again in a minute to start a fresh run.',
        });
      } else {
        toast.show({
          tone: 'ok',
          title: 'Run started',
          description: 'View it in run history.',
        });
      }
      // Reload so last_run / next_run_at columns reflect the new dispatch
      // shortly after the worker picks it up.
      workflows.reload();
    } catch (error) {
      toast.show({ tone: 'bad', title: 'Could not start run', description: error.message });
    } finally {
      setDispatchingIds((prev) => {
        const next = new Set(prev);
        next.delete(workflow.id);
        return next;
      });
    }
  }

  return (
    <div className="page-inner workflows-page">
      <div className="ui-page-head">
        <h1 className="ui-page-title">Workflows</h1>
        <div className="ui-page-actions">
          <Button
            variant="primary"
            size="sm"
            disabled={workflowCreateDisabled}
            onClick={startNewWorkflow}
            title={workflowCreateDisabledReason}>
            <Icon name="plus" size={13} />New workflow
          </Button>
        </div>
      </div>

      <div className="workflows-toolbar">
        <div className="seg workflows-seg">
          <button type="button" className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>
            All<span className="workflows-seg-count">{counts.all}</span>
          </button>
          <button type="button" className={filter === 'active' ? 'active' : ''} onClick={() => setFilter('active')}>
            Active<span className="workflows-seg-count">{counts.active}</span>
          </button>
          <button type="button" className={filter === 'paused' ? 'active' : ''} onClick={() => setFilter('paused')}>
            Paused<span className="workflows-seg-count">{counts.paused}</span>
          </button>
        </div>
        <div className="runs-search workflows-search">
          <Icon name="search" size={13} />
          <input
            placeholder="Filter by name or description"
            value={search}
            onChange={(e) => setSearch(e.target.value)} />
          {search && (
            <button type="button" className="runs-search-clear" onClick={() => setSearch('')} aria-label="Clear search">
              <Icon name="x" size={11} />
            </button>
          )}
        </div>
        <a
          className="workflows-toolbar-link"
          href="/runs"
          onClick={(e) => { e.preventDefault(); navigate('/runs'); }}>
          See all recent runs
        </a>
      </div>

      <div className="runs-table-wrap">
        <table className="runs-table workflows-table">
          <colgroup>
            <col className="col-workflow" />
            <col className="col-target" />
            <col className="col-schedule" />
            <col className="col-rate" />
            <col className="col-next" />
            <col className="col-status" />
            <col className="col-alerts" />
            <col className="col-actions" />
          </colgroup>
          <thead>
            <tr>
              <th>Workflow</th>
              <th>Target</th>
              <th>Schedule</th>
              <th>Pass rate (7d)</th>
              <th>Next run</th>
              <th>Status</th>
              <th>Alerts</th>
              <th className="col-actions-th"><span className="ui-visually-hidden">Actions</span></th>
            </tr>
          </thead>
          <tbody>
            {showInitialLoading ? (
              <WorkflowLoadingRows />
            ) : filtered.length === 0 ? (
              <tr className="runs-empty-row">
                <td colSpan={8}>
                  <EmptyState
                    icon="layers"
                    title={rows.length === 0 ? 'No workflows yet' : 'No workflows match your filters'}
                    body={rows.length === 0 ? 'Create a workflow to start running scheduled tests against your MCP servers.' : 'Try clearing the search or switching to All.'}
                    action={rows.length === 0 ? (
                      <Button
                        variant="primary"
                        size="sm"
                        disabled={!canManageWorkflows || !workflowCreateLimitState.allowed}
                        onClick={startNewWorkflow}
                        title={workflowCreateDisabledReason}>
                        <Icon name="plus" size={13} />New workflow
                      </Button>
                    ) : null} />
                </td>
              </tr>
            ) : filtered.map((workflow) => {
              const passRate = workflow.success_rate_7d;
              const passTone = typeof passRate === 'number' ? (passRate >= 0.95 ? 'ok' : passRate >= 0.8 ? 'warn' : 'bad') : 'neutral';
              const desc = workflow.description || workflow.current_version?.tester_prompt || '';
              const targetKind = workflow.target_kind === 'cli' ? 'cli' : 'mcp';
              const targetTypeLabel = targetKind === 'cli' ? 'CLI' : 'MCP';
              const targetName = workflow.target_name || (targetKind === 'cli' ? 'CLI target' : 'MCP server');
              const targetTitle = `${targetTypeLabel}: ${targetName}`;
              const scheduleCron = workflow.schedule?.cron_expression || '';
              const interval = cronIntervalSeconds(scheduleCron);
              const belowPlanFloor = workflow.schedule?.is_active
                && workflow.schedule?.schedule_type !== 'manual_only'
                && interval != null
                && interval < workflowMinIntervalSeconds(auth);
              const nextRunAt = workflow.is_active && workflow.schedule?.is_active && workflow.schedule?.schedule_type !== 'manual_only'
                ? workflow.schedule?.next_run_at
                : null;
              const lastRunAt = workflow.last_run?.started_at || workflow.last_run?.created_at || null;
              return (
                <tr
                  key={workflow.id}
                  className="workflows-row"
                  tabIndex={0}
                  aria-label={`Open analytics for ${workflow.name}`}
                  onClick={(event) => openWorkflowRunHistory(workflow, event)}
                  onKeyDown={(event) => handleWorkflowRowKeyDown(workflow, event)}>
                  <td className="workflows-cell workflows-cell-main">
                    <span className="workflows-row-name">{workflow.name}</span>
                    {desc ? (
                      <span className="workflows-row-desc-tooltip ui-tooltip" data-tooltip={desc} title={desc} tabIndex={0}>
                        <span className="workflows-row-desc text-xs muted">{desc}</span>
                      </span>
                    ) : null}
                  </td>
                  <td className="workflows-cell workflows-cell-target" title={targetTitle}>
                    <span className="workflows-target-kind">{targetTypeLabel}</span>
                    <span className="workflows-target-name">{targetName}</span>
                  </td>
                  <td className="workflows-cell workflows-cell-schedule" title={cronLabelTitle(workflow.schedule)}>
                    <div className="row" style={{ gap: 6 }}>
                      <Icon name="clock" size={12} />
                      <span>{scheduleLabel(workflow.schedule)}</span>
                    </div>
                    {belowPlanFloor && (
                      <div className="plan-limit-warning">
                        <Icon name="alert" size={12} /> Schedule is faster than your current plan allows.
                      </div>
                    )}
                  </td>
                  <td className="workflows-cell workflows-cell-rate">
                    <span className={`ui-pill ui-pill-${passTone} workflows-rate-pill`}>
                      {passRate === null ? '—' : formatPercent(passRate)}
                    </span>
                    <span className="text-xs muted workflows-rate-runs">{formatNumber(workflow.runs_7d || 0)} runs</span>
                  </td>
                  <td className="workflows-cell workflows-cell-next">
                    <span className="workflows-next-main">{nextRunAt ? formatRelative(nextRunAt) : '—'}</span>
                    {lastRunAt ? <span className="workflows-next-sub">Last {formatRelative(lastRunAt)}</span> : null}
                  </td>
                  <td className="workflows-cell workflows-cell-status">
                    <span className={`workflow-state ${workflow.is_active ? 'active' : 'paused'}`}>
                      {workflow.is_active ? 'Active' : 'Paused'}
                    </span>
                  </td>
                  <td className="workflows-cell workflows-cell-alerts" data-row-action="true">
                    <AlertRulesButton
                      rules={alertsByWorkflow[workflow.id] || []}
                      loading={alerts.loading && !alerts.data}
                      onOpen={() => setManageAlertsWorkflow(workflow)} />
                  </td>
                  <td className="workflows-cell workflows-cell-actions">
                    <div className="workflows-actions-row">
                      <button
                        type="button"
                        className="btn btn-sm workflows-action workflows-action-analytics"
                        data-row-action="true"
                        onClick={() => navigate(`/workflows/${workflow.id}/analytics`)}
                        title="Open workflow analytics">
                        <Icon name="activity" size={12} />Analytics
                      </button>
                      {canRunWorkflow && (
                        <WorkflowRunControl
                          workflow={workflow}
                          busy={dispatchingIds.has(workflow.id)}
                          onRun={runWorkflowNow} />
                      )}
                      <button
                        type="button"
                        className="btn btn-sm workflows-action workflows-action-runs"
                        data-row-action="true"
                        onClick={() => navigate(`/runs?workflowId=${workflow.id}`)}
                        title="View runs for this workflow">
                        <Icon name="list" size={12} />History
                      </button>
                      <button
                        type="button"
                        className="btn btn-sm workflows-action workflows-action-edit"
                        data-row-action="true"
                        disabled={!canManageWorkflows}
                        onClick={() => { if (canManageWorkflows) navigate(`/workflows/${workflow.id}`); }}
                        title={canManageWorkflows ? 'Edit workflow' : workflowActionDisabledReason}>
                        <Icon name="edit" size={12} /><span className="ui-visually-hidden">Edit</span>
                      </button>
                    </div>
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>

      {onboardingOpen && (
        <WorkflowOnboardingModal
          initialServerId={initialWorkflowServerId}
          onClose={() => setOnboardingOpen(false)}
          onCreated={(workflowId) => {
            setOnboardingOpen(false);
            workflows.reload();
            if (workflowId) navigate(newWorkflowRunsPath(workflowId));
          }}
          navigate={navigate}
        />
      )}

      <AlertRulesForTargetModal
        open={Boolean(manageAlertsWorkflow)}
        onClose={() => setManageAlertsWorkflow(null)}
        targetKind="workflow"
        targetId={manageAlertsWorkflow?.id || ''}
        targetName={manageAlertsWorkflow?.name || 'workflow'}
        alertsResource={alerts}
        navigate={navigate} />
    </div>
  );
}

function WorkflowLoadingRows() {
  return (
    <>
      {Array.from({ length: 4 }).map((_, index) => (
        <tr key={index} className="loading-row">
          <td><span className="skel skel-long" /></td>
          <td><span className="skel skel-mid" /></td>
          <td><span className="skel skel-mid" /></td>
          <td><span className="skel skel-short" /></td>
          <td><span className="skel skel-mid" /></td>
          <td><span className="skel skel-badge" /></td>
          <td><span className="skel skel-badge" /></td>
          <td><span className="skel skel-short" /></td>
        </tr>
      ))}
    </>
  );
}

const WORKFLOW_ANALYTICS_COMPARE_OPTIONS = [
  { value: 'harness_model', label: 'Harness x model' },
  { value: 'harness', label: 'Harness' },
  { value: 'model', label: 'Model' },
  { value: 'single', label: 'Single aggregate' },
];

const WORKFLOW_ANALYTICS_HARNESSES = {
  // Distinct colors per harness so a chart mixing the CLI and API
  // Anthropic surfaces (`claude` vs `anthropic_api`) renders two
  // visually distinguishable series. `#d97706` (amber) for Claude Code
  // and `#b45309` (darker amber) for the Claude API stay on-brand for
  // Anthropic but are easy to tell apart at a glance.
  codex: { color: '#2563eb', logoSrc: '/frontend/assets/logos/codex.png', label: 'Codex' },
  claude: { color: '#d97706', logoSrc: '/frontend/assets/logos/claude-code.png', label: 'Claude Code' },
  anthropic_api: { color: '#b45309', logoSrc: '/frontend/assets/logos/anthropic.svg.png', label: 'Claude' },
  openai_api: { color: '#10a37f', logoSrc: '/frontend/assets/logos/chatgpt.png', label: 'ChatGPT' },
  openclaw: { color: '#a16207', logoSrc: '/frontend/assets/logos/openclaw.svg', label: 'Openclaw' },
  gemini: { color: '#4285F4', logoSrc: '/frontend/assets/logos/gemini.png', label: 'Gemini CLI' },
  cursor: { color: '#0ea5e9', logoSrc: '/frontend/assets/logos/cursor.png', label: 'Cursor' },
  // OpenCode color: muted teal so it sits visually between Gemini's blue
  // and the openclaw amber on a mixed harness chart legend.
  opencode: { color: '#0d9488', logoSrc: '/frontend/assets/logos/opencode.svg', label: 'OpenCode' },
};

const WORKFLOW_ANALYTICS_SERIES_COLORS = ['#2563eb', '#d97706', '#0891b2', '#7c3aed', '#be123c', '#525252'];
const WORKFLOW_ANALYTICS_ANTHROPIC_NAMED_MODEL_COLORS = {
  opus: '#b45309',
  sonnet: '#ea580c',
  haiku: '#f97316',
};
const WORKFLOW_ANALYTICS_ANTHROPIC_FALLBACK_MODEL_COLORS = ['#c2410c', '#d97706', '#92400e', '#fb923c', '#9a3412'];

function workflowAnalyticsSeriesHarness(line = {}) {
  // Match the explicit sdk_key first so the API harnesses (anthropic_api,
  // openai_api) never fall through into the substring-based Claude Code /
  // Codex CLI buckets, which used to claim every Anthropic/OpenAI series
  // because they checked `includes('anthropic')` / `includes('openai')`.
  const explicit = String(line.harnessKey || '').toLowerCase();
  if (WORKFLOW_ANALYTICS_HARNESSES[explicit]) return WORKFLOW_ANALYTICS_HARNESSES[explicit];
  // sdk_key substring fallback (e.g. future `claude_code_2`, `codex_2`,
  // `anthropic_api_v2`). API-harness substrings come first so a key like
  // `anthropic_api_beta` lands on the Anthropic consumer brand, not Claude
  // Code; same for OpenAI vs Codex.
  // `openclaw` / `opencode` substrings are checked before anthropic/openai/claude
  // so a model labelled like "Claude Sonnet 4.5 via OpenCode" routes to the
  // opencode analytics bucket instead of being captured by `claude`. Same
  // precedence rule openclaw established.
  if (explicit.includes('openclaw')) return WORKFLOW_ANALYTICS_HARNESSES.openclaw;
  if (explicit.includes('opencode')) return WORKFLOW_ANALYTICS_HARNESSES.opencode;
  if (explicit.includes('anthropic')) return WORKFLOW_ANALYTICS_HARNESSES.anthropic_api;
  if (explicit.includes('openai')) return WORKFLOW_ANALYTICS_HARNESSES.openai_api;
  if (explicit.includes('claude')) return WORKFLOW_ANALYTICS_HARNESSES.claude;
  if (explicit.includes('codex')) return WORKFLOW_ANALYTICS_HARNESSES.codex;
  if (explicit.includes('gemini') || explicit.includes('google')) return WORKFLOW_ANALYTICS_HARNESSES.gemini;
  if (explicit.includes('cursor')) return WORKFLOW_ANALYTICS_HARNESSES.cursor;
  // Display-name fallback for legacy rows where the harness label is the
  // only signal we have. Order matters: brand strings ("ChatGPT", "Claude
  // Code") must win over generic substrings ("openai", "claude"), or we
  // re-introduce the bug this fix exists to address.
  const label = `${line.harnessName || ''} ${line.label || ''}`.toLowerCase();
  if (label.includes('openclaw')) return WORKFLOW_ANALYTICS_HARNESSES.openclaw;
  if (label.includes('opencode')) return WORKFLOW_ANALYTICS_HARNESSES.opencode;
  if (label.includes('chatgpt')) return WORKFLOW_ANALYTICS_HARNESSES.openai_api;
  if (label.includes('claude code') || label.includes('claude-code')) return WORKFLOW_ANALYTICS_HARNESSES.claude;
  if (label.includes('codex')) return WORKFLOW_ANALYTICS_HARNESSES.codex;
  if (label.includes('cursor')) return WORKFLOW_ANALYTICS_HARNESSES.cursor;
  if (label.includes('claude') || label.includes('anthropic')) return WORKFLOW_ANALYTICS_HARNESSES.anthropic_api;
  if (label.includes('openai') || label.includes('gpt')) return WORKFLOW_ANALYTICS_HARNESSES.openai_api;
  if (label.includes('gemini') || label.includes('google')) return WORKFLOW_ANALYTICS_HARNESSES.gemini;
  return null;
}

function workflowAnalyticsSeriesColor(line, index) {
  const harness = workflowAnalyticsSeriesHarness(line);
  if (harness === WORKFLOW_ANALYTICS_HARNESSES.anthropic_api) {
    return workflowAnalyticsAnthropicModelColor(line, index);
  }
  return harness?.color || WORKFLOW_ANALYTICS_SERIES_COLORS[index % WORKFLOW_ANALYTICS_SERIES_COLORS.length];
}

function workflowAnalyticsAnthropicModelColor(line = {}, index = 0) {
  const modelText = `${line.modelName || ''} ${line.modelId || ''} ${line.modelKey || ''} ${line.label || ''} ${line.key || ''}`.toLowerCase();
  if (modelText.includes('opus')) return WORKFLOW_ANALYTICS_ANTHROPIC_NAMED_MODEL_COLORS.opus;
  if (modelText.includes('sonnet')) return WORKFLOW_ANALYTICS_ANTHROPIC_NAMED_MODEL_COLORS.sonnet;
  if (modelText.includes('haiku')) return WORKFLOW_ANALYTICS_ANTHROPIC_NAMED_MODEL_COLORS.haiku;
  let hash = 0;
  for (let i = 0; i < modelText.length; i += 1) hash = ((hash * 31) + modelText.charCodeAt(i)) >>> 0;
  return WORKFLOW_ANALYTICS_ANTHROPIC_FALLBACK_MODEL_COLORS[(hash || index) % WORKFLOW_ANALYTICS_ANTHROPIC_FALLBACK_MODEL_COLORS.length];
}

const WORKFLOW_ANALYTICS_METRICS = [
  { key: 'score', label: 'Score / pass rate', shortLabel: 'Score', icon: 'check' },
  { key: 'duration', label: 'Duration', shortLabel: 'Duration', icon: 'clock' },
  { key: 'toolCalls', label: 'Tool calls', shortLabel: 'Tools', icon: 'tool' },
  { key: 'tokens', label: 'Tokens', shortLabel: 'Tokens', icon: 'cpu' },
];

function parseWorkflowAnalyticsQuery(queryString = '') {
  const params = new URLSearchParams(String(queryString || '').replace(/^\?/, ''));
  return {
    range: params.get('range') || 'auto',
    from: params.get('from') || params.get('dateFrom') || '',
    to: params.get('to') || params.get('dateTo') || '',
    metric: params.get('metric') || 'score',
    compare: params.get('compare') || 'harness_model',
    harness: params.get('harness') || 'all',
    model: params.get('model') || 'all',
  };
}

function workflowAnalyticsQuery(filters) {
  const params = new URLSearchParams();
  if (filters.from && filters.to) {
    params.set('from', filters.from);
    params.set('to', filters.to);
  } else {
    params.set('range', filters.range || 'auto');
  }
  params.set('metric', filters.metric || 'score');
  params.set('compare', filters.compare || 'harness_model');
  if (filters.harness && filters.harness !== 'all') params.set('harness', filters.harness);
  if (filters.model && filters.model !== 'all') params.set('model', filters.model);
  return params.toString();
}

function workflowAnalyticsMetricDef(metricKey) {
  return WORKFLOW_ANALYTICS_METRICS.find((metric) => metric.key === metricKey) || WORKFLOW_ANALYTICS_METRICS[0];
}

function WorkflowAnalyticsPage({ workflowId, navigate, queryString = '' }) {
  const routeFilters = useMemoW(() => parseWorkflowAnalyticsQuery(queryString), [queryString]);
  const [filters, setFilters] = useStateW(() => routeFilters);
  const [selectedRuns, setSelectedRuns] = useStateW(null);
  const query = useMemoW(() => workflowAnalyticsQuery(filters), [filters]);
  const analytics = useApiResource(`/api/workflows/${workflowId}/analytics?${query}`, [workflowId, query]);
  const data = analytics.data || {};
  const workflow = data.workflow || {};
  const summary = data.summary || {};
  const focusedMetric = workflowAnalyticsMetricDef(filters.metric);
  const harnessOptions = useMemoW(() => workflowAnalyticsHarnessOptions(data, filters.harness), [data, filters.harness]);
  const modelOptions = useMemoW(() => workflowAnalyticsModelOptions(data, filters.model), [data, filters.model]);
  const selectedRunSet = selectedRuns ? new Set(selectedRuns.runIds || []) : null;
  const knownRunMap = useMemoW(() => {
    const map = new Map();
    for (const run of [...(data.recentRuns || []), ...(data.outliers || [])]) map.set(run.runId, run);
    return map;
  }, [data]);
  const selectedRunRows = selectedRuns
    ? (selectedRuns.runIds || []).map((runId) => knownRunMap.get(runId) || ({ runId, productStatus: 'pending' }))
    : null;
  const visibleRecentRuns = selectedRunSet
    ? (data.recentRuns || []).filter((run) => selectedRunSet.has(run.runId))
    : (data.recentRuns || []);
  const analyticsLoading = analytics.loading;

  useEffectW(() => {
    setSelectedRuns(null);
    setFilters(routeFilters);
  }, [routeFilters]);

  function setFilter(key, value) {
    const next = { ...filters, [key]: value };
    setSelectedRuns(null);
    setFilters(next);
    navigate(`/workflows/${workflowId}/analytics?${workflowAnalyticsQuery(next)}`);
  }

  function setTimeRange(nextTimeRange) {
    const next = { ...filters, from: '', to: '' };
    if (nextTimeRange.type === 'absolute') {
      next.range = '';
      next.from = new Date(nextTimeRange.from).toISOString();
      next.to = new Date(nextTimeRange.to).toISOString();
    } else {
      next.range = nextTimeRange.range || 'auto';
    }
    setSelectedRuns(null);
    setFilters(next);
    navigate(`/workflows/${workflowId}/analytics?${workflowAnalyticsQuery(next)}`);
  }

  function applyMatrixFilter(row) {
    const next = {
      ...filters,
      harness: row.harnessKey || 'all',
      model: row.modelId || row.modelName || 'all',
      compare: 'harness_model',
    };
    setSelectedRuns(null);
    setFilters(next);
    navigate(`/workflows/${workflowId}/analytics?${workflowAnalyticsQuery(next)}`);
  }

  function selectChartPoint(metricKey, series, point) {
    setSelectedRuns({
      metricKey,
      seriesLabel: series.label,
      bucketStart: point.bucketStart,
      bucketEnd: point.bucketEnd,
      runIds: point.runIds || [],
    });
  }

  return (
    <div className="page-inner workflows-page workflow-analytics-page">
      <button type="button" className="btn btn-ghost btn-sm workflow-back workflow-analytics-back" onClick={() => navigate('/workflows')}>
        <Icon name="chevronLeft" size={12} />Workflows
      </button>

      <WorkflowAnalyticsDetailCard
        workflow={workflow}
        loading={analyticsLoading && !workflow.id}
        onEdit={() => navigate(`/workflows/${workflowId}`)}
        onRunHistory={() => navigate(`/runs?workflowId=${workflowId}`)} />

      {analytics.error ? (
        <div className="workflow-analytics-error">
          <EmptyState
            icon="alert"
            title="Could not load analytics"
            body={analytics.error.message}
            action={<Button size="sm" onClick={analytics.reload}><Icon name="refresh" size={12} />Retry</Button>} />
        </div>
      ) : (
        <>
          <WorkflowAnalyticsHealthStrip summary={summary} loading={analyticsLoading} />

          <div className="workflow-analytics-controls">
            <div className="workflow-analytics-time-control">
              <DashboardTimePicker label="Time range" timeRange={workflowAnalyticsTimeRange(filters, data)} onChange={setTimeRange} />
            </div>
            <Select label="Metric focus" value={filters.metric} options={WORKFLOW_ANALYTICS_METRICS.map((metric) => ({ value: metric.key, label: metric.label }))} onChange={(value) => setFilter('metric', value)} />
            <Select label="Compare by" value={filters.compare} options={WORKFLOW_ANALYTICS_COMPARE_OPTIONS} onChange={(value) => setFilter('compare', value)} />
            <Select label="Harness" value={filters.harness} options={harnessOptions} onChange={(value) => setFilter('harness', value)} searchable />
            <Select label="Model" value={filters.model} options={modelOptions} onChange={(value) => setFilter('model', value)} searchable />
          </div>

          <div className="workflow-analytics-resolution">
            <span>{data.resolution?.mode === 'raw' ? 'Run-level points' : 'Bucketed time series'}</span>
            <span>{formatNumber(data.resolution?.totalRunCount || 0)} runs in range</span>
            {selectedRuns ? (
              <button type="button" className="btn btn-sm btn-ghost" onClick={() => setSelectedRuns(null)}>
                <Icon name="x" size={11} />Clear selection
              </button>
            ) : null}
          </div>

          <div className="workflow-analytics-small-multiples">
            {WORKFLOW_ANALYTICS_METRICS.map((metric) => (
              <button
                type="button"
                key={metric.key}
                className={`workflow-analytics-card workflow-analytics-chart-card ${filters.metric === metric.key ? 'active' : ''}`}
                onClick={() => setFilter('metric', metric.key)}>
                <div className="workflow-analytics-card-head">
                  <span><Icon name={metric.icon} size={13} />{metric.label}</span>
                  <strong>
                    {analyticsLoading
                      ? <span className="skel skel-short workflow-analytics-card-value-skel"></span>
                      : workflowAnalyticsLatestValue(data.series?.[metric.key], metric.key)}
                  </strong>
                </div>
                <WorkflowAnalyticsChart
                  metricKey={metric.key}
                  series={data.series?.[metric.key] || []}
                  compact
                  loading={analyticsLoading}
                  onSelect={selectChartPoint} />
              </button>
            ))}
          </div>

          <section className="workflow-analytics-card workflow-analytics-focus">
            <div className="workflow-analytics-section-head">
              <div>
                <h2>{focusedMetric.label}</h2>
                <p className="muted text-xs">Series follow the active compare mode and filters.</p>
              </div>
              <span className="ui-pill ui-pill-neutral">{WORKFLOW_ANALYTICS_COMPARE_OPTIONS.find((option) => option.value === filters.compare)?.label || 'Compare'}</span>
            </div>
            <WorkflowAnalyticsChart
              metricKey={focusedMetric.key}
              series={data.series?.[focusedMetric.key] || []}
              loading={analyticsLoading}
              onSelect={selectChartPoint} />
            {selectedRuns ? (
              <div className="workflow-analytics-selection">
                <Icon name="filter" size={13} />
                <span>{selectedRuns.seriesLabel}</span>
                <span>{formatAnalyticsBucket(selectedRuns.bucketStart, selectedRuns.bucketEnd)}</span>
                <strong>{formatNumber(selectedRuns.runIds.length)} runs</strong>
              </div>
            ) : null}
          </section>

          <div className="workflow-analytics-details">
            <div className="workflow-analytics-detail-column">
              <WorkflowAnalyticsMatrix rows={data.matrix || []} loading={analyticsLoading} onPick={applyMatrixFilter} />
              <WorkflowAnalyticsRunTable
                title={selectedRuns ? 'Selected runs' : 'Outlier runs'}
                rows={selectedRunRows || (data.outliers || [])}
                emptyTitle={selectedRuns ? 'No runs contributed to this point' : 'No outliers yet'}
                loading={analyticsLoading}
                navigate={navigate} />
            </div>
            <div className="workflow-analytics-detail-column">
              <WorkflowAnalyticsToolBreakdown rows={data.toolBreakdown || []} loading={analyticsLoading} navigate={navigate} />
            </div>
          </div>
          <div className="workflow-analytics-recent">
            <WorkflowAnalyticsRunTable
              title="Recent runs"
              rows={visibleRecentRuns}
              emptyTitle="No runs in this range"
              loading={analyticsLoading}
              navigate={navigate}
              headerAction={(
                <a
                  className="workflow-analytics-recent-link"
                  href={`/runs?workflowId=${encodeURIComponent(workflowId)}`}
                  onClick={(e) => { e.preventDefault(); navigate(`/runs?workflowId=${encodeURIComponent(workflowId)}`); }}>
                  See all runs
                </a>
              )} />
          </div>
        </>
      )}
    </div>
  );
}

function WorkflowAnalyticsDetailCard({ workflow, loading, onEdit, onRunHistory }) {
  const isActive = Boolean(workflow.is_active);
  const description = workflow.description || workflow.tester_prompt || '';
  const targetKind = workflow.target_kind === 'cli' ? 'cli' : 'mcp';
  const targetTypeLabel = targetKind === 'cli' ? 'CLI' : 'MCP';
  const targetName = workflow.target_name || workflow.mcp_server_name
    || (targetKind === 'cli' ? 'CLI target' : 'MCP server');
  const targetEnvironment = workflow.target_environment || workflow.mcp_environment || '';
  const schedule = workflow.schedule || null;
  const showsNextRun = isActive
    && schedule?.is_active
    && schedule?.schedule_type
    && schedule.schedule_type !== 'manual_only';
  const nextRunAt = showsNextRun ? schedule?.next_run_at : null;
  const targets = Array.isArray(workflow.tester_targets) ? workflow.tester_targets : [];

  return (
    <section className="workflow-analytics-detail-card" aria-label="Workflow details">
      <header className="workflow-analytics-detail-head">
        <div className="workflow-analytics-detail-title-block">
          <div className="workflow-analytics-detail-title-row">
            <h1 className="ui-page-title workflow-analytics-detail-title">
              {workflow.name || (loading ? 'Loading workflow…' : 'Workflow analytics')}
            </h1>
            <span className={`workflow-state ${isActive ? 'active' : 'paused'}`}>
              {isActive ? 'Active' : 'Paused'}
            </span>
          </div>
          {description ? (
            <p className="workflow-analytics-detail-desc">{description}</p>
          ) : null}
        </div>
        <div className="workflow-analytics-detail-actions">
          <Button size="sm" onClick={onRunHistory}>
            <Icon name="list" size={12} />Run history
          </Button>
          <Button size="sm" variant="primary" onClick={onEdit}>
            <Icon name="edit" size={12} />Edit
          </Button>
        </div>
      </header>

      <dl className="workflow-analytics-detail-grid">
        <div className="workflow-analytics-detail-field">
          <dt>{targetTypeLabel} target</dt>
          <dd>
            <div className="workflow-analytics-detail-field-main">
              <Icon name={targetKind === 'cli' ? 'terminal' : 'mcp'} size={13} />
              <span>{targetName}</span>
            </div>
            {targetEnvironment ? (
              <span className="workflow-analytics-detail-field-sub">{targetEnvironment}</span>
            ) : null}
          </dd>
        </div>

        <div className="workflow-analytics-detail-field workflow-analytics-detail-field-targets">
          <dt>Targets</dt>
          <dd>
            {targets.length === 0 ? (
              <span className="workflow-analytics-detail-field-empty">No active targets</span>
            ) : (
              <ul className="workflow-analytics-target-chips">
                {targets.map((target, index) => (
                  <li key={`${target.modelId || 'target'}-${target.skillVersionId || 'none'}-${index}`}>
                    <WorkflowAnalyticsHarnessCell
                      sdkKey={target.sdkKey || target.providerKey || null}
                      sdkName={target.sdkName || null}
                      modelName={target.modelName || ''} />
                    {target.skillMode === 'uploaded' && target.skillName ? (
                      <span className="workflow-analytics-target-skill" title={`Skill v${target.skillVersionNumber || ''}`}>
                        <Icon name="sparkle" size={11} />{target.skillName}
                      </span>
                    ) : null}
                  </li>
                ))}
              </ul>
            )}
          </dd>
        </div>

        <div className="workflow-analytics-detail-field">
          <dt>Schedule</dt>
          <dd>
            <div className="workflow-analytics-detail-field-main">
              <Icon name="clock" size={13} />
              <span title={cronLabelTitle(schedule)}>{scheduleLabel(schedule)}</span>
            </div>
            {nextRunAt ? (
              <span className="workflow-analytics-detail-field-sub">Next {formatRelative(nextRunAt)}</span>
            ) : null}
          </dd>
        </div>

        <div className="workflow-analytics-detail-field">
          <dt>Last run</dt>
          <dd>
            <div className="workflow-analytics-detail-field-main">
              {workflow.last_run_at ? formatRelative(workflow.last_run_at) : <span className="muted">No runs yet</span>}
            </div>
          </dd>
        </div>

        <div className="workflow-analytics-detail-field">
          <dt>Created</dt>
          <dd>
            <div className="workflow-analytics-detail-field-main">
              {workflow.created_at ? formatRelative(workflow.created_at) : '—'}
            </div>
            {workflow.workflow_version_number ? (
              <span className="workflow-analytics-detail-field-sub">Version {workflow.workflow_version_number}</span>
            ) : null}
          </dd>
        </div>
      </dl>
    </section>
  );
}

function WorkflowAnalyticsHealthStrip({ summary, loading }) {
  const passTone = summary.passRate === null || summary.passRate === undefined
    ? 'neutral'
    : summary.passRate >= 0.95 ? 'ok' : summary.passRate >= 0.8 ? 'warn' : 'bad';
  const streakLabel = summary.currentStreak?.count
    ? `${formatNumber(summary.currentStreak.count)} ${summary.currentStreak.status || ''}`
    : '—';
  const items = [
    ['Pass rate', formatPercent(summary.passRate), passTone],
    ['Current streak', streakLabel, summary.currentStreak?.status ? (summary.currentStreak.status === 'success' ? 'ok' : 'warn') : 'neutral'],
    ['Runs', formatNumber(summary.totalRuns || 0), 'neutral'],
    ['Partial', formatNumber(summary.partialRuns || 0), (summary.partialRuns || 0) > 0 ? 'warn' : 'neutral'],
    ['P95 duration', formatDuration(summary.p95DurationMs), 'neutral'],
    ['P95 tools', formatNumber(summary.p95ToolCalls), 'neutral'],
  ];
  return (
    <div className="workflow-analytics-health">
      {items.map(([label, value, tone]) => (
        <div key={label} className={`workflow-analytics-kpi ${loading ? 'loading-row' : ''}`} aria-busy={loading ? 'true' : undefined}>
          <span>{label}</span>
          {loading ? (
            <strong aria-hidden="true"><span className="skel skel-mid workflow-analytics-kpi-skel"></span></strong>
          ) : (
            <strong className={tone ? `tone-${tone}` : ''}>{value}</strong>
          )}
        </div>
      ))}
    </div>
  );
}

function workflowChartTooltipId(metricKey, line, point, index) {
  return `workflow-chart-tip-${metricKey}-${index}-${String(line?.key || line?.label || 'series').replace(/[^a-z0-9]/gi, '-')}-${String(point?.bucketStart || '').replace(/[^a-z0-9]/gi, '-')}`;
}

function workflowAnalyticsMetricChartValue(metricKey, value, point) {
  if (value === null || value === undefined || !Number.isFinite(Number(value))) return null;
  if (metricKey === 'score' && point?.valueKind === 'passRate') return Number(value) * 5;
  return Number(value);
}

function WorkflowAnalyticsChartSkeleton({ compact = false }) {
  const bars = compact ? 10 : 18;
  return (
    <div className={`workflow-analytics-chart ${compact ? 'compact' : ''}`} role="status" aria-label="Loading workflow analytics chart" aria-busy="true">
      <div className="workflow-chart-plot">
        <div className="workflow-analytics-chart-skeleton" aria-hidden="true">
          {Array.from({ length: bars }).map((_, index) => (
            <span key={index} className="skel workflow-analytics-chart-skeleton-bar" style={{ height: `${30 + ((index * 19) % 58)}%` }}></span>
          ))}
        </div>
      </div>
      {!compact && (
        <div className="workflow-chart-legend workflow-chart-legend-loading" aria-hidden="true">
          <span><i></i><span className="skel skel-mid"></span></span>
          <span><i></i><span className="skel skel-short"></span></span>
          <span><i></i><span className="skel skel-long"></span></span>
        </div>
      )}
    </div>
  );
}

function WorkflowAnalyticsChart({ metricKey, series = [], compact = false, loading = false, onSelect }) {
  const [hoveredPoint, setHoveredPoint] = useStateW(null);
  const width = compact ? 320 : 920;
  const height = compact ? 132 : 280;
  const padding = compact ? { left: 10, right: 10, top: 12, bottom: 18 } : { left: 42, right: 18, top: 22, bottom: 34 };
  const points = series.flatMap((line) => (line.points || []).map((point) => ({ line, point })))
    .filter(({ point }) => point.value !== null && point.value !== undefined && Number.isFinite(Number(point.value)));
  if (loading) return <WorkflowAnalyticsChartSkeleton compact={compact} />;
  if (points.length === 0) return <div className={`workflow-analytics-chart ${compact ? 'compact' : ''}`}><div className="chart-empty">No data</div></div>;
  const times = points.map(({ point }) => new Date(point.bucketStart).getTime()).filter(Number.isFinite);
  const scoreHasEvaluatorScore = metricKey === 'score' && points.some(({ point }) => point.valueKind === 'score');
  const values = points.map(({ point }) => workflowAnalyticsMetricChartValue(metricKey, point.value, point)).filter((value) => value !== null);
  const minTime = Math.min(...times);
  const maxTime = Math.max(...times);
  const minValue = metricKey === 'score' ? 0 : Math.min(0, Math.min(...values));
  const maxValue = Math.max(metricKey === 'score' ? 5 : 1, ...values);
  const axisValue = metricKey === 'score' && !scoreHasEvaluatorScore ? 1 : maxValue;
  const axisPoint = metricKey === 'score' && !scoreHasEvaluatorScore ? { valueKind: 'passRate' } : { valueKind: 'score' };
  const plotWidth = width - padding.left - padding.right;
  const plotHeight = height - padding.top - padding.bottom;
  const xFor = (value) => padding.left + ((new Date(value).getTime() - minTime) / Math.max(1, maxTime - minTime)) * plotWidth;
  const yFor = (value) => padding.top + (1 - ((Number(value) - minValue) / Math.max(1, maxValue - minValue))) * plotHeight;
  function showTooltip(line, point, index) {
    const x = xFor(point.bucketStart);
    const y = yFor(workflowAnalyticsMetricChartValue(metricKey, point.value, point));
    const placement = x < 150 ? 'right' : x > width - 150 ? 'left' : 'center';
    setHoveredPoint({
      id: workflowChartTooltipId(metricKey, line, point, index),
      label: line.label,
      bucket: formatAnalyticsBucket(point.bucketStart, point.bucketEnd),
      value: formatAnalyticsMetricValue(metricKey, point.value, point),
      runCount: point.runCount,
      color: workflowAnalyticsSeriesColor(line, index),
      left: `${(x / width) * 100}%`,
      top: `${(y / height) * 100}%`,
      placement,
    });
  }
  return (
    <div className={`workflow-analytics-chart ${compact ? 'compact' : ''}`} onMouseLeave={() => setHoveredPoint(null)}>
      <div className="workflow-chart-plot">
        <svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label={`${metricKey} over time`}>
          <line className="workflow-chart-grid" x1={padding.left} y1={padding.top} x2={width - padding.right} y2={padding.top} />
          <line className="workflow-chart-grid" x1={padding.left} y1={padding.top + plotHeight / 2} x2={width - padding.right} y2={padding.top + plotHeight / 2} />
          <line className="workflow-chart-grid" x1={padding.left} y1={height - padding.bottom} x2={width - padding.right} y2={height - padding.bottom} />
          {!compact && (
            <>
              <text className="workflow-chart-axis" x={padding.left} y={height - 8}>{formatAnalyticsBucket(minTime, minTime)}</text>
              <text className="workflow-chart-axis end" x={width - padding.right} y={height - 8}>{formatAnalyticsBucket(maxTime, maxTime)}</text>
              <text className="workflow-chart-axis" x={4} y={padding.top + 4}>{formatAnalyticsMetricValue(metricKey, axisValue, axisPoint)}</text>
            </>
          )}
          {series.map((line, index) => {
            const valid = (line.points || []).filter((point) => point.value !== null && point.value !== undefined && Number.isFinite(Number(point.value)));
            const polyline = valid.map((point) => `${xFor(point.bucketStart)},${yFor(workflowAnalyticsMetricChartValue(metricKey, point.value, point))}`).join(' ');
            const seriesColor = workflowAnalyticsSeriesColor(line, index);
            return (
              <g key={line.key || index} className={`workflow-chart-series series-${index % 6}`} style={{ color: seriesColor }}>
                {valid.length > 1 ? <polyline points={polyline} /> : null}
                {valid.map((point, pointIndex) => {
                  const tooltipId = workflowChartTooltipId(metricKey, line, point, index);
                  return (
                    <circle
                      key={`${line.key}-${point.bucketStart}-${pointIndex}`}
                      cx={xFor(point.bucketStart)}
                      cy={yFor(workflowAnalyticsMetricChartValue(metricKey, point.value, point))}
                      r={compact ? 2 : 3}
                      tabIndex={0}
                      aria-describedby={hoveredPoint?.id === tooltipId ? tooltipId : undefined}
                      aria-label={`${line.label}: ${formatAnalyticsMetricValue(metricKey, point.value, point)} at ${formatAnalyticsBucket(point.bucketStart, point.bucketEnd)} across ${formatNumber(point.runCount)} runs`}
                      onMouseEnter={() => showTooltip(line, point, index)}
                      onFocus={() => showTooltip(line, point, index)}
                      onBlur={() => setHoveredPoint(null)}
                      onClick={(event) => { event.stopPropagation(); onSelect?.(metricKey, line, point); }}
                      onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onSelect?.(metricKey, line, point); } }} />
                  );
                })}
              </g>
            );
          })}
        </svg>
        {hoveredPoint ? (
          <div
            id={hoveredPoint.id}
            role="tooltip"
            className={`workflow-chart-tooltip placement-${hoveredPoint.placement}`}
            style={{ left: hoveredPoint.left, top: hoveredPoint.top }}>
            <div className="workflow-chart-tooltip-title">
              <i aria-hidden="true" style={{ color: hoveredPoint.color }}></i>
              <span>{hoveredPoint.label}</span>
            </div>
            <div className="workflow-chart-tooltip-grid">
              <span>Time</span><strong>{hoveredPoint.bucket}</strong>
              <span>{workflowAnalyticsMetricDef(metricKey).shortLabel}</span><strong>{hoveredPoint.value}</strong>
              <span>Runs</span><strong>{formatNumber(hoveredPoint.runCount)}</strong>
            </div>
          </div>
        ) : null}
      </div>
      {!compact && (
        <div className="workflow-chart-legend">
          {series.slice(0, 8).map((line, index) => {
            const harness = workflowAnalyticsSeriesHarness(line);
            return (
              <span key={line.key || index} className={`series-${index % 6}`} style={{ color: workflowAnalyticsSeriesColor(line, index) }}>
                {harness?.logoSrc ? <img className="workflow-chart-legend-logo" src={harness.logoSrc} alt="" aria-hidden="true" /> : <i></i>}
                {line.label}
              </span>
            );
          })}
        </div>
      )}
    </div>
  );
}

function WorkflowAnalyticsTableLoadingRows({ columns, rows = 4 }) {
  return (
    <>
      {Array.from({ length: rows }).map((_, rowIndex) => (
        <tr key={rowIndex} className="loading-row">
          {Array.from({ length: columns }).map((__, columnIndex) => (
            <td key={columnIndex} className={columnIndex > 1 ? 'num' : undefined}>
              <span className={`skel ${columnIndex === 0 ? 'skel-long' : columnIndex === columns - 1 ? 'skel-short' : 'skel-mid'}`}></span>
            </td>
          ))}
        </tr>
      ))}
    </>
  );
}

function WorkflowAnalyticsMatrix({ rows, loading, onPick }) {
  return (
    <section className="workflow-analytics-card">
      <div className="workflow-analytics-section-head">
        <h2>Harness x model</h2>
        <span className="text-xs muted">{loading ? <span className="skel skel-short"></span> : `${formatNumber(rows.length)} combinations`}</span>
      </div>
      <div className="workflow-analytics-table-wrap">
        <table className="runs-table workflow-analytics-table">
          <thead>
            <tr>
              <th>Harness</th>
              <th>Model</th>
              <th className="num">Pass</th>
              <th className="num">Partial</th>
              <th className="num">P95 duration</th>
              <th className="num">P95 tools</th>
              <th className="num">Runs</th>
            </tr>
          </thead>
          <tbody>
            {loading ? (
              <WorkflowAnalyticsTableLoadingRows columns={7} />
            ) : rows.length === 0 ? (
              <tr className="runs-empty-row"><td colSpan={7}>No combinations in this range.</td></tr>
            ) : rows.map((row) => (
              <tr key={`${row.harnessKey}-${row.modelId || row.modelName}`} onClick={() => onPick?.(row)}>
                <td><WorkflowAnalyticsHarnessCell sdkKey={row.harnessKey} sdkName={row.harnessName} /></td>
                <td className="mono">{displayModelName(row.modelName || row.modelId || 'Unknown')}</td>
                <td className="num"><span className={`ui-pill ui-pill-${analyticsPassTone(row.passRate)}`}>{formatPercent(row.passRate)}</span></td>
                <td className="num">{formatNumber(row.partialRuns || 0)}</td>
                <td className="num">{formatDuration(row.p95DurationMs)}</td>
                <td className="num">{formatNumber(row.p95ToolCalls)}</td>
                <td className="num">{formatNumber(row.runCount)}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </section>
  );
}

function WorkflowAnalyticsToolBreakdown({ rows, loading, navigate }) {
  return (
    <section className="workflow-analytics-card">
      <div className="workflow-analytics-section-head">
        <h2>Tools and commands</h2>
        <span className="text-xs muted">{loading ? <span className="skel skel-short"></span> : 'Failures first'}</span>
      </div>
      <div className="workflow-analytics-table-wrap">
        <table className="runs-table workflow-analytics-table">
          <thead>
            <tr>
              <th>Tool</th>
              <th className="num">Calls</th>
              <th className="num">Failed</th>
              <th className="num">Timeouts</th>
              <th className="num">P95</th>
            </tr>
          </thead>
          <tbody>
            {loading ? (
              <WorkflowAnalyticsTableLoadingRows columns={5} />
            ) : rows.length === 0 ? (
              <tr className="runs-empty-row"><td colSpan={5}>No tool calls in this range.</td></tr>
            ) : rows.map((row) => (
              <tr key={row.toolName} onClick={() => row.latestRunId && navigate(`/runs/${row.latestRunId}`)}>
                <td className="mono">{row.toolName}</td>
                <td className="num">{formatNumber(row.callCount)}</td>
                <td className="num">{formatNumber(row.failedCount)}</td>
                <td className="num">{formatNumber(row.timeoutCount)}</td>
                <td className="num">{formatDuration(row.p95DurationMs)}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </section>
  );
}

function WorkflowAnalyticsRunTable({ title, rows, emptyTitle, loading, navigate, headerAction = null }) {
  return (
    <section className="workflow-analytics-card">
      <div className="workflow-analytics-section-head">
        <h2>{title}</h2>
        <div className="workflow-analytics-section-head-meta">
          <span className="text-xs muted">{loading ? <span className="skel skel-short"></span> : `${formatNumber(rows.length)} runs`}</span>
          {headerAction}
        </div>
      </div>
      <div className="workflow-analytics-table-wrap">
        <table className="runs-table workflow-analytics-table">
          <thead>
            <tr>
              <th>Status</th>
              <th>Run</th>
              <th>Harness</th>
              <th className="num">Duration</th>
              <th className="num">Tools</th>
              <th className="num">Tokens</th>
              <th>Started</th>
            </tr>
          </thead>
          <tbody>
            {loading ? (
              <WorkflowAnalyticsTableLoadingRows columns={7} />
            ) : rows.length === 0 ? (
              <tr className="runs-empty-row"><td colSpan={7}>{emptyTitle}</td></tr>
            ) : rows.map((run) => (
              <tr key={`${title}-${run.runId}-${run.reason || ''}`} onClick={() => navigate(`/runs/${run.runId}`)}>
                <td><StatusBadge status={run.productStatus} /></td>
                <td><span className="mono">{shortId(run.runId)}</span>{run.reason ? <span className="workflow-run-reason">{run.reason.replaceAll('_', ' ')}</span> : null}</td>
                <td><WorkflowAnalyticsHarnessCell sdkKey={run.testerSdkKey} sdkName={run.testerSdkDisplayName} modelName={run.testerModelName} /></td>
                <td className="num">{formatDuration(run.durationMs)}</td>
                <td className="num">{formatNumber(run.toolCallCount)}</td>
                <td className="num">{formatNumber(run.tokensTotal)}</td>
                <td className="muted">{formatRelative(run.startedAt)}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </section>
  );
}

function WorkflowAnalyticsHarnessCell({ sdkKey, sdkName, modelName = '' }) {
  const logos = {
    codex: { src: '/frontend/assets/logos/codex.png', label: 'Codex' },
    claude: { src: '/frontend/assets/logos/claude-code.png', label: 'Claude Code' },
    anthropic_api: { src: '/frontend/assets/logos/anthropic.svg.png', label: 'Claude' },
    openai_api: { src: '/frontend/assets/logos/chatgpt.png', label: 'ChatGPT' },
    gemini: { src: '/frontend/assets/logos/gemini.png', label: 'Gemini CLI' },
    cursor: { src: '/frontend/assets/logos/cursor.png', label: 'Cursor' },
    openclaw: { src: '/frontend/assets/logos/openclaw.svg', label: 'Openclaw' },
    opencode: { src: '/frontend/assets/logos/opencode.svg', label: 'OpenCode' },
  };
  const logo = sdkKey ? logos[sdkKey] : null;
  const harnessLabel = logo?.label || sdkName || sdkKey || 'Harness';
  return (
    <span className="harness-cell">
      <span className="harness-cell-glyph" aria-hidden>
        {logo
          ? <img src={logo.src} alt="" className="harness-cell-logo" width="14" height="14" />
          : <Icon name="cpu" size={12} />}
      </span>
      <span className="harness-cell-name">{harnessLabel}</span>
      {modelName && <span className="harness-cell-model mono">{displayModelName(modelName)}</span>}
    </span>
  );
}

function workflowAnalyticsTimeRange(filters, data) {
  const explicitFrom = parseDashboardTimeParam(filters.from);
  const explicitTo = parseDashboardTimeParam(filters.to);
  if (explicitFrom !== null && explicitTo !== null && explicitFrom < explicitTo) {
    return { type: 'absolute', from: explicitFrom, to: explicitTo };
  }
  if (filters.range && filters.range !== 'auto') {
    return { type: 'relative', range: filters.range };
  }
  const resolvedFrom = parseDashboardTimeParam(data.filters?.dateFrom || data.workflow?.created_at);
  const resolvedTo = parseDashboardTimeParam(data.filters?.dateTo);
  if (resolvedFrom !== null && resolvedTo !== null && resolvedFrom < resolvedTo) {
    return { type: 'absolute', from: resolvedFrom, to: resolvedTo };
  }
  return { type: 'relative', range: '24h' };
}

function workflowAnalyticsHarnessOptions(data, selected) {
  const map = new Map();
  for (const row of data.matrix || []) {
    if (row.harnessKey) map.set(row.harnessKey, row.harnessName || row.harnessKey);
  }
  if (selected && selected !== 'all' && !map.has(selected)) map.set(selected, selected);
  return [{ value: 'all', label: 'All harnesses' }, ...[...map.entries()].map(([value, label]) => ({ value, label }))];
}

function workflowAnalyticsModelOptions(data, selected) {
  const map = new Map();
  for (const row of data.matrix || []) {
    const value = row.modelId || row.modelName;
    if (value) map.set(value, row.modelName || value);
  }
  if (selected && selected !== 'all' && !map.has(selected)) map.set(selected, selected);
  return [{ value: 'all', label: 'All models' }, ...[...map.entries()].map(([value, label]) => ({ value, label: displayModelName(label) }))];
}

function workflowAnalyticsLatestValue(series, metricKey) {
  const values = (series || []).flatMap((line) => line.points || [])
    .filter((point) => point.value !== null && point.value !== undefined);
  const latest = values.reduce((best, point) => {
    if (!best) return point;
    return new Date(point.bucketStart).getTime() >= new Date(best.bucketStart).getTime() ? point : best;
  }, null);
  return latest ? formatAnalyticsMetricValue(metricKey, latest.value, latest) : '—';
}

function formatAnalyticsMetricValue(metricKey, value, point) {
  if (value === null || value === undefined || !Number.isFinite(Number(value))) return '—';
  if (metricKey === 'score') return point?.valueKind === 'passRate' ? formatPercent(value) : `${Number(value).toFixed(1)}/5`;
  if (metricKey === 'duration') return formatDuration(value);
  return formatNumber(Math.round(Number(value)));
}

function formatAnalyticsBucket(start, end) {
  const startDate = new Date(start);
  const endDate = new Date(end);
  if (Number.isNaN(startDate.getTime())) return '—';
  const startText = startDate.toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric' });
  if (Number.isNaN(endDate.getTime()) || startDate.getTime() === endDate.getTime()) return startText;
  return `${startText} - ${endDate.toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric' })}`;
}

function analyticsPassTone(value) {
  if (value === null || value === undefined) return 'neutral';
  if (value >= 0.95) return 'ok';
  if (value >= 0.8) return 'warn';
  return 'bad';
}

function scheduleLabel(schedule) {
  if (!schedule) return 'Manual';
  if (schedule.schedule_type === 'manual_only') return 'Manual';
  if (schedule.schedule_type === 'cron') return humanizeCron(schedule.cron_expression) || 'Cron';
  const friendly = friendlyScheduleLabel(schedule.schedule_type);
  return friendly || schedule.schedule_type;
}

function runWord(count) {
  return count === 1 ? 'run' : 'runs';
}

function initialDispatchMessage(initialDispatch) {
  if (!initialDispatch) return '';
  const queued = Number(initialDispatch.queued || 0);
  const failed = Number(initialDispatch.failed || 0);
  if (queued > 0 && failed > 0) {
    return `${queued} initial ${runWord(queued)} started; ${failed} could not be started.`;
  }
  if (queued > 0) return queued === 1 ? 'First run started.' : `${queued} initial runs started.`;
  if (failed > 0) return 'Workflow created, but the first run could not be started.';
  return '';
}

function workflowBlockedToast(toast, error) {
  if (error && error.code === 'workflow_blocked_by_safety_guard') {
    toast.show({
      tone: 'bad',
      title: 'Workflow blocked',
      description: error.message || 'We could not save this workflow because the instructions appear unsafe. Please revise the instructions and try again.',
    });
    return true;
  }
  return false;
}

function cronLabelTitle(schedule) {
  if (!schedule || schedule.schedule_type !== 'cron') return undefined;
  return schedule.cron_expression || undefined;
}

// Friendly cadence options the editor exposes to end-users. The system
// chooses the exact run time for each (using a per-schedule jitter that
// staggers workflows across the interval window), so we never expose
// cron expressions on the create/edit surface.
const FRIENDLY_SCHEDULE_OPTIONS = [
  { value: 'manual_only', label: 'Manual', help: 'Only runs when triggered by a teammate or API call.' },
  { value: 'hourly', label: 'Hourly', help: 'Runs once per hour.' },
  { value: 'daily', label: 'Daily', help: 'Runs once per day.' },
  { value: 'weekly', label: 'Weekly', help: 'Runs once per week.' },
];

const FRIENDLY_SCHEDULE_INTERVAL_SECONDS = {
  hourly: 3600,
  daily: 86400,
  weekly: 7 * 86400,
};

function friendlyScheduleLabel(scheduleType) {
  const option = FRIENDLY_SCHEDULE_OPTIONS.find((opt) => opt.value === scheduleType);
  return option ? option.label : null;
}

// Translate a saved schedule row coming back from the API into the
// editor's `scheduleType` state. Legacy cron-typed schedules are kept
// as 'cron' so the editor can show them read-only without silently
// re-classifying user-authored expressions.
function deriveScheduleTypeFromRow(scheduleRow) {
  if (!scheduleRow || !scheduleRow.is_active) return 'manual_only';
  const type = scheduleRow.schedule_type || 'manual_only';
  if (type === 'hourly' || type === 'daily' || type === 'weekly' || type === 'manual_only') return type;
  return 'cron';
}

// Schedule cadence required for a given plan? Re-uses the cron-interval
// helper since hourly/daily/weekly intervals are always above any plan
// floor (the strictest floor today is 5 minutes on the starter plan).
function friendlyScheduleAllowedByPlan(auth, scheduleType) {
  if (scheduleType === 'manual_only') return true;
  const interval = FRIENDLY_SCHEDULE_INTERVAL_SECONDS[scheduleType];
  if (!interval) return true;
  return interval >= workflowMinIntervalSeconds(auth);
}

const PRESET_CRON_LABELS = {
  '* * * * *': 'Every minute',
  '*/5 * * * *': 'Every 5 minutes',
  '*/10 * * * *': 'Every 10 minutes',
  '*/15 * * * *': 'Every 15 minutes',
  '*/30 * * * *': 'Every 30 minutes',
  '0 * * * *': 'Hourly',
  '0 0 * * *': 'Daily at midnight',
  '0 12 * * *': 'Daily at noon',
  '0 0 * * 0': 'Weekly on Sunday',
  '0 0 1 * *': 'Monthly on the 1st',
};

function humanizeCron(expression) {
  if (!expression) return null;
  const trimmed = String(expression).trim();
  if (!trimmed) return null;
  if (PRESET_CRON_LABELS[trimmed]) return PRESET_CRON_LABELS[trimmed];
  const parts = trimmed.split(/\s+/);
  if (parts.length !== 5) return trimmed;
  const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
  const everyMinMatch = minute.match(/^\*\/(\d+)$/);
  if (everyMinMatch && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
    return `Every ${everyMinMatch[1]} minutes`;
  }
  const everyHourMatch = hour.match(/^\*\/(\d+)$/);
  if (/^\d+$/.test(minute) && everyHourMatch && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
    const m = Number(minute);
    return m === 0
      ? `Every ${everyHourMatch[1]} hours`
      : `Every ${everyHourMatch[1]} hours at :${String(m).padStart(2, '0')}`;
  }
  if (/^\d+$/.test(minute) && /^\d+$/.test(hour) && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
    const h = Number(hour);
    const m = Number(minute);
    const display = `${h % 12 === 0 ? 12 : h % 12}:${String(m).padStart(2, '0')} ${h < 12 ? 'AM' : 'PM'}`;
    return `Daily at ${display}`;
  }
  const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
  if (/^\d+$/.test(minute) && /^\d+$/.test(hour) && dayOfMonth === '*' && month === '*' && /^\d$/.test(dayOfWeek)) {
    const h = Number(hour);
    const m = Number(minute);
    const display = `${h % 12 === 0 ? 12 : h % 12}:${String(m).padStart(2, '0')} ${h < 12 ? 'AM' : 'PM'}`;
    return `Weekly on ${WEEKDAYS[Number(dayOfWeek)] || 'day ' + dayOfWeek} at ${display}`;
  }
  return trimmed;
}

// Live countdown helper: rerenders every minute so the "Next run in
// XXm" hint stays accurate while the editor is open. We deliberately
// cap the tick at one minute (rather than every second) to avoid
// re-rendering the entire editor pane continuously.
function useNextRunCountdown(nextRunAtIso) {
  const [now, setNow] = useStateW(() => Date.now());
  useEffectW(() => {
    if (!nextRunAtIso) return undefined;
    const tick = () => setNow(Date.now());
    const id = window.setInterval(tick, 60 * 1000);
    return () => window.clearInterval(id);
  }, [nextRunAtIso]);
  if (!nextRunAtIso) return null;
  const target = new Date(nextRunAtIso).getTime();
  if (Number.isNaN(target)) return null;
  return Math.max(0, target - now);
}

function formatCountdown(ms) {
  if (ms == null) return null;
  if (ms <= 0) return 'any moment';
  const totalSeconds = Math.floor(ms / 1000);
  const days = Math.floor(totalSeconds / 86400);
  const hours = Math.floor((totalSeconds % 86400) / 3600);
  const minutes = Math.floor((totalSeconds % 3600) / 60);
  if (days >= 1) {
    const h = hours;
    return h > 0 ? `${days}d ${h}h` : `${days}d`;
  }
  if (hours >= 1) {
    return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
  }
  if (minutes >= 1) return `${minutes}m`;
  const seconds = totalSeconds % 60;
  return seconds > 5 ? `${seconds}s` : 'less than a minute';
}

function WorkflowSchedulePicker({ scheduleType, onChange, auth, legacyCron, savedSchedule, targetCount, disabled = false }) {
  const nextRunAt = savedSchedule?.is_active && savedSchedule?.schedule_type !== 'manual_only'
    ? savedSchedule?.next_run_at
    : null;
  const countdownMs = useNextRunCountdown(nextRunAt);
  const countdownText = formatCountdown(countdownMs);
  const isLegacyCron = scheduleType === 'cron';
  const selectedOption = FRIENDLY_SCHEDULE_OPTIONS.find((opt) => opt.value === scheduleType);
  const helpText = isLegacyCron
    ? `Custom cron — ${humanizeCron(legacyCron) || legacyCron || 'no expression'}`
    : selectedOption?.help || '';
  return (
    <div className="workflow-schedule">
      <div className="workflow-schedule-presets" role="radiogroup" aria-label="Schedule cadence">
        {FRIENDLY_SCHEDULE_OPTIONS.map((option) => {
          const optionDisabled = disabled || !friendlyScheduleAllowedByPlan(auth, option.value);
          const active = scheduleType === option.value;
          return (
            <button
              key={option.value}
              type="button"
              role="radio"
              aria-checked={active}
              className={`ui-pill workflow-schedule-pill ${active ? 'workflow-schedule-pill-active' : ''}`}
              disabled={optionDisabled}
              title={!friendlyScheduleAllowedByPlan(auth, option.value) ? 'This cadence is faster than your current plan allows.' : (disabled ? undefined : option.help)}
              onClick={() => onChange(option.value)}>
              {option.label}
            </button>
          );
        })}
        {isLegacyCron && (
          <span
            className="ui-pill workflow-schedule-pill workflow-schedule-pill-active workflow-schedule-pill-legacy"
            title={`Legacy cron expression: ${legacyCron || 'none'}`}>
            Custom
          </span>
        )}
      </div>
      <div className="workflow-schedule-help">
        <span className="workflow-schedule-help-line">{helpText}</span>
        {nextRunAt && countdownText && (
          <span className="workflow-schedule-help-next">
            <Icon name="clock" size={11} />
            Next run in <strong>{countdownText}</strong>
            <span className="workflow-schedule-help-next-at"> · {formatRelative(nextRunAt)}</span>
          </span>
        )}
        {!nextRunAt && scheduleType !== 'manual_only' && !isLegacyCron && (
          <span className="workflow-schedule-help-next">
            <Icon name="clock" size={11} />
            We&apos;ll set the first run time when you save.
          </span>
        )}
        {targetCount > 1 && (
          <span className="workflow-schedule-help-targets">
            Runs are spaced so the models and harnesses on this workflow don&apos;t all fire at the exact same time.
          </span>
        )}
      </div>
    </div>
  );
}

function WorkflowEditorPage({ workflowId, navigate }) {
  const isNew = workflowId === 'new';
  const auth = useAuth();
  const toast = useToast();
  const [tab, setTab] = useStateW('definition');
  const [savingAction, setSavingAction] = useStateW('');
  const [confirmDelete, setConfirmDelete] = useStateW(false);
  const saving = Boolean(savingAction);
  const workflow = useApiResource(isNew ? null : `/api/workflows/${workflowId}`, [workflowId]);
  const servers = useApiResource('/api/mcp-servers');
  const catalog = useApiResource('/api/catalog/models');

  const [enabled, setEnabled] = useStateW(true);
  const [scheduleType, setScheduleType] = useStateW('hourly');
  const [legacyCron, setLegacyCron] = useStateW('');
  const [name, setName] = useStateW('');
  const [description, setDescription] = useStateW('');
  const [mcp, setMcp] = useStateW('');
  const [authProfile, setAuthProfile] = useStateW('');
  const [models, setModels] = useStateW([]);
  const [targetRows, setTargetRows] = useStateW([]);
  const [prompt, setPrompt] = useStateW('');
  const [criteriaText, setCriteriaText] = useStateW('');
  const [hydratedWorkflowId, setHydratedWorkflowId] = useStateW(isNew ? workflowId : '');
  const canManageWorkflows = canUseTenantAction(auth);
  const workflowActionDisabledReason = tenantActionDisabledReason(auth);

  useEffectW(() => {
    if (isNew) {
      setHydratedWorkflowId(workflowId);
      return;
    }
    if (!workflow.data) {
      return;
    }
    const wf = workflow.data.workflow;
    const version = workflow.data.currentVersion || {};
    const criteria = workflow.data.criteria || [];
    setEnabled(Boolean(wf.is_active));
    setName(wf.name || '');
    setDescription(wf.description || '');
    setMcp(version.mcp_server_id || wf.mcp_server_id || '');
    setAuthProfile(version.mcp_auth_profile_id || wf.default_mcp_auth_profile_id || 'none');
    const activeSchedules = (workflow.data.schedules || []).filter((s) => s.is_active && s.tester_model_id);
    if (activeSchedules.length > 0) {
      setModels(activeSchedules.map((s) => s.tester_model_id).filter((id, idx, ids) => ids.indexOf(id) === idx));
      setTargetRows(activeSchedules.map((s) => ({
        harness: '',
        modelId: s.tester_model_id,
        skillMode: s.skill_version_id ? 'uploaded' : 'none',
        skillVersionId: s.skill_version_id || null,
        skillName: s.skill_name || '',
        skillVersionNumber: s.skill_version_number || null,
      })));
    } else if (version.tester_model_id) {
      setModels([version.tester_model_id]);
      setTargetRows([]);
    }
    setPrompt(version.tester_prompt || '');
    setCriteriaText(criteria.map((c) => c.criterion_text).join('\n'));
    const scheduleRow = workflow.data.schedule;
    setScheduleType(deriveScheduleTypeFromRow(scheduleRow));
    setLegacyCron(scheduleRow?.cron_expression || '');
    setHydratedWorkflowId(workflowId);
  }, [workflow.data, isNew, workflowId]);

  const selectedServer = (servers.data?.rows || []).find((server) => server.id === mcp);
  const authProfiles = selectedServer?.auth_profiles || (selectedServer?.default_auth_profile ? [selectedServer.default_auth_profile] : []);
  const selectedAuthProfileId = authProfile === 'none' ? null : authProfile || selectedServer?.default_auth_profile?.id || null;
  const modelOptions = catalog.data?.models || [];
  const orgDefaultTargets = auth?.me?.organization?.workflowDefaultTargets || [];
  const workflowDetailError = !isNew && !workflow.loading && workflow.error;
  const loadingWorkflowDetails = !isNew && !workflowDetailError && (!workflow.data || hydratedWorkflowId !== workflowId);
  const loadingEditorResources = servers.loading || catalog.loading;
  const editorLoading = loadingWorkflowDetails || loadingEditorResources;
  const editorLoadingLabel = loadingWorkflowDetails ? 'Loading workflow' : 'Loading editor resources';
  const editorDisabled = saving || editorLoading || Boolean(workflowDetailError);

  useEffectW(() => {
    if (loadingWorkflowDetails) return;
    if (modelOptions.length === 0) return;
    const keptTargets = normalizeWorkflowTargetRows(modelOptions, targetRows.length > 0 ? targetRows : models);
    const nextTargets = keptTargets.length > 0 ? keptTargets : getDefaultWorkflowTargetRows(modelOptions, orgDefaultTargets);
    const nextModelIds = getWorkflowTargetModelIds(nextTargets);
    if (workflowTargetRowsKey(nextTargets) !== workflowTargetRowsKey(targetRows)) {
      setTargetRows(nextTargets);
    }
    if (nextModelIds.join(',') !== models.join(',')) {
      setModels(nextModelIds);
    }
  // `workflowTargetRowsKey(orgDefaultTargets)` rather than `.length` so a
  // same-size content swap (e.g. owner repicks a different harness/model
  // pair) still re-seeds. Today `auth.me` only refreshes after navigating
  // away from the role step, but keying on content keeps this safe if
  // background refresh ever lands.
  }, [loadingWorkflowDetails, modelOptions.length, models.join(','), workflowTargetRowsKey(targetRows), workflowTargetRowsKey(orgDefaultTargets)]); // eslint-disable-line

  const formInvalid = !name.trim() || (mcp && models.length === 0) || !prompt.trim() || hasPendingWorkflowSkillVariant(targetRows);

  async function save() {
    if (!requirePaidAction(auth, navigate, { requiredRole: 'editor' })) return;
    const scheduleWillRun = scheduleType !== 'manual_only' && enabled;
    if (scheduleWillRun && !friendlyScheduleAllowedByPlan(auth, scheduleType)) {
      toast.show({
        tone: 'bad',
        title: 'Schedule too frequent',
        description: `This schedule is faster than your current plan allows.`,
      });
      return;
    }
    if (scheduleWillRun && scheduleType === 'cron' && !cronAllowedByPlan(auth, legacyCron.trim())) {
      toast.show({
        tone: 'bad',
        title: 'Schedule too frequent',
        description: `This legacy cron schedule is faster than your current plan allows.`,
      });
      return;
    }
    if (isNew && scheduleWillRun) {
      const limitState = getPlanLimitState(auth, 'scheduledWorkflows', 1);
      if (!limitState.allowed) {
        toast.show({ tone: 'bad', title: 'Plan limit reached', description: limitState.message });
        return;
      }
    }
    setSavingAction('save');
    try {
      let schedulePayload;
      if (scheduleType === 'manual_only') {
        schedulePayload = { scheduleType: 'manual_only', timezone: 'UTC', isActive: enabled };
      } else if (scheduleType === 'cron') {
        // Legacy cron rows stay editable through the API for power users
        // who already have one persisted; we don't surface a cron editor
        // but we round-trip the saved expression so a "Save" doesn't
        // silently discard it.
        const trimmed = legacyCron.trim();
        schedulePayload = trimmed
          ? { scheduleType: 'cron', cronExpression: trimmed, timezone: 'UTC', isActive: enabled }
          : { scheduleType: 'manual_only', timezone: 'UTC', isActive: enabled };
      } else {
        schedulePayload = { scheduleType, timezone: 'UTC', isActive: enabled };
      }
      const body = {
        name,
        description,
        mcpServerId: mcp,
        defaultMcpAuthProfileId: selectedAuthProfileId,
        testerPrompt: prompt,
        testerTargets: buildWorkflowTesterTargets(targetRows.length > 0 ? targetRows : models, modelOptions),
        testerModelIds: models,
        criteria: criteriaText.split('\n').map((line) => line.trim()).filter(Boolean),
        schedule: schedulePayload,
        isActive: enabled,
      };
      const result = await apiFetch(isNew ? '/api/workflows' : `/api/workflows/${workflowId}`, {
        method: isNew ? 'POST' : 'PATCH',
        body: JSON.stringify(body),
      });
      const initialDispatchQueued = Number(result.initialDispatch?.queued || 0);
      const initialDispatchFailed = Number(result.initialDispatch?.failed || 0);
      toast.show({
        tone: isNew && initialDispatchFailed > 0 && initialDispatchQueued === 0 ? 'warn' : 'ok',
        title: isNew ? 'Workflow created' : 'Workflow saved',
        description: isNew ? initialDispatchMessage(result.initialDispatch) : '',
      });
      if (isNew) navigate(`/workflows/${result.workflow.id}`);
      else workflow.reload();
    } catch (error) {
      if (workflowBlockedToast(toast, error)) return;
      toast.show({ tone: 'bad', title: 'Save failed', description: error.message });
    } finally {
      setSavingAction('');
    }
  }

  async function archive() {
    if (isNew) {
      navigate('/workflows');
      return;
    }
    if (!requirePaidAction(auth, navigate, { requiredRole: 'editor' })) return;
    setSavingAction('archive');
    try {
      await apiFetch(`/api/workflows/${workflowId}`, { method: 'DELETE' });
      toast.show({ tone: 'ok', title: 'Workflow deleted' });
      navigate('/workflows');
    } catch (error) {
      toast.show({ tone: 'bad', title: 'Delete failed', description: error.message });
    } finally {
      setSavingAction('');
      setConfirmDelete(false);
    }
  }

  const tabs = [
    { key: 'definition', label: 'Definition', icon: 'edit' },
    { key: 'judge', label: 'Success criteria', icon: 'gavel' },
    ...(isNew ? [] : [{ key: 'history', label: 'History', icon: 'activity' }]),
  ];

  return (
    <div className="page-inner workflow-editor-page">
      <div className="run-detail-back">
        <button type="button" className="btn btn-ghost btn-sm" onClick={() => navigate('/workflows')}>
          <Icon name="chevronLeft" size={13} />Workflows
        </button>
      </div>

      <header className="workflow-editor-hero">
        <div className="workflow-editor-title-block">
          <h1 className="ui-page-title workflow-editor-title">{name || (isNew ? 'New workflow' : 'Workflow')}</h1>
          {!isNew && <span className="run-detail-id mono" title={workflowId}>{shortId(workflowId)}</span>}
        </div>
        <div className="workflow-editor-actions">
          <button
            type="button"
            className={`workflow-toggle ${enabled ? 'on' : ''}`}
            disabled={!canManageWorkflows || editorDisabled}
            onClick={() => setEnabled(!enabled)}
            aria-pressed={enabled}
            title={canManageWorkflows ? (editorLoading ? editorLoadingLabel : undefined) : workflowActionDisabledReason}>
            <span className={`toggle ${enabled ? 'on' : ''}`}></span>
            <span>{enabled ? 'Enabled' : 'Paused'}</span>
          </button>
          {!isNew && (
            <Button
              size="sm"
              variant="ghost"
              disabled={!canManageWorkflows || editorDisabled}
              loading={savingAction === 'archive'}
              loadingLabel="Deleting"
              onClick={() => setConfirmDelete(true)}>
              <Icon name="trash" size={12} />
            </Button>
          )}
          <Button
            variant="primary"
            size="sm"
            disabled={!canManageWorkflows || editorDisabled || formInvalid}
            loading={savingAction === 'save'}
            loadingLabel={isNew ? 'Creating' : 'Saving'}
            onClick={save}>
            <Icon name="check" size={12} />{isNew ? 'Create' : 'Save'}
          </Button>
        </div>
      </header>

      <div className="run-detail-tabs">
        {tabs.map((t) => (
          <button
            key={t.key}
            type="button"
            className={`run-detail-tab ${tab === t.key ? 'active' : ''}`}
            disabled={editorLoading || Boolean(workflowDetailError)}
            onClick={() => setTab(t.key)}>
            <Icon name={t.icon} size={13} />
            <span>{t.label}</span>
          </button>
        ))}
      </div>

      {workflowDetailError ? (
        <WorkflowEditorErrorState error={workflow.error} onRetry={workflow.reload} />
      ) : editorLoading ? (
        <WorkflowEditorLoadingState label={editorLoadingLabel} />
      ) : tab === 'definition' && (
        <fieldset className="workflow-editor-fieldset" disabled={editorDisabled} aria-busy={editorDisabled ? 'true' : undefined}>
          <div className="workflow-editor-pane">
          <FormSection
            title="Identity"
            help="What teammates see in lists and notifications.">
            <Field label="Name" help="Shown in runs table and notifications.">
              <input className="input" placeholder="e.g. Triage incoming bug" value={name} disabled={editorDisabled} onChange={(e) => setName(e.target.value)} />
            </Field>
            <Field label="Description" help="Used to help teammates scan workflow purpose.">
              <input className="input" placeholder="Short summary" value={description} disabled={editorDisabled} onChange={(e) => setDescription(e.target.value)} />
            </Field>
          </FormSection>

          <FormSection
            title="Connection"
            help="Which MCP server the agent will connect to and how.">
            <Field label="MCP server" help="The MCP server the agent will use during this workflow.">
              <Select
                value={mcp}
                placeholder="Select a server"
                searchable
                disabled={editorDisabled}
                options={(servers.data?.rows || []).map((s) => ({ value: s.id, label: s.name }))}
                onChange={(v) => { setMcp(v); setAuthProfile(''); }} />
            </Field>
            <Field label="Auth profile" help="Credentials reference used for this workflow.">
              <Select
                value={authProfile || ''}
                disabled={editorDisabled || !mcp}
                placeholder={mcp ? 'Use server default' : 'Pick a server first'}
                options={[
                  { value: '', label: `Use server default${selectedServer?.default_auth_profile ? ` (${selectedServer.default_auth_profile.name})` : ''}` },
                  { value: 'none', label: 'No auth profile' },
                  ...authProfiles.map((p) => ({ value: p.id, label: `${p.name}${p.is_default ? ' (default)' : ''}` })),
                ]}
                onChange={setAuthProfile} />
            </Field>
          </FormSection>

          <FormSection
            title="Harnesses and models"
            help="Each selected target creates its own queued run for the same workflow definition.">
            <Field label="Targets" help="Select one or more harness and model pairs.">
              <WorkflowTargetPicker
                modelIds={models}
                targetRows={targetRows}
                models={modelOptions}
                disabled={editorDisabled}
                onChangeTargets={setTargetRows}
                onChange={setModels} />
            </Field>
          </FormSection>

          <FormSection
            title="Prompt"
            help="The instruction the agent will try to solve. Be explicit about expected tool calls.">
            <textarea
              className="textarea workflow-prompt-textarea"
              placeholder="Describe the task the agent should accomplish…"
              value={prompt}
              disabled={editorDisabled}
              onChange={(e) => setPrompt(e.target.value)}
              rows={8} />
          </FormSection>

          <FormSection
            title="Schedule"
            help="How often this workflow runs.">
            <WorkflowSchedulePicker
              scheduleType={scheduleType}
              onChange={setScheduleType}
              auth={auth}
              legacyCron={legacyCron}
              savedSchedule={workflow.data?.schedule || null}
              targetCount={models.length}
              disabled={editorDisabled} />
          </FormSection>
          </div>
        </fieldset>
      )}

      {!editorLoading && tab === 'judge' && (
        <fieldset className="workflow-editor-fieldset" disabled={editorDisabled} aria-busy={editorDisabled ? 'true' : undefined}>
          <div className="workflow-editor-pane">
          <FormSection
            title="Rubric"
            help="One plain-language criterion per line. The judge model evaluates each.">
            <textarea
              className="textarea workflow-prompt-textarea"
              rows={10}
              placeholder="A run is successful when…"
              value={criteriaText}
              disabled={editorDisabled}
              onChange={(e) => setCriteriaText(e.target.value)} />
            <div className="text-xs muted" style={{ marginTop: 6 }}>
              {criteriaText.split('\n').map((l) => l.trim()).filter(Boolean).length} criteria
            </div>
          </FormSection>
          </div>
        </fieldset>
      )}

      {!editorLoading && tab === 'history' && !isNew && (
        <fieldset className="workflow-editor-fieldset" disabled={editorDisabled} aria-busy={editorDisabled ? 'true' : undefined}>
          <div className="workflow-editor-pane">
          <div className="runs-table-wrap">
            <table className="runs-table">
              <thead>
                <tr>
                  <th className="col-status">Status</th>
                  <th className="col-id">Run</th>
                  <th>Model</th>
                  <th className="num">Duration</th>
                  <th>Started</th>
                </tr>
              </thead>
              <tbody>
                {(workflow.data?.recentHistory || []).length === 0 ? (
                  <tr className="runs-empty-row">
                    <td colSpan={5}>
                      <EmptyState
                        icon="list"
                        title="No runs yet"
                        body="When this workflow executes, runs will show up here." />
                    </td>
                  </tr>
                ) : workflow.data.recentHistory.map((run) => (
                  <tr key={run.id} className="runs-row" onClick={() => navigate(`/runs/${run.id}`)}>
                    <td className="col-status"><StatusBadge status={run.status === 'completed' ? 'success' : run.status?.includes('failed') ? 'failed' : 'running'} /></td>
                    <td className="col-id mono">{shortId(run.id)}</td>
                    <td><ModelBadge name={run.tester_model_name} /></td>
                    <td className="num">{formatDuration(run.duration_ms)}</td>
                    <td className="muted">{formatRelative(run.started_at || run.created_at)}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
          </div>
        </fieldset>
      )}

      {confirmDelete && window.ConfirmDialog && (
        <window.ConfirmDialog
          open
          tone="danger"
          title="Delete this workflow?"
          description="The workflow stops running and disappears from lists. Past runs and traces are preserved."
          confirmLabel="Delete workflow"
          confirmBusyLabel="Deleting"
          busy={savingAction === 'archive'}
          onCancel={() => setConfirmDelete(false)}
          onConfirm={archive} />
      )}
    </div>
  );
}

function WorkflowEditorLoadingState({ label }) {
  return (
    <div className="workflow-editor-loading-state" role="status" aria-live="polite">
      <LoadingSpinner size="sm" decorative />
      <div className="workflow-editor-loading-copy">
        <div className="workflow-editor-loading-title">{label}</div>
        <div className="workflow-editor-loading-subtitle">Preparing the editor...</div>
      </div>
    </div>
  );
}

function WorkflowEditorErrorState({ error, onRetry }) {
  return (
    <div className="workflow-editor-loading-state workflow-editor-error-state" role="alert">
      <Icon name="alert" size={18} />
      <div className="workflow-editor-loading-copy">
        <div className="workflow-editor-loading-title">Could not load workflow</div>
        <div className="workflow-editor-loading-subtitle">{error?.message || 'Refresh the workflow details and try again.'}</div>
      </div>
      <Button size="sm" onClick={onRetry}>
        <Icon name="refresh" size={12} />Retry
      </Button>
    </div>
  );
}

function FormSection({ title, help, children }) {
  return (
    <section className="workflow-form-section">
      <div className="workflow-form-section-head">
        <div className="workflow-form-section-title">{title}</div>
        {help && <div className="workflow-form-section-help">{help}</div>}
      </div>
      <div className="workflow-form-section-body">
        {children}
      </div>
    </section>
  );
}

function Field({ label, help, children }) {
  return (
    <div className="field-row">
      <div>
        <div className="field-label">{label}</div>
        {help && <div className="field-help">{help}</div>}
      </div>
      <div className="field-value">{children}</div>
    </div>
  );
}

const ARMATURE_ORB_SVG = (
  <svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
    <path d="M8 1.5L9.4 6.6L14.5 8L9.4 9.4L8 14.5L6.6 9.4L1.5 8L6.6 6.6L8 1.5Z" fill="currentColor" />
  </svg>
);

function WorkflowOnboardingOrb({ thinking }) {
  return (
    <div className={`workflow-onb-orb${thinking ? ' is-thinking' : ''}`} aria-hidden="true">
      {ARMATURE_ORB_SVG}
    </div>
  );
}

const USER_ACTIONABLE_WORKFLOW_AGENT_ERROR_CODES = new Set([
  'mcp_probe_failed',
  'cli_catalog_not_ready',
  'mcp_no_tools',
  'workflow_agent_catalog_too_large',
  'missing_mcp_server_id',
  'mcp_server_not_found',
  'invalid_usecase',
  'insufficient_role',
  'rate_limited',
]);

function isNetworkWorkflowAgentError(error) {
  if (!(error instanceof TypeError)) return false;
  if (/** @type {any} */ (error).status) return false;
  return /failed to fetch|fetch failed|networkerror|network request failed|load failed|internet connection/i.test(error.message || '');
}

function isRetryableWorkflowAgentError(error) {
  if (USER_ACTIONABLE_WORKFLOW_AGENT_ERROR_CODES.has(error?.code)) return false;
  const status = Number(error?.status || 0);
  return (status >= 500 && status < 600) || (!status && isNetworkWorkflowAgentError(error));
}

async function retryWorkflowAgentRequest(operation) {
  try {
    return await operation();
  } catch (error) {
    if (!isRetryableWorkflowAgentError(error)) throw error;
    return await operation();
  }
}

function workflowAgentUserErrorMessage(error, kind) {
  if (isRetryableWorkflowAgentError(error)) {
    return kind === 'suggest'
      ? 'An error on our side stopped Armature from drafting suggestions. We retried once. You can try again or continue manually.'
      : 'An error on our side stopped Armature from drafting this workflow. We retried once. You can try again or fill it manually.';
  }
  return error?.message || 'Something went wrong. Try again, or switch to manual.';
}

function workflowAgentInlineErrorMessage(errorText, ourSide, kind) {
  if (ourSide) {
    return kind === 'suggest'
      ? 'An error on our side stopped the suggestions.'
      : 'An error on our side stopped the workflow draft.';
  }
  return errorText || 'Something went wrong.';
}

// Centralizes the agent-suggest / agent-draft / create lifecycle that both
// the in-app "Create new workflow" modal (`WorkflowOnboardingModal`) and the
// onboarding tour's workflow step (`OnboardingTourWorkflowStep`) run through.
// The two contexts call the same `/api/workflows/agent-suggest-usecases`,
// `/api/workflows/agent-draft`, and `POST /api/workflows` endpoints with the
// exact same payload shape (criteria mapping, schedule rules, tester targets
// derived from the catalog), so any drift between them was a bug. The hook
// holds shared state, runs the catalog -> testerModelIds normalization once,
// and exposes the suggest / draft / create actions; consumers stay
// responsible for stage routing + chrome (modal vs tour). Exposed via
// `window.useWorkflowDraftFlow` so `pages-onboarding-tour.jsx` can consume
// it without a circular import.
function useWorkflowDraftFlow({
  mcpServerId,
  mcpServerIds,
  modelRows,
  orgDefaultTargets,
  selectedServer,
  toast,
  onCreated,
  agentSuggestEndpoint = '/api/workflows/agent-suggest-usecases',
  agentDraftEndpoint = '/api/workflows/agent-draft',
}) {
  const [usecase, setUsecase] = useStateW('');
  const [directUsecaseDraft, setDirectUsecaseDraft] = useStateW('');
  const [agentMission, setAgentMission] = useStateW('');
  const [agentPersona, setAgentPersona] = useStateW('');
  const [suggestState, setSuggestState] = useStateW({ status: 'idle', error: '', ourSide: false, usecases: [] });
  const [draftState, setDraftState] = useStateW({ status: 'idle', error: '', ourSide: false, draft: null });
  const [draft, setDraft] = useStateW(/** @type {any} */ (null));
  const [manualDraft, setManualDraft] = useStateW(/** @type {any} */ (null));
  const [creating, setCreating] = useStateW(false);
  const [testerModelIds, setTesterModelIds] = useStateW([]);
  const [testerTargets, setTesterTargets] = useStateW([]);

  // Keep tester models in sync with the catalog. Used to live inline in
  // both consumers with the same body — centralizing prevents drift.
  // `orgDefaultTargets` is the workspace-level preference set during the
  // onboarding role step; we forward it to `getDefaultWorkflowTargetRows`
  // so the modal/tour seed matches what users picked at onboarding.
  const orgDefaultTargetsList = orgDefaultTargets || [];
  useEffectW(() => {
    if (modelRows.length === 0) return;
    const keptTargets = normalizeWorkflowTargetRows(modelRows, testerTargets.length > 0 ? testerTargets : testerModelIds);
    const nextTargets = keptTargets.length > 0
      ? keptTargets
      : getDefaultWorkflowTargetRows(modelRows, orgDefaultTargetsList);
    const nextModelIds = getWorkflowTargetModelIds(nextTargets);
    if (workflowTargetRowsKey(nextTargets) !== workflowTargetRowsKey(testerTargets)) {
      setTesterTargets(nextTargets);
    }
    if (nextModelIds.join(',') !== testerModelIds.join(',')) {
      setTesterModelIds(nextModelIds);
    }
  // See WorkflowEditorPage above — using a content-keyed dep so a
  // same-length swap of the org defaults still re-seeds.
  }, [modelRows.length, testerModelIds.join(','), workflowTargetRowsKey(testerTargets), workflowTargetRowsKey(orgDefaultTargetsList)]); // eslint-disable-line

  function ensureManualDraft() {
    setManualDraft((current) => current || ({
      name: '',
      description: usecase || '',
      testerPrompt: '',
      criteriaText: '',
      authProfileId: '',
      scheduleType: 'hourly',
    }));
  }

  function resetAgentDraft() {
    setDraftState({ status: 'idle', error: '', ourSide: false, draft: null });
    setDraft(null);
  }

  function clearAgentSuggestionContext() {
    setAgentMission('');
    setAgentPersona('');
    setSuggestState({ status: 'idle', error: '', ourSide: false, usecases: [] });
  }

  function prepareDirectUsecaseDraft() {
    setDirectUsecaseDraft(usecase);
    clearAgentSuggestionContext();
    resetAgentDraft();
  }

  function prepareSuggestedUsecases() {
    setDirectUsecaseDraft(usecase);
    setUsecase('');
    clearAgentSuggestionContext();
    resetAgentDraft();
  }

  function restoreDirectUsecaseDraft() {
    setUsecase(directUsecaseDraft);
  }

  function agentSourceBody(extra = {}) {
    const ids = Array.isArray(mcpServerIds)
      ? mcpServerIds.filter((id) => typeof id === 'string' && id.trim())
      : [];
    return ids.length > 0
      ? { mcpServerIds: ids, ...extra }
      : { mcpServerId, ...extra };
  }

  async function suggestUsecases() {
    if (suggestState.status === 'loading' || suggestState.status === 'ready') return;
    setSuggestState({ status: 'loading', error: '', ourSide: false, usecases: [] });
    try {
      const data = await retryWorkflowAgentRequest(() => apiFetch(agentSuggestEndpoint, {
        method: 'POST',
        body: JSON.stringify(agentSourceBody()),
      }));
      setAgentMission(data.mission || '');
      setAgentPersona(data.persona || '');
      setSuggestState({ status: 'ready', error: '', ourSide: false, usecases: data.usecases || [] });
    } catch (err) {
      setSuggestState({
        status: 'error',
        error: workflowAgentUserErrorMessage(err, 'suggest'),
        ourSide: isRetryableWorkflowAgentError(err),
        usecases: [],
      });
    }
  }

  async function resuggestUsecases() {
    setUsecase('');
    setSuggestState({ status: 'loading', error: '', ourSide: false, usecases: [] });
    try {
      const data = await retryWorkflowAgentRequest(() => apiFetch(agentSuggestEndpoint, {
        method: 'POST',
        body: JSON.stringify(agentSourceBody()),
      }));
      setAgentMission(data.mission || '');
      setAgentPersona(data.persona || '');
      setSuggestState({ status: 'ready', error: '', ourSide: false, usecases: data.usecases || [] });
    } catch (err) {
      setSuggestState({
        status: 'error',
        error: workflowAgentUserErrorMessage(err, 'suggest'),
        ourSide: isRetryableWorkflowAgentError(err),
        usecases: [],
      });
    }
  }

  async function loadAgentDraft() {
    if (draftState.status === 'loading') return;
    setDraftState({ status: 'loading', error: '', ourSide: false, draft: null });
    try {
      const data = await retryWorkflowAgentRequest(() => apiFetch(agentDraftEndpoint, {
        method: 'POST',
        body: JSON.stringify(agentSourceBody({ usecase, mission: agentMission, persona: agentPersona })),
      }));
      // Preserve a user-picked cadence across retries/re-drafts. Default to
      // 'hourly' on first draft so the suggested-workflow flow keeps its
      // historical "auto-on" behavior unless the user changes it. `edited`
      // is intentionally reset: the AI just regenerated `name` /
      // `testerPrompt`, so prior edit flags would render stale "Edited"
      // source badges on fields the user did not actually touch (Greptile
      // #382 4/5 review).
      setDraft((prev) => ({
        name: data.name || '',
        description: data.description || '',
        testerPrompt: data.testerPrompt || '',
        criteria: data.criteria || [],
        scheduleType: prev?.scheduleType || 'hourly',
        edited: {},
      }));
      setDraftState({ status: 'ready', error: '', ourSide: false, draft: data });
    } catch (err) {
      setDraftState({
        status: 'error',
        error: workflowAgentUserErrorMessage(err, 'draft'),
        ourSide: isRetryableWorkflowAgentError(err),
        draft: null,
      });
    }
  }

  async function createFromAgent() {
    setCreating(true);
    try {
      // Suggested-workflow flow used to hard-code 'hourly'. We now thread the
      // cadence the user picked in the review screen, defaulting to 'hourly'
      // so existing "auto-on" behavior holds when nothing was changed.
      const draftScheduleType = draft?.scheduleType || 'hourly';
      const result = await apiFetch('/api/workflows', {
        method: 'POST',
        body: JSON.stringify({
          name: draft.name,
          description: draft.description,
          mcpServerId,
          defaultMcpAuthProfileId: selectedServer?.default_auth_profile?.id || null,
          testerPrompt: draft.testerPrompt,
          testerTargets: buildWorkflowTesterTargets(testerTargets.length > 0 ? testerTargets : testerModelIds, modelRows),
          testerModelIds,
          criteria: (draft.criteria || []).filter((c) => (c.criterionText || '').trim().length > 0).map((c, idx) => ({
            position: idx + 1,
            criterionText: c.criterionText.trim(),
            isRequired: c.isRequired !== false,
          })),
          schedule: { scheduleType: draftScheduleType, isActive: true, timezone: 'UTC' },
          isActive: true,
        }),
      });
      const workflowId = result?.workflow?.id || null;
      if (!workflowId) {
        toast?.show?.({
          tone: 'bad',
          title: 'Could not create workflow',
          description: 'The server accepted the request but did not return a workflow id. Try again.',
        });
        return;
      }
      // Await so an async onCreated (the onboarding tour persists state
      // before swapping to the completion screen) finishes before
      // `setCreating(false)` fires in `finally` and the footer button
      // re-enables — without this a second click could dispatch a
      // duplicate POST /api/workflows (Greptile #283 P1).
      await onCreated?.(workflowId, draft.name, result?.initialDispatch || null);
    } catch (err) {
      if (workflowBlockedToast(toast, err)) return;
      toast?.show?.({ tone: 'bad', title: 'Could not create workflow', description: err.message });
    } finally {
      setCreating(false);
    }
  }

  async function createFromManual() {
    setCreating(true);
    try {
      const trimmedCriteria = (manualDraft.criteriaText || '')
        .split('\n')
        .map((line) => line.trim())
        .filter(Boolean)
        .map((text, idx) => ({ position: idx + 1, criterionText: text, isRequired: true }));
      const draftScheduleType = manualDraft.scheduleType || 'manual_only';
      const result = await apiFetch('/api/workflows', {
        method: 'POST',
        body: JSON.stringify({
          name: manualDraft.name.trim(),
          description: (manualDraft.description || '').trim(),
          mcpServerId,
          defaultMcpAuthProfileId: manualDraft.authProfileId === 'none'
            ? null
            : (manualDraft.authProfileId || selectedServer?.default_auth_profile?.id || null),
          testerPrompt: manualDraft.testerPrompt.trim(),
          testerTargets: buildWorkflowTesterTargets(testerTargets.length > 0 ? testerTargets : testerModelIds, modelRows),
          testerModelIds,
          criteria: trimmedCriteria,
          schedule: { scheduleType: draftScheduleType, timezone: 'UTC', isActive: true },
          isActive: true,
        }),
      });
      const workflowId = result?.workflow?.id || null;
      if (!workflowId) {
        toast?.show?.({
          tone: 'bad',
          title: 'Could not create workflow',
          description: 'The server accepted the request but did not return a workflow id. Try again.',
        });
        return;
      }
      // See createFromAgent — async onCreated must finish before the
      // footer button re-enables to prevent a duplicate POST.
      await onCreated?.(workflowId, manualDraft.name.trim(), result?.initialDispatch || null);
    } catch (err) {
      if (workflowBlockedToast(toast, err)) return;
      toast?.show?.({ tone: 'bad', title: 'Could not create workflow', description: err.message });
    } finally {
      setCreating(false);
    }
  }

  return {
    usecase, setUsecase,
    agentMission,
    agentPersona,
    suggestState,
    draftState, setDraftState,
    draft, setDraft,
    manualDraft, setManualDraft,
    creating,
    testerModelIds, setTesterModelIds,
    testerTargets, setTesterTargets,
    ensureManualDraft,
    resetAgentDraft,
    prepareDirectUsecaseDraft,
    prepareSuggestedUsecases,
    restoreDirectUsecaseDraft,
    suggestUsecases,
    resuggestUsecases,
    loadAgentDraft,
    createFromAgent,
    createFromManual,
  };
}

function WorkflowOnboardingModal({ onClose, onCreated, navigate, initialServerId = '' }) {
  const auth = useAuth();
  const servers = useApiResource('/api/mcp-servers');
  const catalog = useApiResource('/api/catalog/models');
  const modelRows = catalog.data?.models || [];
  const [stage, setStage] = useStateW('pick'); // 'pick' | 'agentStart' | 'usecase' | 'review' | 'manual' | 'success'
  const [animDir, setAnimDir] = useStateW('forward'); // 'forward' | 'back'
  const [mcpServerId, setMcpServerId] = useStateW('');
  const [path, setPath] = useStateW('auto'); // 'auto' | 'manual'
  const [agentIdeaMode, setAgentIdeaMode] = useStateW('direct'); // 'direct' | 'suggested'
  const [createdId, setCreatedId] = useStateW(null);
  const [createdInitialDispatch, setCreatedInitialDispatch] = useStateW(null);
  const seededInitialServerRef = useRefW('');
  const toast = useToast();

  const serverRows = servers.data?.rows || [];
  const selectedServer = serverRows.find((s) => s.id === mcpServerId) || null;

  useEffectW(() => {
    if (initialServerId && seededInitialServerRef.current !== initialServerId && serverRows.some((server) => server.id === initialServerId)) {
      seededInitialServerRef.current = initialServerId;
      setMcpServerId(initialServerId);
      return;
    }
    if (!mcpServerId && serverRows.length === 1) {
      setMcpServerId(serverRows[0].id);
    }
  }, [initialServerId, serverRows.length, mcpServerId]);

  async function persistCreatedWorkflowOnboardingState(workflowId) {
    try {
      await apiFetch('/api/onboarding/state', {
        method: 'POST',
        body: JSON.stringify({ onboardingState: { lastWorkflowId: workflowId } }),
      });
      await auth?.refreshMe?.();
    } catch (error) {
      console.error('Failed to save workflow onboarding state', error);
    }
  }

  const flow = useWorkflowDraftFlow({
    mcpServerId,
    mcpServerIds: null,
    modelRows,
    orgDefaultTargets: auth?.me?.organization?.workflowDefaultTargets,
    selectedServer,
    toast,
    onCreated: async (workflowId, _name, dispatch) => {
      await persistCreatedWorkflowOnboardingState(workflowId);
      setCreatedId(workflowId);
      setCreatedInitialDispatch(dispatch);
      setAnimDir('forward');
      setStage('success');
    },
  });

  const {
    usecase, setUsecase,
    suggestState,
    draftState, setDraftState,
    draft, setDraft,
    manualDraft, setManualDraft,
    creating,
    testerModelIds, setTesterModelIds,
    testerTargets, setTesterTargets,
    ensureManualDraft,
    resetAgentDraft,
    prepareDirectUsecaseDraft,
    prepareSuggestedUsecases,
    restoreDirectUsecaseDraft,
    suggestUsecases, resuggestUsecases,
    loadAgentDraft,
    createFromAgent, createFromManual,
  } = flow;

  useEscapeToClose({
    onClose,
  });

  // Initialize the manual draft the first time the user lands on the manual
  // stage. The fields mirror the agent-drafted version so switching paths is
  // not destructive (the agent draft can still be re-loaded by going Back).
  useEffectW(() => {
    if (stage !== 'manual') return;
    ensureManualDraft();
  }, [stage]); // eslint-disable-line

  // Kick off the agent draft as soon as the user enters the review stage,
  // unless we already have a draft loading or ready (so animating between
  // stages doesn't fire duplicate POSTs).
  useEffectW(() => {
    if (stage !== 'review') return;
    if (draftState.status === 'loading' || draftState.status === 'ready') return;
    loadAgentDraft();
  }, [stage]); // eslint-disable-line

  function backdropClose(e) {
    if (e.target !== e.currentTarget) return;
    onClose();
  }

  function goTo(nextStage, dir = 'forward') {
    setAnimDir(dir);
    setStage(nextStage);
  }

  const isWide = stage === 'manual';

  const modal = (
    <div className="dialog-backdrop" onMouseDown={backdropClose}>
      <div
        className={`modal-panel workflow-onb-modal ${isWide ? 'is-wide' : ''}`}
        role="dialog"
        aria-modal="true"
        aria-labelledby="workflow-onb-title">
        <div className="modal-header">
          <div>
            <div className="modal-title" id="workflow-onb-title">
              {stage === 'success' ? 'Workflow created' : 'Create a new workflow'}
            </div>
          </div>
          <button className="icon-btn" type="button" aria-label="Close" onClick={onClose}>
            <Icon name="x" size={15} />
          </button>
        </div>

        <div className="workflow-onb-stage-wrap">
          {stage === 'pick' && (
            <WorkflowOnboardingPick
              key="pick"
              animDir={animDir}
              servers={serverRows}
              selectedId={mcpServerId}
              onPickServer={setMcpServerId}
              path={path}
              onPickPath={setPath}
              onCancel={onClose}
              onContinue={() => {
                if (!mcpServerId) return;
                goTo(path === 'manual' ? 'manual' : 'agentStart', 'forward');
              }}
            />
          )}
          {stage === 'agentStart' && (
            <WorkflowOnboardingAgentStart
              animDir={animDir}
              usecase={usecase}
              setUsecase={setUsecase}
              onBack={() => goTo('pick', 'back')}
              onGenerateIdeas={() => {
                prepareSuggestedUsecases();
                setAgentIdeaMode('suggested');
                goTo('usecase', 'forward');
              }}
              onContinue={() => {
                prepareDirectUsecaseDraft();
                setAgentIdeaMode('direct');
                goTo('review', 'forward');
              }}
            />
          )}
          {stage === 'usecase' && (
            <WorkflowOnboardingUseCase
              animDir={animDir}
              server={selectedServer}
              suggestState={suggestState}
              usecase={usecase}
              setUsecase={setUsecase}
              onSuggest={suggestUsecases}
              onResuggest={resuggestUsecases}
              onBack={() => { restoreDirectUsecaseDraft(); goTo('agentStart', 'back'); }}
              onSwitchManual={() => goTo('manual', 'forward')}
              onContinue={() => {
                resetAgentDraft();
                setAgentIdeaMode('suggested');
                goTo('review', 'forward');
              }}
            />
          )}
          {stage === 'review' && (
            <WorkflowOnboardingReview
              animDir={animDir}
              draftState={draftState}
              draft={draft}
              setDraft={setDraft}
              testerModelIds={testerModelIds}
              testerTargets={testerTargets}
              onChangeTesterModels={setTesterModelIds}
              onChangeTesterTargets={setTesterTargets}
              models={modelRows}
              saving={creating}
              onBack={() => { resetAgentDraft(); goTo(agentIdeaMode === 'suggested' ? 'usecase' : 'agentStart', 'back'); }}
              onRetry={loadAgentDraft}
              onSwitchManual={() => goTo('manual', 'forward')}
              onCreate={createFromAgent}
            />
          )}
          {stage === 'manual' && manualDraft && (
            <WorkflowOnboardingManual
              animDir={animDir}
              server={selectedServer}
              draft={manualDraft}
              setDraft={setManualDraft}
              testerModelIds={testerModelIds}
              testerTargets={testerTargets}
              onChangeTesterModels={setTesterModelIds}
              onChangeTesterTargets={setTesterTargets}
              models={modelRows}
              saving={creating}
              onBack={() => goTo('pick', 'back')}
              onCreate={async () => {
                // Stage 4 (success) reads draft.name; stash the manual name
                // there before flipping stages so the success copy is correct.
                const name = manualDraft.name.trim();
                setDraft({ ...(draft || {}), name });
                await createFromManual();
              }}
            />
          )}
          {stage === 'success' && (
            <WorkflowOnboardingSuccess
              workflowName={draft?.name}
              initialDispatch={createdInitialDispatch}
              onSeeRuns={() => onCreated(createdId)}
              onBack={() => onCreated(null)}
            />
          )}
        </div>
      </div>
    </div>
  );

  if (typeof document !== 'undefined' && ReactDOM?.createPortal) {
    return ReactDOM.createPortal(modal, document.body);
  }
  return modal;
}

function WorkflowOnboardingPick({
  animDir,
  servers,
  selectedId,
  onPickServer,
  path,
  onPickPath,
  onCancel,
  onContinue,
  serverLabel = 'MCP server',
  serverPlaceholder = 'Pick an MCP server…',
  continueLabel = 'Continue',
}) {
  const canContinue = Boolean(selectedId);
  return (
    <div className={`workflow-onb-stage ${animDir === 'back' ? 'is-entering-back' : 'is-entering'}`}>
      <div className="workflow-onb-field">
        <div className="workflow-onb-field-label">{serverLabel}</div>
        <Select
          value={selectedId}
          onChange={onPickServer}
          placeholder={serverPlaceholder}
          options={servers.map((server) => ({ value: server.id, label: server.name }))} />
      </div>

      <div className="workflow-onb-field">
        <div className="workflow-onb-field-label">How would you like to set it up?</div>
        <div className="workflow-onb-path-cards">
          <button
            type="button"
            className={`workflow-onb-path-card ${path === 'auto' ? 'is-selected' : ''}`}
            onClick={() => onPickPath('auto')}>
            <div className="workflow-onb-path-card-mark"><Icon name="sparkle" size={14} /></div>
            <div style={{ flex: 1 }}>
              <div className="workflow-onb-path-card-title">
                With Armature agent
                <span className="pill">Recommended</span>
              </div>
              <div className="workflow-onb-path-card-sub">
                Start from your idea, or let Armature suggest one.
              </div>
            </div>
          </button>
          <button
            type="button"
            className={`workflow-onb-path-card ${path === 'manual' ? 'is-selected' : ''}`}
            onClick={() => onPickPath('manual')}>
            <div className="workflow-onb-path-card-mark"><Icon name="edit" size={13} /></div>
            <div style={{ flex: 1 }}>
              <div className="workflow-onb-path-card-title">Manual</div>
              <div className="workflow-onb-path-card-sub">
                Fill every field yourself.
              </div>
            </div>
          </button>
        </div>
      </div>

      <div className="workflow-onb-footer" style={{ borderTop: 'none', padding: 0, marginTop: 4 }}>
        <div className="workflow-onb-footer-left" />
        <div className="workflow-onb-footer-right">
          <Button onClick={onCancel}>Cancel</Button>
          <Button variant="primary" disabled={!canContinue} onClick={onContinue}>
            {continueLabel}<Icon name="chevronRight" size={12} />
          </Button>
        </div>
      </div>
    </div>
  );
}

function WorkflowOnboardingAgentStart({
  animDir, usecase, setUsecase, onBack, onGenerateIdeas, onContinue, continueLabel = 'Generate workflow', showGenerateIdeas = true,
}) {
  const continueDisabled = !usecase || usecase.trim().length < 5;
  return (
    <div className={`workflow-onb-stage ${animDir === 'back' ? 'is-entering-back' : 'is-entering'}`}>
      <div className="workflow-onb-thinking" style={{ alignItems: 'center' }}>
        <WorkflowOnboardingOrb thinking={false} />
        <div className="workflow-onb-thinking-text">
          <span><strong style={{ color: 'var(--text)', fontWeight: 600 }}>Already have a workflow idea?</strong> Describe it, or ask Armature for options.</span>
        </div>
      </div>

      <div className="workflow-onb-usecase-area">
        <textarea
          className="workflow-onb-usecase-textarea"
          value={usecase}
          onChange={(e) => setUsecase(e.target.value)}
          placeholder="Describe your workflow idea in a sentence..."
          maxLength={500}
          rows={3}
        />
        {showGenerateIdeas && (
          <button type="button" className="workflow-onb-generate-ideas" onClick={onGenerateIdeas}>
            <Icon name="sparkle" size={13} />
            Let Armature Agent generate workflow ideas
          </button>
        )}
      </div>

      <div className="workflow-onb-footer" style={{ borderTop: 'none', padding: 0, marginTop: 4 }}>
        <div className="workflow-onb-footer-left">
          <Button onClick={onBack}><Icon name="chevronLeft" size={12} />Back</Button>
        </div>
        <div className="workflow-onb-footer-right">
          <Button
            variant="primary"
            disabled={continueDisabled}
            onClick={onContinue}>
            {continueLabel}<Icon name="chevronRight" size={12} />
          </Button>
        </div>
      </div>
    </div>
  );
}

function WorkflowOnboardingUseCase({
  animDir, server, suggestState, usecase, setUsecase,
  onSuggest, onResuggest, onBack, onContinue, onSwitchManual = null,
}) {
  const reducedMotion = prefersReducedMotion();
  const toolCount = server?.latest_connection_test?.tool_count || 0;
  const thinkingMessages = useMemoW(() => {
    if (suggestState.status !== 'loading') return [''];
    return [
      `Reading ${toolCount || 'the'} tools…`,
      'Imagining the typical user…',
      'Sketching use-cases…',
    ];
  }, [suggestState.status, toolCount]);
  const status = useCyclingStatus(thinkingMessages, { enabled: suggestState.status === 'loading' && !reducedMotion });

  const firstSuggestion = suggestState.usecases[0]?.title || '';
  const others = suggestState.usecases.slice(1);
  const [typedSuggestion, typewriterDone, finishTypewriter] = useTypewriter(firstSuggestion, {
    speed: 22,
    enabled: suggestState.status === 'ready' && !reducedMotion && usecase === '',
  });

  const userTouchedRef = useRefW(false);
  useEffectW(() => {
    if (suggestState.status === 'loading') userTouchedRef.current = false;
  }, [suggestState.status]);
  useEffectW(() => {
    if (suggestState.status !== 'ready') return;
    if (userTouchedRef.current) return;
    setUsecase(typedSuggestion);
  }, [typedSuggestion, suggestState.status]);

  function handleTextChange(value) {
    userTouchedRef.current = true;
    finishTypewriter();
    setUsecase(value);
  }

  function pickAlternative(idx, title) {
    userTouchedRef.current = true;
    finishTypewriter();
    setUsecase(title);
  }

  useEffectW(() => {
    onSuggest();
  }, []); // eslint-disable-line

  const continueDisabled = !usecase || usecase.trim().length < 5;

  return (
    <div className={`workflow-onb-stage ${animDir === 'back' ? 'is-entering-back' : 'is-entering'}`}>
      <div className="workflow-onb-thinking" style={{ alignItems: 'center' }}>
        <WorkflowOnboardingOrb thinking={suggestState.status === 'loading'} />
        <div className="workflow-onb-thinking-text">
          {suggestState.status === 'loading' ? (
            <>
              <span>{status}</span>
              <span className="workflow-onb-thinking-caret" />
            </>
          ) : suggestState.status === 'error' ? (
            <span style={{ color: 'var(--text)' }}>{workflowAgentInlineErrorMessage(suggestState.error, suggestState.ourSide, 'suggest')}</span>
          ) : suggestState.status === 'ready' ? (
            <span><strong style={{ color: 'var(--text)', fontWeight: 600 }}>What should this workflow test?</strong> Edit freely, or pick another suggestion.</span>
          ) : null}
        </div>
      </div>

      {suggestState.status === 'error' && (
        <div className="workflow-onb-error">
          <Icon name="alert" size={16} />
          <div className="workflow-onb-error-text">
            <div style={{ fontWeight: 600 }}>{suggestState.ourSide ? 'An error on our side stopped the suggestions' : "Couldn't draft suggestions"}</div>
            <div>{suggestState.error || 'Try again, or fill the workflow manually.'}</div>
          </div>
          <div className="workflow-onb-error-actions">
            <Button size="sm" onClick={onResuggest}>Retry</Button>
            {onSwitchManual && <Button size="sm" variant="ghost" onClick={onSwitchManual}>Switch to manual</Button>}
          </div>
        </div>
      )}

      {suggestState.status === 'ready' && (
        <div className="workflow-onb-usecase-area">
          <div className="workflow-onb-reveal" style={{ animationDelay: '0ms' }}>
            <textarea
              className="workflow-onb-usecase-textarea"
              value={usecase}
              onChange={(e) => handleTextChange(e.target.value)}
              onFocus={() => { if (!typewriterDone) finishTypewriter(); }}
              onKeyDown={() => { if (!typewriterDone) finishTypewriter(); }}
              placeholder="Describe what this workflow should test"
              maxLength={500}
              rows={3}
            />
          </div>
          {others.length > 0 && (
            <div className="workflow-onb-suggestion-pills">
              {others.map((u, i) => (
                <SuggestionPill
                  key={`${u.title}:${i}`}
                  delay={(i + 1) * 90}
                  onClick={() => pickAlternative(i + 1, u.title)}
                  text={u.title}
                />
              ))}
            </div>
          )}
          <button type="button" className="workflow-onb-resuggest" onClick={onResuggest}>
            ↻ Suggest again
          </button>
        </div>
      )}

      <div className="workflow-onb-footer" style={{ borderTop: 'none', padding: 0, marginTop: 4 }}>
        <div className="workflow-onb-footer-left">
          <Button onClick={onBack}><Icon name="chevronLeft" size={12} />Back</Button>
        </div>
        <div className="workflow-onb-footer-right">
          <Button
            variant="primary"
            disabled={continueDisabled || suggestState.status !== 'ready'}
            onClick={onContinue}>
            Generate<Icon name="chevronRight" size={12} />
          </Button>
        </div>
      </div>
    </div>
  );
}

function SuggestionPill({ text, delay, onClick }) {
  const [pulsed, setPulsed] = useStateW(false);
  const timerRef = useRefW(/** @type {any} */ (null));
  useEffectW(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
  function handleClick() {
    setPulsed(true);
    if (timerRef.current) clearTimeout(timerRef.current);
    timerRef.current = setTimeout(() => { timerRef.current = null; setPulsed(false); }, 200);
    onClick();
  }
  return (
    <button
      type="button"
      className={`workflow-onb-suggestion-pill workflow-onb-reveal${pulsed ? ' is-pulse' : ''}`}
      style={{ animationDelay: `${delay}ms` }}
      onClick={handleClick}>
      {text}
    </button>
  );
}

function WorkflowOnboardingReview({
  animDir, draftState, draft, setDraft, testerModelIds, testerTargets = [], onChangeTesterModels, onChangeTesterTargets = (_rows) => undefined, models, saving, onBack, onCreate, onRetry, onSwitchManual, hidePrimaryAction = false, hideTargetsAndFrequency = false, primaryActionLabel = 'Create workflow', primaryActionIcon = null, reviewActionText = 'creating',
}) {
  const reducedMotion = prefersReducedMotion();
  const [revealedFields, setRevealedFields] = useStateW(reducedMotion ? 5 : 0);
  const [revealedCriteria, setRevealedCriteria] = useStateW(reducedMotion ? 99 : 0);
  const [counter, setCounter] = useStateW(reducedMotion ? 4 : 0);

  const loadingStatus = useCyclingStatus(
    [
      'Bounding the work...',
      'Mapping the source...',
      'Reading the tool surface...',
      'Bounding the tools...',
      'Drafting the work...',
      'Drafting the brief...',
      'Writing outcome checks...',
      'Checking the cadence...',
    ],
    { enabled: draftState.status !== 'ready' && !reducedMotion },
  );

  useEffectW(() => {
    if (draftState.status !== 'ready') return;
    if (reducedMotion) return;
    let canceled = false;
    const fields = hideTargetsAndFrequency ? 4 : 5;
    const stepMs = 70;
    setRevealedFields(0); setRevealedCriteria(0); setCounter(0);
    const timeouts = [];
    for (let i = 1; i <= fields; i += 1) {
      timeouts.push(setTimeout(() => { if (!canceled) setRevealedFields(i); }, i * stepMs));
    }
    const criteriaCount = (draft?.criteria || []).length;
    for (let i = 1; i <= criteriaCount; i += 1) {
      timeouts.push(setTimeout(() => { if (!canceled) setRevealedCriteria(i); }, fields * stepMs + i * 110));
    }
    const counterTicks = fields;
    for (let i = 1; i <= counterTicks; i += 1) {
      timeouts.push(setTimeout(() => { if (!canceled) setCounter(i); }, (600 * i) / counterTicks));
    }
    return () => { canceled = true; timeouts.forEach(clearTimeout); };
  }, [draftState.status]); // eslint-disable-line

  function skipReveal() {
    if (reducedMotion) return;
    setRevealedFields(99); setRevealedCriteria(99); setCounter(4);
  }

  if (draftState.status === 'error') {
    return (
      <div className={`workflow-onb-stage ${animDir === 'back' ? 'is-entering-back' : 'is-entering'}`}>
        <div className="workflow-onb-thinking">
          <WorkflowOnboardingOrb thinking={false} />
          <div className="workflow-onb-thinking-text">
            <span style={{ color: 'var(--text)' }}>{workflowAgentInlineErrorMessage(draftState.error, draftState.ourSide, 'draft')} Want me to try again, or fill it manually?</span>
          </div>
        </div>
        <div className="workflow-onb-error">
          <div className="workflow-onb-error-text">{draftState.error}</div>
          <div className="workflow-onb-error-actions">
            <Button size="sm" onClick={onRetry}>Retry</Button>
            <Button size="sm" variant="ghost" onClick={onSwitchManual}>Switch to manual</Button>
          </div>
        </div>
        <div className="workflow-onb-footer" style={{ borderTop: 'none', padding: 0, marginTop: 4 }}>
          <div className="workflow-onb-footer-left"><Button onClick={onBack}><Icon name="chevronLeft" size={12} />Back</Button></div>
          <div className="workflow-onb-footer-right" />
        </div>
      </div>
    );
  }

  if (draftState.status !== 'ready') {
    return (
      <div className={`workflow-onb-stage ${animDir === 'back' ? 'is-entering-back' : 'is-entering'}`}>
        <div className="workflow-onb-thinking">
          <WorkflowOnboardingOrb thinking />
          <div className="workflow-onb-thinking-text">
            <span>{loadingStatus}</span>
            <span className="workflow-onb-thinking-caret" />
          </div>
        </div>
      </div>
    );
  }

  function fieldCascade(idx, child) {
    if (revealedFields >= idx) {
      return <div className="workflow-onb-cascade" style={{ animationDelay: `${idx * 70}ms` }}>{child}</div>;
    }
    return null;
  }

  function setField(key, val) {
    if (saving) return;
    setDraft({ ...draft, [key]: val, edited: { ...(draft.edited || {}), [key]: true } });
  }
  function updateCriterion(idx, text) {
    if (saving) return;
    const next = (draft.criteria || []).map((c, i) => i === idx ? { ...c, criterionText: text, edited: true } : c);
    setDraft({ ...draft, criteria: next });
  }
  function removeCriterion(idx) {
    if (saving) return;
    const next = (draft.criteria || []).filter((_, i) => i !== idx);
    setDraft({ ...draft, criteria: next });
  }
  function addCriterion() {
    if (saving) return;
    const next = [...(draft.criteria || []), { criterionText: '', isRequired: true, edited: true, position: (draft.criteria?.length || 0) + 1 }];
    setDraft({ ...draft, criteria: next });
  }

  const filledCount = ['name', 'description', 'testerPrompt'].filter((k) => (draft[k] || '').length > 0).length + (draft.criteria?.length > 0 ? 1 : 0);
  const displayCount = reducedMotion ? filledCount : Math.min(counter, filledCount);

  return (
    <div className={`workflow-onb-stage ${animDir === 'back' ? 'is-entering-back' : 'is-entering'}`} onClick={skipReveal} onKeyDownCapture={skipReveal}>
      <div className="workflow-onb-review-banner workflow-onb-cascade" style={{ animationDelay: '0ms' }}>
        <Icon name="sparkle" size={14} style={{ color: 'var(--brand)' }} />
        <span>
          Armature filled <span className="workflow-onb-review-banner-count">{displayCount}</span> field{displayCount === 1 ? '' : 's'}.
          Edit anything before {reviewActionText}.
        </span>
      </div>

      <div className="workflow-onb-review">
        {fieldCascade(1, (
          <div className="workflow-onb-field">
            <div className="workflow-onb-field-label">
              <span>Name</span>
              {!draft.edited?.name && <span className="workflow-onb-source"><Icon name="sparkle" className="sparkle" size={10} />Drafted</span>}
              {draft.edited?.name && <span className="workflow-onb-source is-edited">Edited</span>}
            </div>
            <input
              value={draft.name || ''}
              onChange={(e) => setField('name', e.target.value)}
              disabled={saving}
              maxLength={80} />
          </div>
        ))}

        {fieldCascade(2, (
          <div className="workflow-onb-field">
            <div className="workflow-onb-field-label"><span>Description</span></div>
            <AutoTextarea
              value={draft.description || ''}
              onChange={(e) => setField('description', e.target.value)}
              disabled={saving}
              maxLength={200}
              minRows={1}
              maxRows={6} />
          </div>
        ))}

        {fieldCascade(3, (
          <div className="workflow-onb-field">
            <div className="workflow-onb-field-label">
              <span>Main instruction</span>
              {!draft.edited?.testerPrompt && <span className="workflow-onb-source"><Icon name="sparkle" className="sparkle" size={10} />Drafted</span>}
              {draft.edited?.testerPrompt && <span className="workflow-onb-source is-edited">Edited</span>}
            </div>
            <AutoTextarea
              value={draft.testerPrompt || ''}
              onChange={(e) => setField('testerPrompt', e.target.value)}
              disabled={saving}
              maxLength={2000}
              minRows={3}
              maxRows={14} />
          </div>
        ))}

        {revealedFields >= 4 && (draft.criteria || []).length > 0 && (
          <div className="workflow-onb-field">
            <div className="workflow-onb-field-label">
              <span>Success criteria</span>
              <span className="workflow-onb-source"><Icon name="sparkle" className="sparkle" size={10} />Drafted</span>
            </div>
            <div className="workflow-onb-criteria">
              {(draft.criteria || []).map((c, i) => (
                (revealedCriteria > i) && (
                  <div key={i} className="workflow-onb-criterion workflow-onb-cascade" style={{ animationDelay: `${i * 110}ms` }}>
                    <svg className="workflow-onb-criterion-dot" viewBox="0 0 14 14" aria-hidden="true">
                      <circle cx="7" cy="7" r="5.5" />
                    </svg>
                    <AutoTextarea
                      value={c.criterionText}
                      onChange={(e) => updateCriterion(i, e.target.value)}
                      disabled={saving}
                      minRows={1}
                      maxRows={8}
                      maxLength={200} />
                    <button type="button" className="workflow-onb-criterion-remove" aria-label="Remove criterion" disabled={saving} onClick={() => removeCriterion(i)}>
                      <Icon name="x" size={12} />
                    </button>
                  </div>
                )
              ))}
              {revealedCriteria >= (draft.criteria || []).length && (
                <button type="button" className="workflow-onb-criterion-add" disabled={saving} onClick={addCriterion}>
                  <Icon name="plus" size={11} /> Add criterion
                </button>
              )}
            </div>
          </div>
        )}

        {!hideTargetsAndFrequency && fieldCascade(5, (
          <>
            <div className="workflow-onb-field workflow-onb-target-field">
              <div className="workflow-onb-field-label"><span>Targets</span></div>
              <WorkflowTargetPicker
                modelIds={testerModelIds}
                targetRows={testerTargets}
                onChange={onChangeTesterModels}
                onChangeTargets={onChangeTesterTargets}
                models={models}
                disabled={saving} />
            </div>
            <div className="workflow-onb-field">
              <div className="workflow-onb-field-label"><span>Frequency</span></div>
              <div className="workflow-onb-cron-presets" role="radiogroup" aria-label="Schedule cadence">
                {FRIENDLY_SCHEDULE_OPTIONS.map((option) => {
                  const active = (draft.scheduleType || 'hourly') === option.value;
                  return (
                    <button
                      key={option.value}
                      type="button"
                      role="radio"
                      aria-checked={active}
                      className={`workflow-onb-cron-pill ${active ? 'is-selected' : ''}`}
                      onClick={() => setField('scheduleType', option.value)}
                      disabled={saving}
                      title={option.help}>
                      {option.label}
                    </button>
                  );
                })}
              </div>
              <div className="workflow-onb-cron-help">
                {(FRIENDLY_SCHEDULE_OPTIONS.find((opt) => opt.value === (draft.scheduleType || 'hourly')) || {}).help || ''}
              </div>
            </div>
          </>
        ))}
      </div>

      <div className="workflow-onb-footer" style={{ borderTop: 'none', padding: 0, marginTop: 4 }}>
        <div className="workflow-onb-footer-left"><Button onClick={onBack} disabled={saving}><Icon name="chevronLeft" size={12} />Back</Button></div>
        <div className="workflow-onb-footer-right">
          {!hidePrimaryAction && (
            <Button
              variant="primary"
              disabled={saving || !draft.name?.trim() || !draft.testerPrompt?.trim() || (!hideTargetsAndFrequency && testerModelIds.length === 0)}
              loading={saving}
              loadingLabel="Creating"
              onClick={onCreate}>
              {primaryActionIcon && <Icon name={primaryActionIcon} size={12} />}
              {primaryActionLabel}
            </Button>
          )}
        </div>
      </div>
    </div>
  );
}

function WorkflowOnboardingManual({ animDir, server, draft, setDraft, testerModelIds, testerTargets = [], onChangeTesterModels, onChangeTesterTargets = (_rows) => undefined, models, saving, onBack, onCreate, hidePrimaryAction = false, hideServerHeader = false, hideAuthProfile = false, hideTargets = false, hideFrequency = false, primaryActionLabel = 'Create workflow', primaryActionIcon = 'check' }) {
  const authProfiles = server?.auth_profiles
    || (server?.default_auth_profile ? [server.default_auth_profile] : []);
  const draftScheduleType = draft.scheduleType || 'manual_only';
  const scheduleHelp = (FRIENDLY_SCHEDULE_OPTIONS.find((opt) => opt.value === draftScheduleType) || {}).help || '';
  const criteriaCount = (draft.criteriaText || '')
    .split('\n')
    .map((l) => l.trim())
    .filter(Boolean).length;

  function set(key, value) {
    if (saving) return;
    setDraft({ ...draft, [key]: value });
  }

  const canCreate = !saving
    && draft.name.trim().length > 0
    && draft.testerPrompt.trim().length > 0
    && (hideTargets || testerModelIds.length > 0)
    && (hideTargets || !hasPendingWorkflowSkillVariant(testerTargets));

  return (
    <div className={`workflow-onb-stage workflow-onb-manual ${animDir === 'back' ? 'is-entering-back' : 'is-entering'}`}>
      {!hideServerHeader && (
      <div className="workflow-onb-manual-head">
        <div className="workflow-onb-manual-server">
          <span className="workflow-onb-manual-server-label">MCP server</span>
          <span className="workflow-onb-manual-server-name">{server?.name || '—'}</span>
          <button type="button" className="workflow-onb-manual-server-change" onClick={onBack} disabled={saving}>
            Change
          </button>
        </div>
      </div>
      )}

      <div className="workflow-onb-review">
        <div className="workflow-onb-field">
          <div className="workflow-onb-field-label"><span>Name</span></div>
          <input
            value={draft.name}
            onChange={(e) => set('name', e.target.value)}
            disabled={saving}
            placeholder="e.g. Vercel deploy smoke test"
            maxLength={80} />
        </div>

        <div className="workflow-onb-field">
          <div className="workflow-onb-field-label"><span>Description</span></div>
          <AutoTextarea
            value={draft.description || ''}
            onChange={(e) => set('description', e.target.value)}
            disabled={saving}
            placeholder="Short summary teammates can scan."
            minRows={1}
            maxRows={6}
            maxLength={200} />
        </div>

        <div className="workflow-onb-field">
          <div className="workflow-onb-field-label">
            <span>Main instruction</span>
            <span className="workflow-onb-source workflow-onb-source-hint">Tester prompt</span>
          </div>
          <AutoTextarea
            value={draft.testerPrompt || ''}
            onChange={(e) => set('testerPrompt', e.target.value)}
            disabled={saving}
            placeholder="Describe the outcome the tester should achieve. Don't enumerate tools or steps."
            minRows={3}
            maxRows={14}
            maxLength={2000} />
        </div>

        <div className="workflow-onb-field">
          <div className="workflow-onb-field-label">
            <span>Success criteria</span>
            <span className="workflow-onb-source workflow-onb-source-hint">{criteriaCount} {criteriaCount === 1 ? 'item' : 'items'}</span>
          </div>
          <AutoTextarea
            value={draft.criteriaText || ''}
            onChange={(e) => set('criteriaText', e.target.value)}
            disabled={saving}
            placeholder="One outcome per line. e.g. A new deployment exists for the project."
            minRows={3}
            maxRows={10}
            maxLength={4000} />
        </div>

        {!hideAuthProfile && (
        <div className="workflow-onb-field">
          <div className="workflow-onb-field-label"><span>Auth profile</span></div>
          <Select
            value={draft.authProfileId || ''}
            onChange={(value) => set('authProfileId', value)}
            disabled={saving}
            options={[
              {
                value: '',
                label: `Use server default${server?.default_auth_profile ? ` (${server.default_auth_profile.name})` : ''}`,
              },
              { value: 'none', label: 'No auth profile' },
              ...authProfiles.map((profile) => ({
                value: profile.id,
                label: `${profile.name}${profile.is_default ? ' (default)' : ''}`,
              })),
            ]} />
        </div>
        )}

        {!hideTargets && (
        <div className="workflow-onb-field workflow-onb-target-field">
          <div className="workflow-onb-field-label"><span>Targets</span></div>
          <WorkflowTargetPicker
            modelIds={testerModelIds}
            targetRows={testerTargets}
            onChange={onChangeTesterModels}
            onChangeTargets={onChangeTesterTargets}
            models={models}
            disabled={saving} />
        </div>
        )}

        {!hideFrequency && (
        <div className="workflow-onb-field">
          <div className="workflow-onb-field-label"><span>Frequency</span></div>
          <div className="workflow-onb-cron-presets" role="radiogroup" aria-label="Schedule cadence">
            {FRIENDLY_SCHEDULE_OPTIONS.map((option) => {
              const active = draftScheduleType === option.value;
              return (
                <button
                  key={option.value}
                  type="button"
                  role="radio"
                  aria-checked={active}
                  className={`workflow-onb-cron-pill ${active ? 'is-selected' : ''}`}
                  onClick={() => set('scheduleType', option.value)}
                  disabled={saving}
                  title={option.help}>
                  {option.label}
                </button>
              );
            })}
          </div>
          <div className="workflow-onb-cron-help">{scheduleHelp}</div>
        </div>
        )}
      </div>

      <div className="workflow-onb-footer" style={{ borderTop: 'none', padding: 0, marginTop: 4 }}>
        <div className="workflow-onb-footer-left">
          <Button onClick={onBack} disabled={saving}>
            <Icon name="chevronLeft" size={12} />Back
          </Button>
        </div>
        <div className="workflow-onb-footer-right">
          {!hidePrimaryAction && (
            <Button variant="primary" disabled={!canCreate} loading={saving} loadingLabel="Creating" onClick={onCreate}>
              {primaryActionIcon && <Icon name={primaryActionIcon} size={12} />}{primaryActionLabel}
            </Button>
          )}
        </div>
      </div>
    </div>
  );
}

function WorkflowOnboardingSuccess({ workflowName, initialDispatch, onSeeRuns, onBack }) {
  const message = initialDispatchMessage(initialDispatch) || 'This workflow is ready.';
  return (
    <div className="workflow-onb-stage is-entering">
      <div className="workflow-onb-success">
        <div className="workflow-onb-success-mark">
          <svg viewBox="0 0 32 32" width="28" height="28" aria-hidden="true">
            <path d="M7 17 L13 23 L25 9" />
          </svg>
        </div>
        <div className="workflow-onb-success-title">{workflowName || 'Workflow'} is live</div>
        <div className="workflow-onb-success-sub">
          {message}
        </div>
        <div className="workflow-onb-success-actions">
          <Button variant="primary" onClick={onSeeRuns}>See runs</Button>
          <Button onClick={onBack}>Back to workflows</Button>
        </div>
      </div>
    </div>
  );
}

window.newWorkflowRunsPath = newWorkflowRunsPath;
window.WorkflowsPage = WorkflowsPage;
window.WorkflowEditorPage = WorkflowEditorPage;
window.WorkflowAnalyticsPage = WorkflowAnalyticsPage;
window.WorkflowRunControl = WorkflowRunControl;
window.WorkflowOnboardingModal = WorkflowOnboardingModal;
window.WorkflowOnboardingPick = WorkflowOnboardingPick;
window.WorkflowOnboardingAgentStart = WorkflowOnboardingAgentStart;
window.WorkflowOnboardingUseCase = WorkflowOnboardingUseCase;
window.WorkflowOnboardingReview = WorkflowOnboardingReview;
window.WorkflowOnboardingManual = WorkflowOnboardingManual;
window.WorkflowOnboardingSuccess = WorkflowOnboardingSuccess;
window.WorkflowTargetPicker = WorkflowTargetPicker;
// Exposed for pages-onboarding-tour.jsx so the tour's workflow step
// runs through the same suggest / draft / create lifecycle as the
// in-app "Create new workflow" modal.
window.useWorkflowDraftFlow = useWorkflowDraftFlow;
window.buildWorkflowTesterTargets = buildWorkflowTesterTargets;
window.normalizeWorkflowModelIds = normalizeWorkflowModelIds;
window.normalizeWorkflowTargetRows = normalizeWorkflowTargetRows;
window.workflowTargetRowsKey = workflowTargetRowsKey;
window.getDefaultWorkflowTargetRows = getDefaultWorkflowTargetRows;
window.getWorkflowTargetModelIds = getWorkflowTargetModelIds;
window.getModelsForWorkflowHarness = getModelsForWorkflowHarness;
window.getWorkflowHarnessOption = getWorkflowHarnessOption;
window.getWorkflowHarnessOptions = getWorkflowHarnessOptions;
window.pickAddedWorkflowTargetModel = pickAddedWorkflowTargetModel;
window.pickDefaultModelForHarness = pickDefaultModelForHarness;
window.pickLatestByProvider = pickLatestByProvider;
