PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.5
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.5
4.9.5 4.9.4 4.9.3 4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 All 203 releases
betterdocs / react-src / gutenberg / write-with-ai / StudioGenerateModal.js

StudioGenerateModal.js in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.5, at react-src/gutenberg/write-with-ai/StudioGenerateModal.js

1,218 lines 71.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 // ===== write-with-ai/StudioGenerateModal.js — "Write Documentation with BetterDocs AI" =====
2 // Superset modal ported from the AI Studio mockup (studio-modal.jsx + the
3 // OutlineEditor from ai-modal.jsx), wired to the real REST endpoint and real
4 // Gutenberg insertion. Brand-voice chips are intentionally omitted (deferred).
5 // The token meter markup is built but kept hidden for now.
6
7 import { __, sprintf } from "@wordpress/i18n";
8 import { normalizeAIError } from "../../utils/aiErrorMessage";
9 import { useState, useEffect, useRef } from "@wordpress/element";
10 import { dispatch, select } from "@wordpress/data";
11
12 import {
13 Modal, AIcon, FieldLabel, TextInput, TextArea, Pick, DropdownSelect, Toggle, Banner,
14 PrimaryBtn, GhostBtn, TextBtn,
15 IcList, IcSummary, IcChevDown, IcPlus, IcTrash, IcGrip, IcArrowR, IcRefresh, IcCheck, AIPill, AIMarkWhite,
16 } from "../ai-shared/kit";
17 import { PreviewAccept, ERR_LABEL } from "../ai-shared/PreviewAccept";
18 import InstructionPicker from "../ai-shared/InstructionPicker";
19 import { callWriteWithAI, callWriteWithAIMultipart, getConfig, suggestTerms } from "./api";
20 import { cleanGeneratedHtml, extractFirstHeading, insertContentToEditor } from "./insert";
21 import SuggestTermsPreview from "./SuggestTermsPreview";
22 import GitBrowse from "./GitBrowse";
23
24 // Tone + length selectors. The empty/"any" values send no directive (backend default).
25 const TONE_OPTIONS = [
26 { value: "", label: __("Default", "betterdocs") },
27 { value: "friendly", label: __("Friendly", "betterdocs") },
28 { value: "technical", label: __("Technical", "betterdocs") },
29 { value: "formal", label: __("Formal", "betterdocs") },
30 { value: "casual", label: __("Casual", "betterdocs") },
31 ];
32
33 const SIZE_OPTIONS = [
34 { value: "any", label: __("Any", "betterdocs") },
35 { value: "short", label: __("Short", "betterdocs") },
36 { value: "medium", label: __("Medium", "betterdocs") },
37 { value: "long", label: __("Long", "betterdocs") },
38 ];
39
40 // ── Local icons (sliders for the settings accordion, sparkle for "Generate") ──
41 const IcSliders = ({ size = 16 }) => (
42 <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
43 strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
44 <line x1="4" y1="7" x2="20" y2="7" /><line x1="4" y1="12" x2="20" y2="12" /><line x1="4" y1="17" x2="20" y2="17" />
45 <circle cx="9" cy="7" r="2.2" fill="white" /><circle cx="15" cy="12" r="2.2" fill="white" /><circle cx="9" cy="17" r="2.2" fill="white" />
46 </svg>
47 );
48 const IcSpark = ({ size = 16 }) => (
49 <svg width={size} height={size} viewBox="0 0 24 24" fill="white" stroke="none">
50 <path d="M12 2l1.9 5.1L19 9l-5.1 1.9L12 16l-1.9-5.1L5 9l5.1-1.9z" />
51 <path d="M19 14l.9 2.4L22 17l-2.1.6L19 20l-.9-2.4L16 17l2.1-.6z" />
52 </svg>
53 );
54 // Pencil that inherits the tab's color (currentColor) — the kit's AIMark is fixed to the AI accent.
55 const IcPencil = (p) => (
56 <AIcon {...p}>
57 <path d="M12 20h9" />
58 <path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
59 </AIcon>
60 );
61 // Git branch glyph (inherits currentColor) for the "From Git" tab.
62 const IcGit = (p) => (
63 <AIcon {...p}>
64 <circle cx="6" cy="6" r="2.5" />
65 <circle cx="6" cy="18" r="2.5" />
66 <circle cx="18" cy="9" r="2.5" />
67 <path d="M6 8.5v7" />
68 <path d="M18 11.5a6 6 0 0 1-6 6H8.5" />
69 </AIcon>
70 );
71 // Paperclip glyph for the "From Attachment" tab (inherits currentColor).
72 const IcPaperclip = (p) => (
73 <AIcon {...p}>
74 <path d="M21.44 11.05l-9.19 9.19a5 5 0 0 1-7.07-7.07l9.19-9.19a3 3 0 0 1 4.24 4.24l-9.2 9.19a1 1 0 0 1-1.41-1.41l8.49-8.49" />
75 </AIcon>
76 );
77 // Padlock + star — for the "Pro feature" upsell panel (inherit currentColor).
78 const IcLock = (p) => (
79 <AIcon {...p}>
80 <rect x="5" y="11" width="14" height="10" rx="2" />
81 <path d="M8 11V7a4 4 0 0 1 8 0v4" />
82 </AIcon>
83 );
84 const IcProStar = ({ size = 14 }) => (
85 <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" stroke="none">
86 <path d="M12 2l2.6 6.3L21 9l-5 4.1L17.5 21 12 17.3 6.5 21 8 13.1 3 9l6.4-.7z" />
87 </svg>
88 );
89
90 // ── Underline tabs (Prompt | From source) ────────────────────────────────────
91 // Typography, colour and active border-bottom mirror the admin
92 // `.betterdocs-metabox-tabs` (see react-src/admin/scss/unified-metabox.scss):
93 // 14px / 1.5 / weight 500, inactive #d0d5dd, active + hover green (#00b884),
94 // active gets a 2px green bottom border.
95 const Segmented = ({ value, onChange, tabs }) => (
96 <div style={{ display: "inline-flex", gap: 12 }}>
97 {tabs.map((t) => {
98 const active = value === t.id;
99 const idle = "var(--text-color-400, #d0d5dd)";
100 return (
101 <button key={t.id} type="button" onClick={() => onChange(t.id)} style={{
102 display: "inline-flex", alignItems: "center", gap: 7,
103 padding: "4px 4px 13px", border: "none", background: "transparent", cursor: "pointer",
104 borderBottom: `2px solid ${active ? "var(--green)" : "transparent"}`, marginBottom: -1,
105 fontSize: 14, lineHeight: 1.5, fontWeight: 500, whiteSpace: "nowrap",
106 color: active ? "var(--green)" : idle,
107 transition: "color .2s ease, border-color .2s ease",
108 }}
109 onMouseEnter={(e) => { if (!active) e.currentTarget.style.color = "var(--green)"; }}
110 onMouseLeave={(e) => { if (!active) e.currentTarget.style.color = idle; }}>
111 {t.icon}{t.label}
112 </button>
113 );
114 })}
115 </div>
116 );
117
118 // ── Segmented choice control (tone / length) ─────────────────────────────────
119 // Segmented choice control (tone / length) — mirrors .betterdocs-faq-page-tabs:
120 // a white bordered track with a soft shadow; the active segment gets the ash
121 // (--text-color-50) fill + green (--base-color-700) label.
122 const SegControl = ({ value, onChange, options }) => (
123 <div style={{ display: "flex", gap: 2, flex: 1, background: "var(--base-background, #fff)", border: "1px solid var(--text-color-200, #eaecf0)", padding: 4, borderRadius: 8 }}>
124 {options.map((o) => {
125 const active = value === o.value;
126 return (
127 <button key={o.value || "default"} type="button" onClick={() => onChange(o.value)} style={{
128 flex: "1 1 0", minWidth: 0, padding: "7px 6px", borderRadius: 9, border: "none", cursor: "pointer",
129 fontSize: 13, fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
130 background: active ? "var(--text-color-50, #f9fafb)" : "transparent",
131 color: active ? "var(--green)" : "var(--muted)",
132 transition: "background .12s, color .12s",
133 }}>
134 {o.label}
135 </button>
136 );
137 })}
138 </div>
139 );
140
141 // ── Quick prompt chip (From Git) — toggles a starter snippet into the prompt ──
142 const QuickChip = ({ active, onClick, children }) => (
143 <button type="button" onClick={onClick} style={{
144 display: "inline-flex", alignItems: "center", gap: 6, padding: "6px 11px", borderRadius: 999, cursor: "pointer",
145 fontSize: 12.5, fontWeight: 600, whiteSpace: "nowrap",
146 border: `1px solid ${active ? "var(--green)" : "var(--text-color-200, #eaecf0)"}`,
147 background: active ? "rgba(0,184,132,.08)" : "var(--base-background, #fff)",
148 color: active ? "var(--green)" : "var(--ink-2)",
149 transition: "background .12s, border-color .12s, color .12s",
150 }}
151 onMouseEnter={(e) => { if (!active) { e.currentTarget.style.borderColor = "var(--text-color-300, #dde2e4)"; e.currentTarget.style.background = "#f4f6f8"; } }}
152 onMouseLeave={(e) => { if (!active) { e.currentTarget.style.borderColor = "var(--text-color-200, #eaecf0)"; e.currentTarget.style.background = "var(--base-background, #fff)"; } }}>
153 <span style={{ display: "flex" }}>{active ? <IcCheck size={13} sw={2.6} /> : <IcPlus size={13} sw={2.2} />}</span>
154 {children}
155 </button>
156 );
157
158 // ── One row inside the Advanced settings card ────────────────────────────────
159 const SettingRow = ({ label, children }) => (
160 <div style={{ display: "flex", alignItems: "center", gap: 14, padding: "14px 0" }}>
161 <span style={{ width: 62, flexShrink: 0, fontSize: 13, fontWeight: 700, color: "var(--ink-2)" }}>{label}</span>
162 {children}
163 </div>
164 );
165 const ToggleRow = ({ title, subtitle, checked, onChange }) => (
166 <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 14, padding: "14px 0" }}>
167 <div>
168 <div style={{ fontSize: 13.5, fontWeight: 700, color: "var(--ink)" }}>{title}</div>
169 <div style={{ fontSize: 12.5, color: "var(--muted)", marginTop: 2 }}>{subtitle}</div>
170 </div>
171 <Toggle checked={checked} onChange={onChange} />
172 </div>
173 );
174
175 // ── Read-only platform + model indicator ─────────────────────────────────────
176 const ModelPill = ({ platform, model }) => {
177 const label = [platform, model].filter(Boolean).join(" · ");
178 if (!label) return null;
179 return (
180 <span title={__("AI platform & model configured in BetterDocs → Settings → AI", "betterdocs")} style={{
181 display: "inline-flex", alignItems: "center", gap: 6,
182 padding: "6px 11px", borderRadius: 999, background: "var(--text-color-50, #f9fafb)",
183 border: "1px solid var(--border)", fontSize: 12, fontWeight: 700, color: "var(--ink-2)", whiteSpace: "nowrap",
184 }}>
185 <span style={{ width: 7, height: 7, borderRadius: "50%", background: "var(--green)" }} />
186 {label}
187 </span>
188 );
189 };
190
191 // ── Token meter — markup built but hidden for now (design-complete, inactive) ──
192 const TokenMeter = ({ estimated = 0, max = 4000, hidden = true }) => {
193 const pct = Math.min(100, Math.round((estimated / max) * 100));
194 return (
195 <div data-bd-token-meter style={{ display: hidden ? "none" : "flex", alignItems: "center", gap: 8 }}>
196 <span style={{ fontSize: 11.5, fontWeight: 700, color: "var(--muted-2)", whiteSpace: "nowrap" }}>
197 {__("Tokens", "betterdocs")}
198 </span>
199 <span style={{ position: "relative", width: 90, height: 6, borderRadius: 999, background: "#eef0f3", overflow: "hidden" }}>
200 <span style={{ position: "absolute", inset: 0, width: `${pct}%`, background: "var(--ai)", borderRadius: 999 }} />
201 </span>
202 <span style={{ fontSize: 11.5, fontWeight: 600, color: "var(--muted)", whiteSpace: "nowrap" }}>
203 ~{estimated}/{max}
204 </span>
205 </div>
206 );
207 };
208
209 // ── Editable outline (H2/H3, drag to reorder, inline edit, add/remove) ────────
210 const OutlineEditor = ({ items, setItems, onExpand, onRegenerate, onDiscard }) => {
211 const [dragId, setDragId] = useState(null);
212 const [overId, setOverId] = useState(null);
213
214 const move = (from, to) => {
215 setItems((list) => {
216 const next = [...list];
217 const [m] = next.splice(from, 1);
218 next.splice(to, 0, m);
219 return next;
220 });
221 };
222 const onDrop = (targetId) => {
223 const from = items.findIndex((i) => i.id === dragId);
224 const to = items.findIndex((i) => i.id === targetId);
225 if (from > -1 && to > -1 && from !== to) move(from, to);
226 setDragId(null); setOverId(null);
227 };
228
229 return (
230 <div>
231 <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
232 <AIPill>{__("AI outline", "betterdocs")}</AIPill>
233 <span style={{ fontSize: 12, color: "var(--muted)", fontWeight: 600 }}>
234 {sprintf(__("%d sections · drag to reorder", "betterdocs"), items.length)}
235 </span>
236 </div>
237
238 <div style={{ border: "1px solid var(--border)", borderRadius: 12, padding: 8, maxHeight: 340, overflowY: "auto" }}>
239 {items.map((it) => {
240 const isH3 = it.level === "h3";
241 return (
242 <div key={it.id}
243 draggable
244 onDragStart={() => setDragId(it.id)}
245 onDragOver={(e) => { e.preventDefault(); setOverId(it.id); }}
246 onDrop={() => onDrop(it.id)}
247 onDragEnd={() => { setDragId(null); setOverId(null); }}
248 style={{
249 display: "flex", alignItems: "center", gap: 8,
250 padding: "7px 8px", marginLeft: isH3 ? 26 : 0, borderRadius: 9,
251 background: overId === it.id && dragId !== it.id ? "var(--text-color-100, #f0f0f0)" : dragId === it.id ? "#f4f6f8" : "transparent",
252 opacity: dragId === it.id ? 0.5 : 1, transition: "background .12s",
253 }}>
254 <span style={{ color: "#b6bdc7", cursor: "grab", display: "flex", flexShrink: 0 }}><IcGrip size={14} /></span>
255 <button type="button"
256 onClick={() => setItems((list) => list.map((x) => x.id === it.id ? { ...x, level: x.level === "h2" ? "h3" : "h2" } : x))}
257 title={__("Toggle heading level", "betterdocs")}
258 style={{
259 flexShrink: 0, width: 32, height: 24, borderRadius: 6, cursor: "pointer",
260 fontSize: 11, fontWeight: 800, letterSpacing: 0.3,
261 background: isH3 ? "var(--text-color-50, #f9fafb)" : "var(--text-color-100, #f0f0f0)",
262 color: isH3 ? "var(--muted)" : "var(--text-color-700, #344054)",
263 border: `1px solid ${isH3 ? "var(--border-soft)" : "var(--text-color-300, #dde2e4)"}`,
264 }}>
265 {isH3 ? "H3" : "H2"}
266 </button>
267 <input type="text"
268 value={it.text}
269 onChange={(e) => setItems((list) => list.map((x) => x.id === it.id ? { ...x, text: e.target.value } : x))}
270 style={{
271 flex: 1, border: "1px solid transparent", background: "transparent",
272 borderRadius: 7, padding: "6px 8px", outline: "none",
273 fontSize: isH3 ? 13.5 : 14, fontWeight: isH3 ? 500 : 600, color: "var(--ink)",
274 transition: "background .12s, border-color .12s",
275 }}
276 onFocus={(e) => { e.target.style.background = "white"; e.target.style.borderColor = "var(--border)"; }}
277 onBlur={(e) => { e.target.style.background = "transparent"; e.target.style.borderColor = "transparent"; }} />
278 <button type="button" onClick={() => setItems((list) => list.filter((x) => x.id !== it.id))}
279 title={__("Remove", "betterdocs")} style={{
280 width: 28, height: 28, borderRadius: 7, color: "#b6bdc7", flexShrink: 0, border: "none",
281 background: "transparent", cursor: "pointer",
282 display: "flex", alignItems: "center", justifyContent: "center",
283 }}
284 onMouseEnter={(e) => { e.currentTarget.style.background = "#fef2f2"; e.currentTarget.style.color = "#dc2626"; }}
285 onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; e.currentTarget.style.color = "#b6bdc7"; }}>
286 <IcTrash size={14} />
287 </button>
288 </div>
289 );
290 })}
291
292 <button type="button"
293 onClick={() => setItems((list) => [...list, { id: `new-${Date.now()}`, level: "h2", text: __("New section", "betterdocs") }])}
294 style={{
295 display: "flex", alignItems: "center", gap: 8, width: "100%", border: "none",
296 padding: "9px 10px", marginTop: 2, borderRadius: 9, background: "transparent", cursor: "pointer",
297 color: "var(--muted)", fontSize: 13.5, fontWeight: 600, textAlign: "left",
298 transition: "background .12s, color .12s",
299 }}
300 onMouseEnter={(e) => { e.currentTarget.style.background = "#f4f6f8"; e.currentTarget.style.color = "var(--ink-2)"; }}
301 onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; e.currentTarget.style.color = "var(--muted)"; }}>
302 <IcPlus size={15} sw={2.2} /> {__("Add section", "betterdocs")}
303 </button>
304 </div>
305
306 <div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 18 }}>
307 <TextBtn onClick={onDiscard} style={{ marginRight: "auto" }}>{__("Discard", "betterdocs")}</TextBtn>
308 <GhostBtn icon={<IcRefresh size={15} />} onClick={onRegenerate}>{__("Regenerate outline", "betterdocs")}</GhostBtn>
309 <PrimaryBtn icon={<IcArrowR size={16} sw={2.2} />} onClick={onExpand} disabled={items.length === 0}>{__("Expand into Doc", "betterdocs")}</PrimaryBtn>
310 </div>
311 </div>
312 );
313 };
314
315 const backBtn = {
316 width: 34, height: 34, borderRadius: 9, flexShrink: 0, border: "none", cursor: "pointer",
317 display: "flex", alignItems: "center", justifyContent: "center",
318 color: "var(--muted)", background: "#f4f6f8",
319 };
320
321 const SOURCE_TYPES = [
322 { value: "", label: __("Auto-detect", "betterdocs") },
323 { value: "transcript", label: __("Support transcript", "betterdocs") },
324 { value: "forum", label: __("Forum thread", "betterdocs") },
325 { value: "notes", label: __("Raw notes", "betterdocs") },
326 ];
327
328 const SOURCE_PLACEHOLDERS = {
329 "": __("Paste any existing content — a support chat, forum thread, or rough notes. BetterDocs AI will turn it into a structured doc.", "betterdocs"),
330 transcript: __("Paste the full support conversation (chat or email). BetterDocs AI will turn it into a structured doc.", "betterdocs"),
331 forum: __("Paste the forum thread, including replies. BetterDocs AI will turn it into a structured doc.", "betterdocs"),
332 notes: __("Paste your rough notes or bullet points. BetterDocs AI will expand them into a structured doc.", "betterdocs"),
333 };
334
335 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");
336 const UPGRADE_URL = "https://betterdocs.co/upgrade";
337
338 // From Git input modes: paste a URL, or browse the connected account's repos.
339 // ── Attachment kinds ─────────────────────────────────────────────────────────
340 //
341 // Recordings take a different route through the modal than every other
342 // attachment: they are transcribed first, the author corrects the transcript,
343 // and only then is the doc generated from that text. The extension list comes
344 // from the server (REST\WriteWithAI::media_exts) so the two can never drift.
345 const MEDIA_EXTS_FALLBACK = [
346 "mp3", "mpga", "m4a", "wav", "flac", "ogg", "oga", "mp4", "mpeg", "mpg", "webm",
347 ];
348
349 // Read lazily, not at module scope: the bundle can evaluate before
350 // wp_localize_script has run on some screens, and a snapshot taken then would
351 // freeze the fallback list even once the real config arrives.
352 const mediaExts = () => getConfig().media_exts || MEDIA_EXTS_FALLBACK;
353
354 const extOf = (name) => String(name || "").split(".").pop().toLowerCase();
355
356 const isRecording = (f) => !!f && mediaExts().indexOf(extOf(f.name)) !== -1;
357
358 // Accept attribute for the file input, built from the same list.
359 const acceptAttr = () =>
360 [".pdf", ".docx", ".txt", ".md", ".markdown", ".png", ".jpg", ".jpeg", ".webp"]
361 .concat(mediaExts().map((e) => `.${e}`))
362 .join(",");
363
364 // wp_localize_script stringifies top-level scalars, so every number here
365 // arrives as "26214400" and every boolean as "1" or "". Coerce explicitly
366 // rather than leaning on JS comparison rules — "0" is truthy, and a silently
367 // truthy "can transcribe" flag would offer a button that always fails.
368 const num = (value, fallback) => {
369 const n = parseInt(value, 10);
370 return isNaN(n) || n <= 0 ? fallback : n;
371 };
372
373 const flag = (value) => !!value && "0" !== String(value);
374
375 // The server cap, already clamped to what the host will accept — a site with
376 // upload_max_filesize = 8M advertises 8 MB here, not our 25.
377 const capFor = (f) => {
378 const cfg = getConfig();
379 return isRecording(f)
380 ? num(cfg.max_media_bytes, 25165824)
381 : num(cfg.max_upload_bytes, 5242880);
382 };
383
384 const asMB = (bytes) => `${Math.round((bytes / 1048576) * 10) / 10} MB`;
385
386 const GIT_MODES = [
387 { value: "url", label: __("Paste URL", "betterdocs") },
388 { value: "browse", label: __("Browse repository", "betterdocs") },
389 ];
390
391 // Quick prompt starters for the From Git tab. One click appends the snippet to
392 // the additional-prompt field (click again to remove) — they replace the old
393 // framing dropdown, so the AI reads the intent straight from the prompt text.
394 const QUICK_PROMPTS = [
395 { id: "feature", label: __("Document this feature / PR", "betterdocs"),
396 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") },
397 { id: "howto", label: __("Turn into a how-to guide", "betterdocs"),
398 text: __("Turn this into a concise, numbered step-by-step how-to guide.", "betterdocs") },
399 { id: "whatsnew", label: __("Write a what's-new doc", "betterdocs"),
400 text: __("Write a \"What's New\" announcement-style doc that highlights the changes and explains why they matter to users.", "betterdocs") },
401 { id: "adapt", label: __("Adapt this doc to my site", "betterdocs"),
402 text: __("Rewrite this as a fresh doc for my site, keeping the meaning but improving the clarity, structure, and tone.", "betterdocs") },
403 ];
404
405 const withId = (outline) =>
406 (outline || []).map((o, i) => ({ id: `o-${i}`, level: o.level === "h3" ? "h3" : "h2", text: String(o.text || "") }));
407
408 // Taxonomies offered in the preview-step term suggestions (order = display order).
409 // Slugs double as the canonical post-attribute key (doc_category/doc_tag have no
410 // rest_base), matching the native Docs AI Suite panel.
411 const SUGGEST_TAXONOMIES = [
412 { taxonomy: "doc_category", label: __("Categories", "betterdocs") },
413 { taxonomy: "doc_tag", label: __("Tags", "betterdocs") },
414 ];
415
416 // ── The modal ─────────────────────────────────────────────────────────────────
417 const StudioGenerateModal = ({ open, onClose }) => {
418 const config = getConfig();
419 const hasKey = !!config.has_key;
420 const languageOptions = Array.isArray(config.language_options) ? config.language_options : [];
421 const showLanguage = !!config.is_multilingual_active && languageOptions.length > 0;
422 const instructionChoices = Array.isArray(config.instructions) ? config.instructions : [];
423
424 // From Git gating: Pro must be active AND Git Sync enabled + authenticated.
425 const isProActive = !!config.is_pro_active;
426 const git = config.git || {};
427 const gitReady = isProActive && !!git.enabled && !!git.connected;
428 // Site owners can hide the whole "From Git" tab from Settings → Git Sync
429 // (default on). Only applies when Pro is active; without Pro the tab still
430 // shows the upsell.
431 const showGitTab = !isProActive || git.ai_enabled !== false;
432 // Repository browsing is GitHub-only (GitLab connections are single-project);
433 // GitLab users get the Paste-URL mode only.
434 const gitProvider = git.provider || "github";
435 const canBrowse = gitProvider === "github";
436 // Falls back to the Git Sync settings anchor (not the AI settings anchor).
437 const gitSettingsUrl = git.settings_url || "admin.php?page=betterdocs-settings#git-sync";
438
439 const [tab, setTab] = useState("prompt");
440 const [stage, setStage] = useState("form"); // form · loadingOutline · loadingDoc · outline · preview · previewError
441 const [prompt, setPrompt] = useState("");
442 const [source, setSource] = useState("");
443 const [srcType, setSrcType] = useState(SOURCE_TYPES[0].value);
444 const [gitUrl, setGitUrl] = useState("");
445 const [gitPrompt, setGitPrompt] = useState(""); // additional prompt for the From Git tab
446 const [gitMode, setGitMode] = useState("url"); // url · browse
447 const [file, setFile] = useState(null); // selected file for the From Attachment tab
448 const [filePrompt, setFilePrompt] = useState(""); // optional prompt for the From Attachment tab
449 // Recording flow: a transcript sits between the upload and generation so the
450 // author can fix mis-heard product names before the doc is written.
451 const [transcript, setTranscript] = useState("");
452 const [transcribing, setTranscribing] = useState(false);
453 const [fileError, setFileError] = useState("");
454 const [transcriptClipped, setTranscriptClipped] = useState(false);
455 // Keyboard focus on the (visually hidden) file input, drawn on its drop zone.
456 const [pickerFocus, setPickerFocus] = useState(false);
457 // Bumped whenever the file changes or is removed. A transcription answers
458 // only if it is still the latest request — otherwise Remove (or a new pick)
459 // during "Transcribing…" let the old file's transcript land on the new file.
460 const transcribeReq = useRef(0);
461 const [browseUrl, setBrowseUrl] = useState(""); // URL resolved by the browse picker
462 const [lang, setLang] = useState("");
463 const [tone, setTone] = useState("");
464 const [docSize, setDocSize] = useState("any");
465 const [selectedInstructionIds, setSelectedInstructionIds] = useState([]);
466 const [advOpen, setAdvOpen] = useState(false);
467 const [existingTitle, setExistingTitle] = useState("");
468 const [hasContent, setHasContent] = useState(false);
469 const [regenerateTitle, setRegenerateTitle] = useState(true);
470 const [overwrite, setOverwrite] = useState(true);
471 const [genTitle, setGenTitle] = useState("");
472 const [html, setHtml] = useState("");
473 const [outline, setOutline] = useState([]);
474 const [error, setError] = useState(null);
475 // Preview-step term suggestions — one independent on/off control per taxonomy.
476 const [suggestTax, setSuggestTax] = useState({ doc_category: true, doc_tag: true });
477 const [suggestions, setSuggestions] = useState({}); // { [taxonomy]: [{name,term_id,is_new}] }
478 const [suggestLoading, setSuggestLoading] = useState(false);
479 const [selectedKeys, setSelectedKeys] = useState(() => new Set()); // `${taxonomy}::${name}` kept
480
481 // Snapshot the editor's title + content state, and reset, each time it opens.
482 useEffect(() => {
483 if (!open) return;
484 setStage("form");
485 setError(null);
486 setGenTitle("");
487 setOverwrite(true);
488 setRegenerateTitle(true);
489 setSelectedInstructionIds([]);
490 setGitMode("url");
491 setBrowseUrl("");
492 setGitPrompt("");
493 setFile(null);
494 setFilePrompt("");
495 setTranscript("");
496 setTranscribing(false);
497 setFileError("");
498 setTranscriptClipped(false);
499 transcribeReq.current += 1;
500 setSuggestTax({ doc_category: true, doc_tag: true });
501 setSuggestions({});
502 setSelectedKeys(new Set());
503 setSuggestLoading(false);
504 try {
505 const editorTitle = window.wp?.data?.select("core/editor")?.getEditedPostAttribute("title") || "";
506 setExistingTitle(editorTitle);
507 const blocks = window.wp?.data?.select("core/block-editor")?.getBlocks() || [];
508 setHasContent(blocks.length > 0);
509 } catch (e) { /* editor not ready */ }
510 }, [open]);
511
512 // Generate a title when the doc has none, or when the user opted to regenerate.
513 const titleRequested = !existingTitle || regenerateTitle;
514
515 const canPrompt = hasKey && prompt.trim().length > 0;
516 const canSource = hasKey && source.trim().length > 20;
517 // The URL "Generate from Git" sends: pasted, or resolved by the browse picker.
518 const effectiveGitUrl = (gitMode === "browse" ? browseUrl : gitUrl).trim();
519 const canGit = hasKey && gitReady && effectiveGitUrl.length > 8;
520 // A recording needs a transcript before it can generate; every other file
521 // type goes straight through as it always has.
522 const canAttachment = hasKey && !!file && (!isRecording(file) || transcript.trim().length > 0);
523 // On the Git tab without a working connection we show only the upsell/connect
524 // panel — no Guides, Advanced settings, or generate footer.
525 const gitBlocked = tab === "git" && !gitReady;
526 const est = Math.round(((prompt + source).length) / 4) + 200;
527
528 // Append the chosen language to the prompt at send-time (client-side).
529 const langSuffix = () => {
530 if (!showLanguage || !lang) return "";
531 const found = languageOptions.find((o) => String(o.value) === String(lang));
532 const label = found ? found.label : lang;
533 return " " + sprintf(__("Write the documentation in %s.", "betterdocs"), label);
534 };
535
536 // Shared directives sent on every generation: tone, size, and whether the AI
537 // should also produce a title (assembled into the prompt by the backend).
538 const directives = () => ({ tone, doc_size: docSize, generate_title: titleRequested, instruction_ids: selectedInstructionIds });
539
540 // Quick-prompt chips toggle their snippet into the From Git prompt: append if
541 // absent, strip it out (tidying stray blank lines) if already there.
542 const toggleQuickPrompt = (text) => setGitPrompt((prev) =>
543 prev.includes(text)
544 ? prev.split(text).join("").replace(/\n{2,}/g, "\n").trim()
545 : (prev.trim() ? prev.trim() + "\n" + text : text)
546 );
547
548 // Term suggestions are gated by an API key AND the Docs AI Suite setting (the
549 // same gate the REST endpoint enforces). Each taxonomy then has its own toggle.
550 const canSuggest = hasKey && !!config.suggest_terms_enabled;
551
552 // Taxonomies to actually request: whichever per-taxonomy toggles are on.
553 const enabledSuggestTaxes = () => SUGGEST_TAXONOMIES.filter(
554 (t) => suggestTax[t.taxonomy]
555 );
556
557 const toggleTax = (tax) => setSuggestTax((prev) => ({ ...prev, [tax]: !prev[tax] }));
558
559 const toggleKey = (key) => setSelectedKeys((prev) => {
560 const next = new Set(prev);
561 if (next.has(key)) next.delete(key); else next.add(key);
562 return next;
563 });
564
565 // Fetch suggestions for each enabled taxonomy in parallel off the freshly
566 // generated doc.
567 const fetchTermSuggestions = (contentHtml, docTitle) => {
568 const taxes = enabledSuggestTaxes();
569 setSuggestions({});
570 setSelectedKeys(new Set());
571 if (!taxes.length) { setSuggestLoading(false); return; }
572 setSuggestLoading(true);
573 Promise.all(
574 taxes.map((t) =>
575 suggestTerms({ taxonomy: t.taxonomy, content: contentHtml, title: docTitle })
576 .then((list) => [t.taxonomy, list])
577 )
578 )
579 .then((pairs) => {
580 const next = {};
581 pairs.forEach(([tax, list]) => { next[tax] = list || []; });
582 setSuggestions(next);
583 })
584 .finally(() => setSuggestLoading(false));
585 };
586
587 // Apply every kept suggestion (from an enabled taxonomy) to the (unsaved) doc:
588 // create new terms as needed, then assign via editPost keyed by the taxonomy
589 // slug — mirroring the native Docs AI Suite panel so tokens update immediately.
590 const applyKeptTerms = async () => {
591 if (!selectedKeys.size) return;
592 const { saveEntityRecord } = dispatch("core");
593 const editor = dispatch("core/editor");
594 const editorSelect = select("core/editor");
595 for (const t of SUGGEST_TAXONOMIES) {
596 if (!suggestTax[t.taxonomy]) continue;
597 const items = (suggestions[t.taxonomy] || []).filter((s) => selectedKeys.has(`${t.taxonomy}::${s.name}`));
598 if (!items.length) continue;
599 const current = (editorSelect.getEditedPostAttribute(t.taxonomy) || []).map(Number);
600 const toAdd = [];
601 for (const s of items) {
602 let termId = s.term_id;
603 if (!termId) {
604 try {
605 const created = await saveEntityRecord("taxonomy", t.taxonomy, { name: s.name });
606 termId = created && created.id;
607 } catch (e) { continue; }
608 }
609 termId = Number(termId);
610 if (termId && !current.includes(termId) && !toAdd.includes(termId)) toAdd.push(termId);
611 }
612 if (toAdd.length) editor.editPost({ [t.taxonomy]: [...current, ...toAdd] });
613 }
614 };
615
616 const handleDoc = (data) => {
617 const newTitle = titleRequested ? extractFirstHeading(data.content) : "";
618 if (titleRequested) setGenTitle(newTitle);
619 const cleaned = cleanGeneratedHtml(data.content);
620 setHtml(cleaned);
621 setStage("preview");
622 if (canSuggest && enabledSuggestTaxes().length) {
623 fetchTermSuggestions(cleaned, newTitle || existingTitle);
624 }
625 };
626
627 // Refuse an oversize file here rather than uploading it and waiting for the
628 // server to say no — the old flow pushed the whole 50 MB up the wire first.
629 const pickFile = (picked) => {
630 transcribeReq.current += 1;
631 setTranscribing(false);
632 setTranscript("");
633 setTranscriptClipped(false);
634 setFileError("");
635
636 if (!picked) {
637 setFile(null);
638 return;
639 }
640
641 const cap = capFor(picked);
642 if (picked.size > cap) {
643 setFile(null);
644 setFileError(sprintf(
645 /* translators: 1: file name, 2: maximum allowed size. */
646 __("“%1$s” is larger than the %2$s limit. Trim it, or export a smaller version.", "betterdocs"),
647 picked.name,
648 asMB(cap)
649 ));
650 return;
651 }
652
653 setFile(picked);
654 };
655
656 // Step one of the recording flow. Returns the transcript to the panel; no
657 // doc is written and no stage change happens, so the author stays in the form.
658 const transcribeFile = () => {
659 if (!file) return;
660 setError(null);
661 setFileError("");
662 setTranscribing(true);
663
664 const req = ++transcribeReq.current;
665
666 callWriteWithAIMultipart("transcribe-attachment", {}, file)
667 .then((data) => {
668 if (req !== transcribeReq.current) return;
669 setTranscript(String((data && data.transcript) || ""));
670 setTranscriptClipped(!!(data && data.clipped));
671 setTranscribing(false);
672 })
673 .catch((err) => {
674 if (req !== transcribeReq.current) return;
675 setFileError(normalizeAIError(err));
676 setTranscribing(false);
677 });
678 };
679
680 const run = (action) => {
681 setError(null);
682 setStage(action === "generate-outline" ? "loadingOutline" : "loadingDoc");
683
684 // From Attachment posts a multipart request (the file + directives) and
685 // lands on the same doc-preview flow as every other source.
686 if (action === "from-attachment") {
687 // A recording has already become text; generate from the transcript the
688 // author just approved, through the ordinary grounded source path. Only
689 // non-media attachments still need the file itself on the wire.
690 if (isRecording(file)) {
691 callWriteWithAI("from-source", {
692 source: transcript,
693 source_type: "recording",
694 prompt: filePrompt + langSuffix(),
695 ...directives(),
696 })
697 .then(handleDoc)
698 .catch((err) => { setError({ ...(err || {}), message: normalizeAIError(err) }); setStage("previewError"); });
699 return;
700 }
701
702 callWriteWithAIMultipart(action, { prompt: filePrompt + langSuffix(), ...directives() }, file)
703 .then(handleDoc)
704 .catch((err) => { setError({ ...(err || {}), message: normalizeAIError(err) }); setStage("previewError"); });
705 return;
706 }
707
708 let payload;
709 if (action === "from-source") {
710 payload = { source, source_type: srcType, prompt: prompt + langSuffix(), ...directives() };
711 } else if (action === "from-git") {
712 payload = { git_url: effectiveGitUrl, prompt: gitPrompt + langSuffix(), ...directives() };
713 } else {
714 payload = { prompt: prompt + langSuffix(), ...directives() };
715 }
716
717 callWriteWithAI(action, payload)
718 .then((data) => {
719 if (action === "generate-outline") {
720 setOutline(withId(data.outline));
721 setStage("outline");
722 } else {
723 handleDoc(data);
724 }
725 })
726 .catch((err) => {
727 setError({ ...(err || {}), message: normalizeAIError(err) });
728 setStage(action === "generate-outline" ? "form" : "previewError");
729 });
730 };
731
732 const expand = () => {
733 setError(null);
734 setStage("loadingDoc");
735 callWriteWithAI("expand-outline", {
736 outline: outline.map((o) => ({ level: o.level, text: o.text })),
737 prompt: prompt + langSuffix(),
738 ...directives(),
739 })
740 .then(handleDoc)
741 .catch((err) => { setError({ ...(err || {}), message: normalizeAIError(err) }); setStage("previewError"); });
742 };
743
744 const keepAndInsert = async () => {
745 await applyKeptTerms();
746 insertContentToEditor({ title: titleRequested ? genTitle : "", html, isOverwrite: overwrite });
747 onClose();
748 };
749
750 const regenerateDoc = () => run(tab === "source" ? "from-source" : tab === "git" ? "from-git" : tab === "attachment" ? "from-attachment" : "generate-doc");
751 const showBack = stage === "outline" || stage === "preview" || stage === "previewError";
752
753 // Live summary shown on the collapsed "Advanced settings" bar.
754 const toneLabel = (TONE_OPTIONS.find((o) => o.value === tone) || TONE_OPTIONS[0]).label;
755 const sizeLabel = "any" === docSize
756 ? __("Any size", "betterdocs")
757 : (SIZE_OPTIONS.find((o) => o.value === docSize) || SIZE_OPTIONS[0]).label;
758 const enabledCount = (existingTitle && regenerateTitle ? 1 : 0) + (hasContent && overwrite ? 1 : 0) + (canSuggest ? enabledSuggestTaxes().length : 0);
759 /* translators: 1: tone label, 2: length label, 3: number of enabled toggles. */
760 const advSummary = sprintf(__("%1$s · %2$s · %3$d on", "betterdocs"), toneLabel, sizeLabel, enabledCount);
761
762 // Settings rows are conditional; build the list, then render with dividers.
763 const settingRows = [
764 <SettingRow key="tone" label={__("Tone", "betterdocs")}>
765 <SegControl value={tone} onChange={setTone} options={TONE_OPTIONS} />
766 </SettingRow>,
767 <SettingRow key="length" label={__("Length", "betterdocs")}>
768 <SegControl value={docSize} onChange={setDocSize} options={SIZE_OPTIONS} />
769 </SettingRow>,
770 ];
771 if (showLanguage) {
772 settingRows.push(
773 <SettingRow key="lang" label={__("Language", "betterdocs")}>
774 <DropdownSelect value={lang} onChange={setLang}
775 options={[{ value: "", label: __("Default (Site Language)", "betterdocs") }, ...languageOptions]}
776 style={{ flex: 1 }} />
777 </SettingRow>
778 );
779 }
780 if (existingTitle) {
781 settingRows.push(
782 <ToggleRow key="title" checked={regenerateTitle} onChange={setRegenerateTitle}
783 title={__("Regenerate title", "betterdocs")} subtitle={__("Let AI rewrite the document title", "betterdocs")} />
784 );
785 }
786 if (hasContent) {
787 settingRows.push(
788 <ToggleRow key="overwrite" checked={overwrite} onChange={setOverwrite}
789 title={__("Overwrite existing doc", "betterdocs")} subtitle={__("Replace current content instead of appending", "betterdocs")} />
790 );
791 }
792 if (canSuggest) {
793 settingRows.push(
794 <ToggleRow key="suggest-cat" checked={suggestTax.doc_category} onChange={() => toggleTax("doc_category")}
795 title={__("Suggest categories", "betterdocs")}
796 subtitle={__("Suggest doc categories after generating", "betterdocs")} />,
797 <ToggleRow key="suggest-tag" checked={suggestTax.doc_tag} onChange={() => toggleTax("doc_tag")}
798 title={__("Suggest tags", "betterdocs")}
799 subtitle={__("Suggest doc tags after generating", "betterdocs")} />
800 );
801 }
802
803 return (
804 <Modal open={open} onClose={onClose} width={720} labelledBy="bd-wd-title">
805 {/* Header */}
806 <div style={{ padding: "22px 26px 0" }}>
807 <div style={{ display: "flex", alignItems: "center", gap: 13 }}>
808 {showBack ? (
809 <button type="button" onClick={() => setStage("form")} title={__("Back", "betterdocs")} style={backBtn}>
810 <AIcon d="M15 18l-6-6 6-6" size={17} sw={2} />
811 </button>
812 ) : (
813 <div style={{
814 width: 46, height: 46, borderRadius: 13, flexShrink: 0, background: "var(--green)",
815 display: "flex", alignItems: "center", justifyContent: "center",
816 boxShadow: "0 2px 6px rgba(0,184,132,.35)",
817 }}>
818 <AIMarkWhite size={22} />
819 </div>
820 )}
821 <div style={{ flex: 1, minWidth: 0 }}>
822 <div id="bd-wd-title" style={{ fontSize: 19, fontWeight: 800, color: "var(--ink)", letterSpacing: -0.3, paddingRight: 30 }}>
823 {__("Write Documentation", "betterdocs")}
824 </div>
825 {!showBack && (
826 <div style={{ fontSize: 13, color: "var(--muted)", marginTop: 2 }}>
827 {__("Draft a doc with BetterDocs AI", "betterdocs")}
828 </div>
829 )}
830 </div>
831 </div>
832 </div>
833
834 {/* Tabs + model on one line. The modal width (720) is sized so the
835 four source tabs and the "Platform · Model" pill share a single
836 row with room to spare — at 640 the pill overflowed and clipped. */}
837 {!showBack && stage === "form" && (
838 <div style={{ padding: "18px 26px 0px", borderBottom: "1px solid var(--border-soft)", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
839 <Segmented value={tab} onChange={setTab} tabs={[
840 { id: "prompt", label: __("Prompt", "betterdocs"), icon: <IcPencil size={15} /> },
841 { id: "source", label: __("From source", "betterdocs"), icon: <IcSummary size={15} /> },
842 ...(showGitTab ? [{ id: "git", label: __("From Git", "betterdocs"), icon: <IcGit size={15} /> }] : []),
843 { id: "attachment", label: __("From Attachment", "betterdocs"), icon: <IcPaperclip size={15} /> },
844 ]} />
845 {/* Lift the pill to align with the tab labels, not the bottom
846 border — the tabs carry 13px bottom padding, so a centred
847 pill otherwise sits low against the divider line. */}
848 <div style={{ marginBottom: 10 }}>
849 <ModelPill platform={config.platform_label} model={config.model_label || config.model} />
850 </div>
851 </div>
852 )}
853
854 {/* Body — the 320px floor keeps the form/loading/preview steps a
855 comfortable size, but the error step's content is tiny, so we let
856 it size to content (no forced floor) and the footer sits at the
857 modal bottom instead of floating above a large empty gap. */}
858 <div style={{ padding: "16px 26px 22px", overflowY: "auto", minHeight: stage === "previewError" ? undefined : 320 }}>
859 {stage === "form" && (
860 <>
861 {!hasKey && (
862 <Banner tone="error" style={{ marginBottom: 16 }}>
863 {__("Please insert your", "betterdocs")}{" "}
864 <a href={config.settings_url || "#"} target="_blank" rel="noreferrer" style={{ color: "var(--wp-blue)", fontWeight: 700, textDecoration: "none" }}>
865 {__("OpenAI API Key", "betterdocs")}
866 </a>{" "}
867 {__("to use this Write with AI feature.", "betterdocs")}
868 </Banner>
869 )}
870 {hasKey && error && (
871 <Banner tone="error" style={{ marginBottom: 16 }}>
872 <strong style={{ fontWeight: 700 }}>{ERR_LABEL[error.code] || __("Couldn't generate", "betterdocs")}.</strong> {error.message}
873 </Banner>
874 )}
875
876 {tab === "prompt" && (
877 <>
878 <FieldLabel>{__("What should this documentation cover?", "betterdocs")}</FieldLabel>
879 <TextArea value={prompt} onChange={setPrompt} rows={5}
880 placeholder={__("Describe what you want the documentation to cover — e.g. \"A getting-started guide for installing and configuring the plugin.\" BetterDocs AI handles the structure and formatting.", "betterdocs")} />
881 <div style={{ fontSize: 12.5, color: "var(--muted)", marginTop: 8, lineHeight: 1.5 }}>
882 {__("The clearer your request, the better the result. A title and formatting are added automatically.", "betterdocs")}
883 </div>
884 </>
885 )}
886
887 {tab === "source" && (
888 <>
889 <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, marginBottom: 10 }}>
890 <FieldLabel style={{ margin: 0 }}>{__("Source content:", "betterdocs")}</FieldLabel>
891 <DropdownSelect value={srcType} onChange={setSrcType} options={SOURCE_TYPES} style={{ width: 200 }} />
892 </div>
893 <TextArea value={source} onChange={setSource} rows={7}
894 placeholder={SOURCE_PLACEHOLDERS[srcType] || SOURCE_PLACEHOLDERS[""]} />
895 <div style={{ fontSize: 12.5, color: "var(--muted)", marginTop: 8, lineHeight: 1.5 }}>
896 {__("BetterDocs AI uses only what's in your source — it won't invent details.", "betterdocs")}
897 </div>
898 </>
899 )}
900
901 {tab === "git" && !isProActive && (
902 /* State 1 — Pro not active: upgrade panel */
903 <div style={{ textAlign: "center", padding: "16px 8px 6px" }}>
904 {/* Lock tile */}
905 <div style={{
906 width: 56, height: 56, borderRadius: 15, margin: "0 auto",
907 background: "#fff7e6", color: "#f0a615",
908 display: "flex", alignItems: "center", justifyContent: "center",
909 }}>
910 <IcLock size={26} sw={1.9} />
911 </div>
912 {/* Pro eyebrow */}
913 <div style={{ display: "flex", justifyContent: "center", marginTop: 14 }}>
914 <span style={{ display: "inline-flex", alignItems: "center", gap: 5, color: "#f0a615", fontSize: 13.5, fontWeight: 800 }}>
915 <IcProStar size={14} />{__("Pro", "betterdocs")}
916 </span>
917 </div>
918 {/* Headline */}
919 <div style={{ fontSize: 18, fontWeight: 800, color: "var(--ink)", marginTop: 10, letterSpacing: -0.3 }}>
920 {__("Writing from Git is a Pro feature", "betterdocs")}
921 </div>
922 {/* Description */}
923 <div style={{ fontSize: 13.5, color: "var(--muted)", marginTop: 8, lineHeight: 1.55, maxWidth: 400, marginLeft: "auto", marginRight: "auto" }}>
924 {__("Paste a pull request or repo file URL and let BetterDocs AI turn what you already shipped in Git into a polished doc.", "betterdocs")}
925 </div>
926 {/* Feature list */}
927 <div style={{ display: "inline-flex", flexDirection: "column", gap: 12, textAlign: "left", marginTop: 22 }}>
928 {[
929 __("Generate a doc from a Pull Request", "betterdocs"),
930 __("Turn a repository file into a new doc", "betterdocs"),
931 __("Works with GitHub & GitLab via Git Sync", "betterdocs"),
932 ].map((feature, i) => (
933 <div key={i} style={{ display: "flex", alignItems: "center", gap: 10, fontSize: 13.5, color: "var(--ink-2)" }}>
934 <span style={{ color: "var(--green)", display: "flex", flexShrink: 0 }}><IcCheck size={17} sw={2.4} /></span>
935 {feature}
936 </div>
937 ))}
938 </div>
939 {/* CTA */}
940 <div style={{ display: "flex", justifyContent: "center", marginTop: 24 }}>
941 <PrimaryBtn onClick={() => window.open(UPGRADE_URL, "_blank", "noopener")}>
942 {__("Upgrade Now", "betterdocs")}<IcArrowR size={16} sw={2.2} />
943 </PrimaryBtn>
944 </div>
945 </div>
946 )}
947
948 {tab === "git" && isProActive && !gitReady && (
949 /* State 2 — Pro active but Git Sync not enabled/connected */
950 <Banner tone="warn">
951 {__("Enable and connect", "betterdocs")}{" "}
952 <a href={gitSettingsUrl} target="_blank" rel="noreferrer" style={{ color: "var(--wp-blue)", fontWeight: 700, textDecoration: "none" }}>
953 {__("BetterDocs Git Sync", "betterdocs")}
954 </a>{" "}
955 {__("to generate documentation from a repository.", "betterdocs")}
956 </Banner>
957 )}
958
959 {tab === "git" && gitReady && (
960 /* State 3 — connected: the From Git form */
961 <>
962 {/* Source mode: Paste URL | Browse repository */}
963 <div style={{ marginBottom: 14 }}>
964 {canBrowse
965 ? <SegControl value={gitMode} onChange={setGitMode} options={GIT_MODES} />
966 : <FieldLabel style={{ margin: 0 }}>{__("Git URL:", "betterdocs")}</FieldLabel>}
967 </div>
968
969 {(!canBrowse || gitMode === "url") && (
970 <>
971 <TextInput value={gitUrl} onChange={setGitUrl} placeholder={GIT_PLACEHOLDER} />
972 <div style={{ fontSize: 12.5, color: "var(--muted)", marginTop: 8, lineHeight: 1.5 }}>
973 {__("BetterDocs AI fetches the content over your connected Git account and writes from it — it won't invent details.", "betterdocs")}
974 </div>
975 </>
976 )}
977
978 {canBrowse && gitMode === "browse" && (
979 <GitBrowse onSelect={setBrowseUrl} settingsUrl={gitSettingsUrl} />
980 )}
981
982 {/* Additional prompt + quick starters — layered on the Git source in both modes */}
983 <div style={{ marginTop: 16 }}>
984 <FieldLabel>{__("Add a prompt (optional)", "betterdocs")}</FieldLabel>
985 <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 10 }}>
986 {QUICK_PROMPTS.map((q) => (
987 <QuickChip key={q.id} active={gitPrompt.includes(q.text)} onClick={() => toggleQuickPrompt(q.text)}>
988 {q.label}
989 </QuickChip>
990 ))}
991 </div>
992 <TextArea value={gitPrompt} onChange={setGitPrompt} rows={3}
993 placeholder={__("Add extra instructions for this doc, or tap a suggestion above — e.g. \"Focus on the user-facing steps and keep it beginner-friendly.\"", "betterdocs")} />
994 </div>
995 </>
996 )}
997
998 {tab === "attachment" && (
999 <>
1000 <FieldLabel>{__("Upload a file to write from:", "betterdocs")}</FieldLabel>
1001 <label style={{
1002 position: "relative",
1003 display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
1004 gap: 6, padding: "26px 16px", textAlign: "center", cursor: "pointer",
1005 borderRadius: 10, border: "1.5px dashed var(--text-color-200, #d0d5dd)",
1006 background: "var(--base-background, #fff)",
1007 outline: pickerFocus ? "2px solid var(--wp-admin-theme-color, #2271b1)" : "none",
1008 outlineOffset: 2,
1009 }}>
1010 {/* Visually hidden rather than display:none, which took the
1011 input out of the tab order — the picker could not be
1012 reached from the keyboard at all. */}
1013 <input type="file" accept={acceptAttr()}
1014 style={{ position: "absolute", width: 1, height: 1, padding: 0, margin: -1, overflow: "hidden", clip: "rect(0 0 0 0)", whiteSpace: "nowrap", border: 0, opacity: 0 }}
1015 onFocus={(e) => { let visible = true; try { visible = e.target.matches(":focus-visible"); } catch (x) { /* older engines */ } setPickerFocus(visible); }}
1016 onBlur={() => setPickerFocus(false)}
1017 onChange={(e) => {
1018 pickFile(e.target.files && e.target.files[0] ? e.target.files[0] : null);
1019 // Cleared so that picking the same file again — after
1020 // Remove — still fires a change.
1021 e.target.value = "";
1022 }} />
1023 <span style={{ display: "flex", color: "var(--green)" }}><IcPaperclip size={22} /></span>
1024 {file
1025 ? <span style={{ fontSize: 14, fontWeight: 700, color: "var(--ink)", wordBreak: "break-all" }}>{file.name}</span>
1026 : <span style={{ fontSize: 14, fontWeight: 700, color: "var(--ink-2)" }}>{__("Click to choose a file", "betterdocs")}</span>}
1027 <span style={{ fontSize: 12.5, color: "var(--muted)" }}>
1028 {sprintf(
1029 /* translators: 1: document/image size limit, 2: recording size limit. */
1030 __("PDF, DOCX, TXT, MD or an image up to %1$s — or a recording (MP3, M4A, WAV, FLAC, OGG, MP4, MPEG, WEBM) up to %2$s", "betterdocs"),
1031 asMB(num(config.max_upload_bytes, 5242880)),
1032 asMB(num(config.max_media_bytes, 25165824))
1033 )}
1034 </span>
1035 </label>
1036 {fileError && (
1037 <div style={{ marginTop: 8 }}><Banner tone="error">{fileError}</Banner></div>
1038 )}
1039 {file && (
1040 <div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 8 }}>
1041 <span style={{ fontSize: 12.5, color: "var(--muted)" }}>{`${Math.max(1, Math.round(file.size / 1024))} KB`}</span>
1042 <TextBtn onClick={() => { transcribeReq.current += 1; setTranscribing(false); setFile(null); setTranscript(""); setTranscriptClipped(false); setFileError(""); }}>{__("Remove", "betterdocs")}</TextBtn>
1043 </div>
1044 )}
1045
1046 {/* Recording → transcribe, correct, then generate. The transcript is
1047 shown because speech-to-text mangles product names, and a wrong
1048 term here becomes a wrong term in the published doc. */}
1049 {file && isRecording(file) && (
1050 <div style={{ marginTop: 14 }}>
1051 {!transcript && (
1052 <>
1053 <GhostBtn
1054 onClick={transcribeFile}
1055 disabled={!hasKey || transcribing || !flag(config.supports_transcription)}
1056 >
1057 {transcribing
1058 ? __("Transcribing your recording…", "betterdocs")
1059 : __("Transcribe recording", "betterdocs")}
1060 </GhostBtn>
1061 {!flag(config.supports_transcription) && (
1062 <div style={{ marginTop: 8 }}>
1063 <Banner tone="warn">
1064 {sprintf(
1065 /* translators: %s: the configured AI platform label. */
1066 __("%s can't read audio or video. Switch to OpenAI or Google Gemini in Settings → AI Content Suite.", "betterdocs"),
1067 config.platform_label || config.platform || ""
1068 )}
1069 </Banner>
1070 </div>
1071 )}
1072 </>
1073 )}
1074 {transcript && (
1075 <>
1076 <FieldLabel>{__("Transcript — correct anything misheard, then generate:", "betterdocs")}</FieldLabel>
1077 <TextArea value={transcript} onChange={setTranscript} rows={8} />
1078 {transcriptClipped && (
1079 <div style={{ marginTop: 8 }}>
1080 <Banner tone="warn">
1081 {__("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")}
1082 </Banner>
1083 </div>
1084 )}
1085 <div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 8 }}>
1086 <span style={{ fontSize: 12.5, color: "var(--muted)" }}>
1087 {sprintf(
1088 /* translators: %d: character count of the transcript. */
1089 __("%d characters", "betterdocs"),
1090 transcript.length
1091 )}
1092 </span>
1093 <TextBtn onClick={transcribeFile}>{__("Transcribe again", "betterdocs")}</TextBtn>
1094 </div>
1095 </>
1096 )}
1097 </div>
1098 )}
1099 <div style={{ marginTop: 16 }}>
1100 <FieldLabel>{__("Add a prompt (optional)", "betterdocs")}</FieldLabel>
1101 <TextArea value={filePrompt} onChange={setFilePrompt} rows={3}
1102 placeholder={__("Optional: how should BetterDocs AI treat this file? — e.g. \"Turn this into a step-by-step how-to guide.\"", "betterdocs")} />
1103 </div>
1104 <div style={{ fontSize: 12.5, color: "var(--muted)", marginTop: 8, lineHeight: 1.5 }}>
1105 {file && isRecording(file)
1106 ? __("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")
1107 : __("BetterDocs AI reads the file's text and writes only from its content — it won't invent details.", "betterdocs")}
1108 </div>
1109 </>
1110 )}
1111
1112 {/* Guides — pick saved instruction sets to layer on the base prompt */}
1113 {!gitBlocked && instructionChoices.length > 0 && (
1114 <div style={{ marginTop: 18 }}>
1115 <InstructionPicker
1116 instructions={instructionChoices}
1117 selected={selectedInstructionIds}
1118 onChange={setSelectedInstructionIds}
1119 />
1120 </div>
1121 )}
1122
1123 {/* Advanced settings — collapsible (tone · length · language · toggles) */}
1124 {!gitBlocked && (
1125 <div style={{ marginTop: 18 }}>
1126 <button type="button" onClick={() => setAdvOpen((v) => !v)} aria-expanded={advOpen} style={{
1127 width: "100%", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12,
1128 padding: "11px 14px", borderRadius: 8, cursor: "pointer",
1129 background: "var(--base-background, #fff)",
1130 border: "1px solid var(--text-color-200, #eaecf0)",
1131 transition: "background .12s, border-color .12s",
1132 }}>
1133 <span style={{ display: "inline-flex", alignItems: "center", gap: 9, fontSize: 13.5, fontWeight: 700, color: "var(--ink-2)" }}>
1134 <IcSliders size={16} />{__("Advanced settings", "betterdocs")}
1135 </span>
1136 <span style={{ display: "inline-flex", alignItems: "center", gap: 10 }}>
1137 <span style={{ fontSize: 12.5, color: "var(--muted)", fontWeight: 600 }}>{advSummary}</span>
1138 <span style={{ display: "flex", color: "var(--muted)", transform: advOpen ? "rotate(180deg)" : "none", transition: "transform .15s" }}>
1139 <IcChevDown size={16} />
1140 </span>
1141 </span>
1142 </button>
1143
1144 {advOpen && (
1145 <div style={{ marginTop: 10, background: "var(--base-background, #fff)", border: "1px solid var(--text-color-200, #eaecf0)", borderRadius: 8, padding: "2px 16px" }}>
1146 {settingRows.map((row, i) => (
1147 <div key={row.key} style={{ borderTop: i ? "1px solid var(--border-soft)" : "none" }}>{row}</div>
1148 ))}
1149 </div>
1150 )}
1151 </div>
1152 )}
1153
1154 {/* footer */}
1155 {!gitBlocked && (
1156 <div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 20, paddingTop: 16, borderTop: "1px solid var(--border-soft)" }}>
1157 <div style={{ marginRight: "auto" }}><TokenMeter estimated={est} max={config.max_token || 4000} hidden /></div>
1158 {tab === "prompt" && (
1159 <>
1160 <GhostBtn icon={<IcList size={16} />} onClick={() => run("generate-outline")} disabled={!canPrompt}>{__("Generate Outline", "betterdocs")}</GhostBtn>
1161 <PrimaryBtn icon={<IcSpark size={16} />} onClick={() => run("generate-doc")} disabled={!canPrompt}>{__("Generate Doc", "betterdocs")}</PrimaryBtn>
1162 </>
1163 )}
1164 {tab === "source" && (
1165 <PrimaryBtn icon={<IcSpark size={16} />} onClick={() => run("from-source")} disabled={!canSource}>{__("Generate from source", "betterdocs")}</PrimaryBtn>
1166 )}
1167 {tab === "git" && (
1168 <PrimaryBtn icon={<IcSpark size={16} />} onClick={() => run("from-git")} disabled={!canGit}>{__("Generate from Git", "betterdocs")}</PrimaryBtn>
1169 )}
1170 {tab === "attachment" && (
1171 <PrimaryBtn icon={<IcSpark size={16} />} onClick={() => run("from-attachment")} disabled={!canAttachment}>{__("Generate from attachment", "betterdocs")}</PrimaryBtn>
1172 )}
1173 </div>
1174 )}
1175 {!hasKey && <div style={{ fontSize: 12, color: "var(--muted-2)", marginTop: 8, textAlign: "right" }}>{__("Connect an API key in Settings → AI to enable generation.", "betterdocs")}</div>}
1176 </>
1177 )}
1178
1179 {(stage === "loadingDoc" || stage === "loadingOutline") && (
1180 <PreviewAccept mode="doc" status="loading"
1181 loadingLabel={stage === "loadingOutline" ? __("Outlining", "betterdocs") : (tab === "source" ? __("Reading your source", "betterdocs") : tab === "git" ? __("Fetching from Git", "betterdocs") : tab === "attachment" ? (isRecording(file) ? __("Writing from your transcript", "betterdocs") : __("Reading your file", "betterdocs")) : __("Generating documentation", "betterdocs"))}
1182 onKeep={() => { }} onDiscard={() => setStage("form")} />
1183 )}
1184
1185 {stage === "outline" && (
1186 <OutlineEditor items={outline} setItems={setOutline} onExpand={expand}
1187 onRegenerate={() => run("generate-outline")} onDiscard={() => setStage("form")} />
1188 )}
1189
1190 {(stage === "preview" || stage === "previewError") && (
1191 <PreviewAccept mode="doc" status={stage === "previewError" ? "error" : "ready"} error={error} html={html}
1192 keepLabel={__("Keep & insert", "betterdocs")} maxHeight={340}
1193 onKeep={keepAndInsert} onRegenerate={regenerateDoc} onDiscard={() => setStage("form")}
1194 actions={
1195 <div style={{ width: "100%" }}>
1196 {stage === "preview" && canSuggest && enabledSuggestTaxes().length > 0 && (
1197 <SuggestTermsPreview
1198 groups={enabledSuggestTaxes().map((t) => ({ taxonomy: t.taxonomy, label: t.label, items: suggestions[t.taxonomy] || [] }))}
1199 selected={selectedKeys}
1200 onToggle={toggleKey}
1201 loading={suggestLoading}
1202 />
1203 )}
1204 <div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 18 }}>
1205 <TextBtn onClick={() => setStage("form")} style={{ marginRight: "auto" }}>{__("Discard", "betterdocs")}</TextBtn>
1206 <GhostBtn icon={<IcRefresh size={15} />} onClick={regenerateDoc}>{__("Regenerate", "betterdocs")}</GhostBtn>
1207 <PrimaryBtn icon={<IcCheck size={16} sw={2.4} />} onClick={keepAndInsert} disabled={stage !== "preview"}>{__("Keep & insert", "betterdocs")}</PrimaryBtn>
1208 </div>
1209 </div>
1210 } />
1211 )}
1212 </div>
1213 </Modal>
1214 );
1215 };
1216
1217 export default StudioGenerateModal;
1218