// ===== write-with-ai/StudioGenerateModal.js — "Write Documentation with BetterDocs AI" =====
// Superset modal ported from the AI Studio mockup (studio-modal.jsx + the
// OutlineEditor from ai-modal.jsx), wired to the real REST endpoint and real
// Gutenberg insertion. Brand-voice chips are intentionally omitted (deferred).
// The token meter markup is built but kept hidden for now.
import { __, sprintf } from "@wordpress/i18n";
import { normalizeAIError } from "../../utils/aiErrorMessage";
import { useState, useEffect, useRef } from "@wordpress/element";
import { dispatch, select } from "@wordpress/data";
import {
Modal, AIcon, FieldLabel, TextInput, TextArea, Pick, DropdownSelect, Toggle, Banner,
PrimaryBtn, GhostBtn, TextBtn,
IcList, IcSummary, IcChevDown, IcPlus, IcTrash, IcGrip, IcArrowR, IcRefresh, IcCheck, AIPill, AIMarkWhite,
} from "../ai-shared/kit";
import { PreviewAccept, ERR_LABEL } from "../ai-shared/PreviewAccept";
import InstructionPicker from "../ai-shared/InstructionPicker";
import { callWriteWithAI, callWriteWithAIMultipart, getConfig, suggestTerms } from "./api";
import { cleanGeneratedHtml, extractFirstHeading, insertContentToEditor } from "./insert";
import SuggestTermsPreview from "./SuggestTermsPreview";
import GitBrowse from "./GitBrowse";
// Tone + length selectors. The empty/"any" values send no directive (backend default).
const TONE_OPTIONS = [
{ value: "", label: __("Default", "betterdocs") },
{ value: "friendly", label: __("Friendly", "betterdocs") },
{ value: "technical", label: __("Technical", "betterdocs") },
{ value: "formal", label: __("Formal", "betterdocs") },
{ value: "casual", label: __("Casual", "betterdocs") },
];
const SIZE_OPTIONS = [
{ value: "any", label: __("Any", "betterdocs") },
{ value: "short", label: __("Short", "betterdocs") },
{ value: "medium", label: __("Medium", "betterdocs") },
{ value: "long", label: __("Long", "betterdocs") },
];
// ── Local icons (sliders for the settings accordion, sparkle for "Generate") ──
const IcSliders = ({ size = 16 }) => (
);
const IcSpark = ({ size = 16 }) => (
);
// Pencil that inherits the tab's color (currentColor) — the kit's AIMark is fixed to the AI accent.
const IcPencil = (p) => (
);
// Git branch glyph (inherits currentColor) for the "From Git" tab.
const IcGit = (p) => (
);
// Paperclip glyph for the "From Attachment" tab (inherits currentColor).
const IcPaperclip = (p) => (
);
// Padlock + star — for the "Pro feature" upsell panel (inherit currentColor).
const IcLock = (p) => (
);
const IcProStar = ({ size = 14 }) => (
);
// ── Underline tabs (Prompt | From source) ────────────────────────────────────
// Typography, colour and active border-bottom mirror the admin
// `.betterdocs-metabox-tabs` (see react-src/admin/scss/unified-metabox.scss):
// 14px / 1.5 / weight 500, inactive #d0d5dd, active + hover green (#00b884),
// active gets a 2px green bottom border.
const Segmented = ({ value, onChange, tabs }) => (
{tabs.map((t) => {
const active = value === t.id;
const idle = "var(--text-color-400, #d0d5dd)";
return (
);
})}
);
// ── Segmented choice control (tone / length) ─────────────────────────────────
// Segmented choice control (tone / length) — mirrors .betterdocs-faq-page-tabs:
// a white bordered track with a soft shadow; the active segment gets the ash
// (--text-color-50) fill + green (--base-color-700) label.
const SegControl = ({ value, onChange, options }) => (
{options.map((o) => {
const active = value === o.value;
return (
);
})}
);
// ── Quick prompt chip (From Git) — toggles a starter snippet into the prompt ──
const QuickChip = ({ active, onClick, children }) => (
);
// ── One row inside the Advanced settings card ────────────────────────────────
const SettingRow = ({ label, children }) => (
);
};
const backBtn = {
width: 34, height: 34, borderRadius: 9, flexShrink: 0, border: "none", cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "center",
color: "var(--muted)", background: "#f4f6f8",
};
const SOURCE_TYPES = [
{ value: "", label: __("Auto-detect", "betterdocs") },
{ value: "transcript", label: __("Support transcript", "betterdocs") },
{ value: "forum", label: __("Forum thread", "betterdocs") },
{ value: "notes", label: __("Raw notes", "betterdocs") },
];
const SOURCE_PLACEHOLDERS = {
"": __("Paste any existing content — a support chat, forum thread, or rough notes. BetterDocs AI will turn it into a structured doc.", "betterdocs"),
transcript: __("Paste the full support conversation (chat or email). BetterDocs AI will turn it into a structured doc.", "betterdocs"),
forum: __("Paste the forum thread, including replies. BetterDocs AI will turn it into a structured doc.", "betterdocs"),
notes: __("Paste your rough notes or bullet points. BetterDocs AI will expand them into a structured doc.", "betterdocs"),
};
const GIT_PLACEHOLDER = __("Paste a GitHub pull request or file URL — e.g. https://github.com/owner/repo/pull/123 or .../blob/main/docs/guide.md", "betterdocs");
const UPGRADE_URL = "https://betterdocs.co/upgrade";
// From Git input modes: paste a URL, or browse the connected account's repos.
// ── Attachment kinds ─────────────────────────────────────────────────────────
//
// Recordings take a different route through the modal than every other
// attachment: they are transcribed first, the author corrects the transcript,
// and only then is the doc generated from that text. The extension list comes
// from the server (REST\WriteWithAI::media_exts) so the two can never drift.
const MEDIA_EXTS_FALLBACK = [
"mp3", "mpga", "m4a", "wav", "flac", "ogg", "oga", "mp4", "mpeg", "mpg", "webm",
];
// Read lazily, not at module scope: the bundle can evaluate before
// wp_localize_script has run on some screens, and a snapshot taken then would
// freeze the fallback list even once the real config arrives.
const mediaExts = () => getConfig().media_exts || MEDIA_EXTS_FALLBACK;
const extOf = (name) => String(name || "").split(".").pop().toLowerCase();
const isRecording = (f) => !!f && mediaExts().indexOf(extOf(f.name)) !== -1;
// Accept attribute for the file input, built from the same list.
const acceptAttr = () =>
[".pdf", ".docx", ".txt", ".md", ".markdown", ".png", ".jpg", ".jpeg", ".webp"]
.concat(mediaExts().map((e) => `.${e}`))
.join(",");
// wp_localize_script stringifies top-level scalars, so every number here
// arrives as "26214400" and every boolean as "1" or "". Coerce explicitly
// rather than leaning on JS comparison rules — "0" is truthy, and a silently
// truthy "can transcribe" flag would offer a button that always fails.
const num = (value, fallback) => {
const n = parseInt(value, 10);
return isNaN(n) || n <= 0 ? fallback : n;
};
const flag = (value) => !!value && "0" !== String(value);
// The server cap, already clamped to what the host will accept — a site with
// upload_max_filesize = 8M advertises 8 MB here, not our 25.
const capFor = (f) => {
const cfg = getConfig();
return isRecording(f)
? num(cfg.max_media_bytes, 25165824)
: num(cfg.max_upload_bytes, 5242880);
};
const asMB = (bytes) => `${Math.round((bytes / 1048576) * 10) / 10} MB`;
const GIT_MODES = [
{ value: "url", label: __("Paste URL", "betterdocs") },
{ value: "browse", label: __("Browse repository", "betterdocs") },
];
// Quick prompt starters for the From Git tab. One click appends the snippet to
// the additional-prompt field (click again to remove) — they replace the old
// framing dropdown, so the AI reads the intent straight from the prompt text.
const QUICK_PROMPTS = [
{ id: "feature", label: __("Document this feature / PR", "betterdocs"),
text: __("Explain what this feature does and how to use it in clear end-user documentation terms — focus on the user-facing behavior, not the code or implementation details.", "betterdocs") },
{ id: "howto", label: __("Turn into a how-to guide", "betterdocs"),
text: __("Turn this into a concise, numbered step-by-step how-to guide.", "betterdocs") },
{ id: "whatsnew", label: __("Write a what's-new doc", "betterdocs"),
text: __("Write a \"What's New\" announcement-style doc that highlights the changes and explains why they matter to users.", "betterdocs") },
{ id: "adapt", label: __("Adapt this doc to my site", "betterdocs"),
text: __("Rewrite this as a fresh doc for my site, keeping the meaning but improving the clarity, structure, and tone.", "betterdocs") },
];
const withId = (outline) =>
(outline || []).map((o, i) => ({ id: `o-${i}`, level: o.level === "h3" ? "h3" : "h2", text: String(o.text || "") }));
// Taxonomies offered in the preview-step term suggestions (order = display order).
// Slugs double as the canonical post-attribute key (doc_category/doc_tag have no
// rest_base), matching the native Docs AI Suite panel.
const SUGGEST_TAXONOMIES = [
{ taxonomy: "doc_category", label: __("Categories", "betterdocs") },
{ taxonomy: "doc_tag", label: __("Tags", "betterdocs") },
];
// ── The modal ─────────────────────────────────────────────────────────────────
const StudioGenerateModal = ({ open, onClose }) => {
const config = getConfig();
const hasKey = !!config.has_key;
const languageOptions = Array.isArray(config.language_options) ? config.language_options : [];
const showLanguage = !!config.is_multilingual_active && languageOptions.length > 0;
const instructionChoices = Array.isArray(config.instructions) ? config.instructions : [];
// From Git gating: Pro must be active AND Git Sync enabled + authenticated.
const isProActive = !!config.is_pro_active;
const git = config.git || {};
const gitReady = isProActive && !!git.enabled && !!git.connected;
// Site owners can hide the whole "From Git" tab from Settings → Git Sync
// (default on). Only applies when Pro is active; without Pro the tab still
// shows the upsell.
const showGitTab = !isProActive || git.ai_enabled !== false;
// Repository browsing is GitHub-only (GitLab connections are single-project);
// GitLab users get the Paste-URL mode only.
const gitProvider = git.provider || "github";
const canBrowse = gitProvider === "github";
// Falls back to the Git Sync settings anchor (not the AI settings anchor).
const gitSettingsUrl = git.settings_url || "admin.php?page=betterdocs-settings#git-sync";
const [tab, setTab] = useState("prompt");
const [stage, setStage] = useState("form"); // form · loadingOutline · loadingDoc · outline · preview · previewError
const [prompt, setPrompt] = useState("");
const [source, setSource] = useState("");
const [srcType, setSrcType] = useState(SOURCE_TYPES[0].value);
const [gitUrl, setGitUrl] = useState("");
const [gitPrompt, setGitPrompt] = useState(""); // additional prompt for the From Git tab
const [gitMode, setGitMode] = useState("url"); // url · browse
const [file, setFile] = useState(null); // selected file for the From Attachment tab
const [filePrompt, setFilePrompt] = useState(""); // optional prompt for the From Attachment tab
// Recording flow: a transcript sits between the upload and generation so the
// author can fix mis-heard product names before the doc is written.
const [transcript, setTranscript] = useState("");
const [transcribing, setTranscribing] = useState(false);
const [fileError, setFileError] = useState("");
const [transcriptClipped, setTranscriptClipped] = useState(false);
// Keyboard focus on the (visually hidden) file input, drawn on its drop zone.
const [pickerFocus, setPickerFocus] = useState(false);
// Bumped whenever the file changes or is removed. A transcription answers
// only if it is still the latest request — otherwise Remove (or a new pick)
// during "Transcribing…" let the old file's transcript land on the new file.
const transcribeReq = useRef(0);
const [browseUrl, setBrowseUrl] = useState(""); // URL resolved by the browse picker
const [lang, setLang] = useState("");
const [tone, setTone] = useState("");
const [docSize, setDocSize] = useState("any");
const [selectedInstructionIds, setSelectedInstructionIds] = useState([]);
const [advOpen, setAdvOpen] = useState(false);
const [existingTitle, setExistingTitle] = useState("");
const [hasContent, setHasContent] = useState(false);
const [regenerateTitle, setRegenerateTitle] = useState(true);
const [overwrite, setOverwrite] = useState(true);
const [genTitle, setGenTitle] = useState("");
const [html, setHtml] = useState("");
const [outline, setOutline] = useState([]);
const [error, setError] = useState(null);
// Preview-step term suggestions — one independent on/off control per taxonomy.
const [suggestTax, setSuggestTax] = useState({ doc_category: true, doc_tag: true });
const [suggestions, setSuggestions] = useState({}); // { [taxonomy]: [{name,term_id,is_new}] }
const [suggestLoading, setSuggestLoading] = useState(false);
const [selectedKeys, setSelectedKeys] = useState(() => new Set()); // `${taxonomy}::${name}` kept
// Snapshot the editor's title + content state, and reset, each time it opens.
useEffect(() => {
if (!open) return;
setStage("form");
setError(null);
setGenTitle("");
setOverwrite(true);
setRegenerateTitle(true);
setSelectedInstructionIds([]);
setGitMode("url");
setBrowseUrl("");
setGitPrompt("");
setFile(null);
setFilePrompt("");
setTranscript("");
setTranscribing(false);
setFileError("");
setTranscriptClipped(false);
transcribeReq.current += 1;
setSuggestTax({ doc_category: true, doc_tag: true });
setSuggestions({});
setSelectedKeys(new Set());
setSuggestLoading(false);
try {
const editorTitle = window.wp?.data?.select("core/editor")?.getEditedPostAttribute("title") || "";
setExistingTitle(editorTitle);
const blocks = window.wp?.data?.select("core/block-editor")?.getBlocks() || [];
setHasContent(blocks.length > 0);
} catch (e) { /* editor not ready */ }
}, [open]);
// Generate a title when the doc has none, or when the user opted to regenerate.
const titleRequested = !existingTitle || regenerateTitle;
const canPrompt = hasKey && prompt.trim().length > 0;
const canSource = hasKey && source.trim().length > 20;
// The URL "Generate from Git" sends: pasted, or resolved by the browse picker.
const effectiveGitUrl = (gitMode === "browse" ? browseUrl : gitUrl).trim();
const canGit = hasKey && gitReady && effectiveGitUrl.length > 8;
// A recording needs a transcript before it can generate; every other file
// type goes straight through as it always has.
const canAttachment = hasKey && !!file && (!isRecording(file) || transcript.trim().length > 0);
// On the Git tab without a working connection we show only the upsell/connect
// panel — no Guides, Advanced settings, or generate footer.
const gitBlocked = tab === "git" && !gitReady;
const est = Math.round(((prompt + source).length) / 4) + 200;
// Append the chosen language to the prompt at send-time (client-side).
const langSuffix = () => {
if (!showLanguage || !lang) return "";
const found = languageOptions.find((o) => String(o.value) === String(lang));
const label = found ? found.label : lang;
return " " + sprintf(__("Write the documentation in %s.", "betterdocs"), label);
};
// Shared directives sent on every generation: tone, size, and whether the AI
// should also produce a title (assembled into the prompt by the backend).
const directives = () => ({ tone, doc_size: docSize, generate_title: titleRequested, instruction_ids: selectedInstructionIds });
// Quick-prompt chips toggle their snippet into the From Git prompt: append if
// absent, strip it out (tidying stray blank lines) if already there.
const toggleQuickPrompt = (text) => setGitPrompt((prev) =>
prev.includes(text)
? prev.split(text).join("").replace(/\n{2,}/g, "\n").trim()
: (prev.trim() ? prev.trim() + "\n" + text : text)
);
// Term suggestions are gated by an API key AND the Docs AI Suite setting (the
// same gate the REST endpoint enforces). Each taxonomy then has its own toggle.
const canSuggest = hasKey && !!config.suggest_terms_enabled;
// Taxonomies to actually request: whichever per-taxonomy toggles are on.
const enabledSuggestTaxes = () => SUGGEST_TAXONOMIES.filter(
(t) => suggestTax[t.taxonomy]
);
const toggleTax = (tax) => setSuggestTax((prev) => ({ ...prev, [tax]: !prev[tax] }));
const toggleKey = (key) => setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
// Fetch suggestions for each enabled taxonomy in parallel off the freshly
// generated doc.
const fetchTermSuggestions = (contentHtml, docTitle) => {
const taxes = enabledSuggestTaxes();
setSuggestions({});
setSelectedKeys(new Set());
if (!taxes.length) { setSuggestLoading(false); return; }
setSuggestLoading(true);
Promise.all(
taxes.map((t) =>
suggestTerms({ taxonomy: t.taxonomy, content: contentHtml, title: docTitle })
.then((list) => [t.taxonomy, list])
)
)
.then((pairs) => {
const next = {};
pairs.forEach(([tax, list]) => { next[tax] = list || []; });
setSuggestions(next);
})
.finally(() => setSuggestLoading(false));
};
// Apply every kept suggestion (from an enabled taxonomy) to the (unsaved) doc:
// create new terms as needed, then assign via editPost keyed by the taxonomy
// slug — mirroring the native Docs AI Suite panel so tokens update immediately.
const applyKeptTerms = async () => {
if (!selectedKeys.size) return;
const { saveEntityRecord } = dispatch("core");
const editor = dispatch("core/editor");
const editorSelect = select("core/editor");
for (const t of SUGGEST_TAXONOMIES) {
if (!suggestTax[t.taxonomy]) continue;
const items = (suggestions[t.taxonomy] || []).filter((s) => selectedKeys.has(`${t.taxonomy}::${s.name}`));
if (!items.length) continue;
const current = (editorSelect.getEditedPostAttribute(t.taxonomy) || []).map(Number);
const toAdd = [];
for (const s of items) {
let termId = s.term_id;
if (!termId) {
try {
const created = await saveEntityRecord("taxonomy", t.taxonomy, { name: s.name });
termId = created && created.id;
} catch (e) { continue; }
}
termId = Number(termId);
if (termId && !current.includes(termId) && !toAdd.includes(termId)) toAdd.push(termId);
}
if (toAdd.length) editor.editPost({ [t.taxonomy]: [...current, ...toAdd] });
}
};
const handleDoc = (data) => {
const newTitle = titleRequested ? extractFirstHeading(data.content) : "";
if (titleRequested) setGenTitle(newTitle);
const cleaned = cleanGeneratedHtml(data.content);
setHtml(cleaned);
setStage("preview");
if (canSuggest && enabledSuggestTaxes().length) {
fetchTermSuggestions(cleaned, newTitle || existingTitle);
}
};
// Refuse an oversize file here rather than uploading it and waiting for the
// server to say no — the old flow pushed the whole 50 MB up the wire first.
const pickFile = (picked) => {
transcribeReq.current += 1;
setTranscribing(false);
setTranscript("");
setTranscriptClipped(false);
setFileError("");
if (!picked) {
setFile(null);
return;
}
const cap = capFor(picked);
if (picked.size > cap) {
setFile(null);
setFileError(sprintf(
/* translators: 1: file name, 2: maximum allowed size. */
__("“%1$s” is larger than the %2$s limit. Trim it, or export a smaller version.", "betterdocs"),
picked.name,
asMB(cap)
));
return;
}
setFile(picked);
};
// Step one of the recording flow. Returns the transcript to the panel; no
// doc is written and no stage change happens, so the author stays in the form.
const transcribeFile = () => {
if (!file) return;
setError(null);
setFileError("");
setTranscribing(true);
const req = ++transcribeReq.current;
callWriteWithAIMultipart("transcribe-attachment", {}, file)
.then((data) => {
if (req !== transcribeReq.current) return;
setTranscript(String((data && data.transcript) || ""));
setTranscriptClipped(!!(data && data.clipped));
setTranscribing(false);
})
.catch((err) => {
if (req !== transcribeReq.current) return;
setFileError(normalizeAIError(err));
setTranscribing(false);
});
};
const run = (action) => {
setError(null);
setStage(action === "generate-outline" ? "loadingOutline" : "loadingDoc");
// From Attachment posts a multipart request (the file + directives) and
// lands on the same doc-preview flow as every other source.
if (action === "from-attachment") {
// A recording has already become text; generate from the transcript the
// author just approved, through the ordinary grounded source path. Only
// non-media attachments still need the file itself on the wire.
if (isRecording(file)) {
callWriteWithAI("from-source", {
source: transcript,
source_type: "recording",
prompt: filePrompt + langSuffix(),
...directives(),
})
.then(handleDoc)
.catch((err) => { setError({ ...(err || {}), message: normalizeAIError(err) }); setStage("previewError"); });
return;
}
callWriteWithAIMultipart(action, { prompt: filePrompt + langSuffix(), ...directives() }, file)
.then(handleDoc)
.catch((err) => { setError({ ...(err || {}), message: normalizeAIError(err) }); setStage("previewError"); });
return;
}
let payload;
if (action === "from-source") {
payload = { source, source_type: srcType, prompt: prompt + langSuffix(), ...directives() };
} else if (action === "from-git") {
payload = { git_url: effectiveGitUrl, prompt: gitPrompt + langSuffix(), ...directives() };
} else {
payload = { prompt: prompt + langSuffix(), ...directives() };
}
callWriteWithAI(action, payload)
.then((data) => {
if (action === "generate-outline") {
setOutline(withId(data.outline));
setStage("outline");
} else {
handleDoc(data);
}
})
.catch((err) => {
setError({ ...(err || {}), message: normalizeAIError(err) });
setStage(action === "generate-outline" ? "form" : "previewError");
});
};
const expand = () => {
setError(null);
setStage("loadingDoc");
callWriteWithAI("expand-outline", {
outline: outline.map((o) => ({ level: o.level, text: o.text })),
prompt: prompt + langSuffix(),
...directives(),
})
.then(handleDoc)
.catch((err) => { setError({ ...(err || {}), message: normalizeAIError(err) }); setStage("previewError"); });
};
const keepAndInsert = async () => {
await applyKeptTerms();
insertContentToEditor({ title: titleRequested ? genTitle : "", html, isOverwrite: overwrite });
onClose();
};
const regenerateDoc = () => run(tab === "source" ? "from-source" : tab === "git" ? "from-git" : tab === "attachment" ? "from-attachment" : "generate-doc");
const showBack = stage === "outline" || stage === "preview" || stage === "previewError";
// Live summary shown on the collapsed "Advanced settings" bar.
const toneLabel = (TONE_OPTIONS.find((o) => o.value === tone) || TONE_OPTIONS[0]).label;
const sizeLabel = "any" === docSize
? __("Any size", "betterdocs")
: (SIZE_OPTIONS.find((o) => o.value === docSize) || SIZE_OPTIONS[0]).label;
const enabledCount = (existingTitle && regenerateTitle ? 1 : 0) + (hasContent && overwrite ? 1 : 0) + (canSuggest ? enabledSuggestTaxes().length : 0);
/* translators: 1: tone label, 2: length label, 3: number of enabled toggles. */
const advSummary = sprintf(__("%1$s · %2$s · %3$d on", "betterdocs"), toneLabel, sizeLabel, enabledCount);
// Settings rows are conditional; build the list, then render with dividers.
const settingRows = [
,
,
];
if (showLanguage) {
settingRows.push(
);
}
if (existingTitle) {
settingRows.push(
);
}
if (hasContent) {
settingRows.push(
);
}
if (canSuggest) {
settingRows.push(
toggleTax("doc_category")}
title={__("Suggest categories", "betterdocs")}
subtitle={__("Suggest doc categories after generating", "betterdocs")} />,
toggleTax("doc_tag")}
title={__("Suggest tags", "betterdocs")}
subtitle={__("Suggest doc tags after generating", "betterdocs")} />
);
}
return (
{/* Header */}
{showBack ? (
) : (
)}
{__("Write Documentation", "betterdocs")}
{!showBack && (
{__("Draft a doc with BetterDocs AI", "betterdocs")}
)}
{/* Tabs + model on one line. The modal width (720) is sized so the
four source tabs and the "Platform · Model" pill share a single
row with room to spare — at 640 the pill overflowed and clipped. */}
{!showBack && stage === "form" && (
},
{ id: "source", label: __("From source", "betterdocs"), icon: },
...(showGitTab ? [{ id: "git", label: __("From Git", "betterdocs"), icon: }] : []),
{ id: "attachment", label: __("From Attachment", "betterdocs"), icon: },
]} />
{/* Lift the pill to align with the tab labels, not the bottom
border — the tabs carry 13px bottom padding, so a centred
pill otherwise sits low against the divider line. */}
)}
{/* Body — the 320px floor keeps the form/loading/preview steps a
comfortable size, but the error step's content is tiny, so we let
it size to content (no forced floor) and the footer sits at the
modal bottom instead of floating above a large empty gap. */}
{stage === "form" && (
<>
{!hasKey && (
{__("Please insert your", "betterdocs")}{" "}
{__("OpenAI API Key", "betterdocs")}
{" "}
{__("to use this Write with AI feature.", "betterdocs")}
)}
{hasKey && error && (
{ERR_LABEL[error.code] || __("Couldn't generate", "betterdocs")}. {error.message}
)}
{tab === "prompt" && (
<>
{__("What should this documentation cover?", "betterdocs")}
{__("The clearer your request, the better the result. A title and formatting are added automatically.", "betterdocs")}
>
)}
{tab === "source" && (
<>
{__("Source content:", "betterdocs")}
{__("BetterDocs AI uses only what's in your source — it won't invent details.", "betterdocs")}
>
)}
{tab === "git" && !isProActive && (
/* State 1 — Pro not active: upgrade panel */
{/* Lock tile */}
{/* Pro eyebrow */}
{__("Pro", "betterdocs")}
{/* Headline */}
{__("Writing from Git is a Pro feature", "betterdocs")}
{/* Description */}
{__("Paste a pull request or repo file URL and let BetterDocs AI turn what you already shipped in Git into a polished doc.", "betterdocs")}
{/* Feature list */}
{[
__("Generate a doc from a Pull Request", "betterdocs"),
__("Turn a repository file into a new doc", "betterdocs"),
__("Works with GitHub & GitLab via Git Sync", "betterdocs"),
].map((feature, i) => (
)}
{/* Recording → transcribe, correct, then generate. The transcript is
shown because speech-to-text mangles product names, and a wrong
term here becomes a wrong term in the published doc. */}
{file && isRecording(file) && (
{sprintf(
/* translators: %s: the configured AI platform label. */
__("%s can't read audio or video. Switch to OpenAI or Google Gemini in Settings → AI Content Suite.", "betterdocs"),
config.platform_label || config.platform || ""
)}
{__("This recording is longer than BetterDocs AI can use at once, so the transcript stops part-way — the doc will cover only this part. Split the recording to document the rest.", "betterdocs")}
)}
{sprintf(
/* translators: %d: character count of the transcript. */
__("%d characters", "betterdocs"),
transcript.length
)}
{__("Transcribe again", "betterdocs")}
>
)}
)}
{__("Add a prompt (optional)", "betterdocs")}
{file && isRecording(file)
? __("BetterDocs AI transcribes the speech, then writes only from that transcript — it won't invent details. Video is read for its audio; what's on screen is not.", "betterdocs")
: __("BetterDocs AI reads the file's text and writes only from its content — it won't invent details.", "betterdocs")}
>
)}
{/* Guides — pick saved instruction sets to layer on the base prompt */}
{!gitBlocked && instructionChoices.length > 0 && (