{/* Two counters and three buttons. Without wrapping this row measures
553px, which on a phone widened the whole layout viewport rather
than merely overflowing — every other element then rendered at the
wrong scale. flexWrap + minWidth:0 keeps it inside the screen. */}
{generated}/{total} generated{approved}/{total} approved
{bulkProgress && (
Running pipeline · {bulkProgress.running} of {bulkProgress.total}✓ {bulkProgress.succeeded} · ✕ {bulkProgress.failed}
)}
);
};
/* ================================================================
QUESTION SIDEBAR — sectioned list with status dots
================================================================ */
const AK_QuestionSidebar = ({ questions, keys, running, activeQid, onPick, loading }) => {
// Group by inferred section letter (first non-digit prefix in q_no) — falls
// back to a single "Questions" bucket if numbering is plain.
const groups = useMemo(() => {
const m = new Map();
questions.forEach(q => {
const key = String(q.num ?? "").match(/^[A-Z]/) ? String(q.num)[0] : "•";
if (!m.has(key)) m.set(key, []);
m.get(key).push(q);
});
return [...m.entries()].map(([k, items]) => ({ k, items }));
}, [questions]);
return (
);
};
const AK_StatusBadge = ({ ak, running }) => {
if (running === "running") {
return ;
}
if (running === "error") {
return ✕;
}
if (!ak) return ;
if (ak.status === "approved") return ✓;
return ;
};
/* ================================================================
RIGHT PANE — 4 stacked cards or empty state
================================================================ */
const AK_RightPane = ({ question, answerKey, running, error, onRun, onApprove, language }) => {
if (!question) {
return
);
/* ---------- Running indicator ---------- */
const AK_RunningCard = ({ q, language }) => {
const stages = [
"Identifying scope & syllabus anchor",
"Composing the perfect answer + flow",
"Explaining the chemistry / logic per criterion",
"Generating deduction-proof variants + rebuttals",
];
// No real per-stage SSE yet — animate sequentially for honest UX.
const [now] = useState(Date.now());
const elapsed = (Date.now() - now) / 1000;
return (
Pipeline running for Q{q.num} ({AK_LANG_LABEL[language] || language})~50s typical
{stages.map((s, i) => (
› {s}
))}
);
};
/* ---------- Lightweight Bengali-friendly markdown renderer ----------
The pipeline prompts produce text like:
- paragraphs separated by blank lines
- **bold** spans
- "* " bullet list rows
- "1." / "১." numbered list rows
- indented " " continuation rows (inline reactions, etc.)
We parse those into proper block elements so long answers flow naturally
without `
` quirks or fixed heights cutting content off. */
const AK_BN_NUM = /^([0-9]+|[০-৯]+)[.)]\s+/;
const AK_INLINE_BOLD = /(\*\*[^*]+\*\*)/g;
function ak_renderInline(s) {
if (s == null) return null;
const parts = String(s).split(AK_INLINE_BOLD);
return parts.map((p, i) =>
p.startsWith("**") && p.endsWith("**")
? {p.slice(2, -2)}
: {p}
);
}
function ak_renderMarkdown(text, opts = {}) {
if (!text) return null;
const className = opts.className || "bn";
const baseStyle = {
fontSize: opts.fontSize || 13.5,
lineHeight: 1.6,
color: opts.color || "var(--ink-0)",
fontFamily: opts.font || "var(--font-serif)",
overflowWrap: "anywhere",
wordBreak: "break-word",
};
const lines = String(text).replace(/\r\n/g, "\n").split("\n");
const blocks = [];
let i = 0;
let key = 0;
while (i < lines.length) {
const raw = lines[i];
const trimmed = raw.trim();
// Blank line → paragraph break (rendered as gap between blocks).
if (!trimmed) { i++; continue; }
// Bullet block — collect consecutive "* " (or "- ") items, treating
// indented continuation lines as part of the previous item.
if (/^[*\-]\s+/.test(trimmed)) {
const items = [];
while (i < lines.length) {
const r = lines[i];
const t = r.trim();
if (/^[*\-]\s+/.test(t)) {
items.push(t.replace(/^[*\-]\s+/, ""));
i++;
} else if (/^\s{2,}\S/.test(r) && items.length) {
// Continuation of the last bullet (indented).
items[items.length - 1] += "\n" + t;
i++;
} else {
break;
}
}
blocks.push(
{items.map((it, j) => (
{ak_renderInline(it)}
))}
);
continue;
}
// Numbered block (English or Bengali digits).
if (AK_BN_NUM.test(trimmed)) {
const items = [];
while (i < lines.length) {
const r = lines[i];
const t = r.trim();
const m = t.match(AK_BN_NUM);
if (m) {
items.push(t.slice(m[0].length));
i++;
} else if (/^\s{2,}\S/.test(r) && items.length) {
items[items.length - 1] += "\n" + t;
i++;
} else {
break;
}
}
blocks.push(
{items.map((it, j) => (
{ak_renderInline(it)}
))}
);
continue;
}
// Plain paragraph — keep going until we hit a blank line or a list marker.
const para = [trimmed];
i++;
while (i < lines.length) {
const t = lines[i].trim();
if (!t) break;
if (/^[*\-]\s+/.test(t) || AK_BN_NUM.test(t)) break;
para.push(t);
i++;
}
blocks.push(
{ak_renderInline(para.join("\n"))}
);
}
return blocks;
}
/* ---------- Card 1: Scope ---------- */
const AK_ScopeCard = ({ ak }) => {
const s = ak.scope;
if (!s) return null;
return (
{/* A4 on paper, but 210mm is 794px — on a phone that forced the whole
layout viewport wider than the screen. Cap it for the screen; the
@page rule in print CSS is what actually governs the printout. */}