fix: the AI draft opens what it wrote, and Editorial opens the editor

**"Draft with AI does nothing" was almost true.** The job worked — about forty
seconds, then a draft saved — but the panel closed onto a library that looked
unchanged, and the only progress it ever showed was the same "Drafting…" line.
The job now reports the article it made and the page goes straight into it, and
the wait is counted in seconds so it is visibly a wait rather than a hang.

**Editorial rows open the editor.** It is the queue of things to *do* to an
article; rows that opened the reader made an editor press Edit on every one.

**No Contents button on a phone.** The header's menu opens the contents, and a
second door in the prose is the same mistake the player had. An open drawer now
has a backdrop that closes it, and choosing a section closes it too — it sits
over the prose it just scrolled to.

**References wrap.** A source title is often a filename with underscores and no
spaces to break at, so the list ran off the side of the screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 22:37:46 +02:00
parent 672e1c2014
commit e1b8ba012d
6 changed files with 57 additions and 6 deletions

View file

@ -981,6 +981,14 @@ def get_article_job(job_id: str, current_user: User = Depends(get_current_user))
status = r.get(f"extraction:status:{job_id}") or "unknown"
steps = [_json.loads(s) for s in r.lrange(f"extraction:steps:{job_id}", 0, -1)]
result = {"job_id": job_id, "status": status, "steps": steps}
# What it made, so the page that started the job can open it. Without this
# a draft finished into a list somewhere and the educator who asked for it
# was left looking at a panel that had simply closed.
# Parsed defensively: an older job has no such key, and anything that is
# not a number means the same thing as nothing — no article to open.
article_id = r.get(f"extraction:article:{job_id}")
if article_id and str(article_id).isdigit():
result["article_id"] = int(article_id)
if status == "failed":
result["error"] = r.get(f"extraction:error:{job_id}") or "Unknown error"
return result

View file

@ -1068,6 +1068,8 @@ def generate_article_draft(self, job_id: str, user_id: int, topic: str,
# `Article.sections` has to do this; the ones that did not left 323
# articles with no section rows and a vector built from the title alone.
article_service.reindex(db, article)
# The id, so whoever asked for the draft can be taken straight to it.
r.set(f"extraction:article:{job_id}", str(article.id), ex=EXPIRE_SECONDS)
r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS)
_push_step(r, job_id, "done", f"Draft saved: {title}")
except ArticleDraftError as refusal:

View file

@ -371,6 +371,13 @@ export default function ArticleReader({
return (
<div className={`article-layout${railOpen ? '' : ' is-railed-off'}${aside ? ' has-aside' : ''}`}
ref={root} style={{ '--article-top': `${headerTop}px` }}>
{/* Tapping anywhere outside an open drawer closes it. A real element
rather than a CSS pseudo-element, because a backdrop has to be
clickable and `::after` cannot take a listener. */}
{drawerOpen && (
<div className="article-drawer-backdrop" aria-hidden="true"
onClick={() => setDrawerOpen(false)} />
)}
<aside id={`${idPrefix}article-sections`} className={`article-sections ${drawerOpen ? 'open' : ''}`}>
{/* Outside the scroller, because it hangs over the boundary between the
rail and the prose which is where the reader is looking when they
@ -387,7 +394,7 @@ export default function ArticleReader({
{topSections.map(sec => (
<li key={sec.id}>
<button className={marked === sec.id ? 'section-link active' : 'section-link'}
onClick={() => openSection(sec.id)}>
onClick={() => { openSection(sec.id); setDrawerOpen(false) }}>
{sec.title}
</button>
{/* A sub-section is listed under its parent, not alongside it,

View file

@ -148,8 +148,16 @@
transition: transform .18s ease, visibility .18s;
}
.article-sections.open { transform: none; visibility: visible; }
/* The dark ground behind an open drawer. Tapping it closes the drawer. */
.article-drawer-backdrop {
position: fixed; inset: 0; z-index: 39; background: rgba(15, 23, 42, .38);
}
.article-rail-hide, .article-rail-reopen { display: none; }
.article-drawer-toggle { display: inline-flex; }
/* No Contents button. The menu in the header opens the contents on a phone,
and it is the control that is in the same place on every page a second
one in the prose is the same door twice, which is what the quiz player had
until it did not. */
.article-drawer-toggle { display: none; }
.article-content { padding: 12px 16px 40px; min-height: 0; }
.article-title { font-size: 1.4rem; }
}
@ -350,7 +358,13 @@
.article-references { margin-top: 26px; padding-top: 14px; border-top: 1px solid var(--border); }
.article-references h2 { margin: 0 0 10px; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle); }
.article-references ol { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 5px; }
.article-references li { font-size: 0.83rem; line-height: 1.5; color: var(--text-muted); }
/* A source title is often a filename with underscores in it and no spaces to
break at `2021_Fleisher_&_Ludwig's_Textbook_of_Pediatric_Emergency` so
the line has to be allowed to break anywhere or it runs off the screen. */
.article-references li {
font-size: 0.83rem; line-height: 1.5; color: var(--text-muted);
overflow-wrap: anywhere; word-break: break-word;
}
.article-ref-title { color: var(--text); font-weight: 600; }
.article-ref-pages { font-variant-numeric: tabular-nums; }

View file

@ -87,13 +87,29 @@ export default function ArticlesPage() {
if (!aiTopic.trim()) { setError('Topic is required'); return }
try {
const res = await api.post('/articles/ai-draft', { topic: aiTopic, instructions: aiInstructions })
const startedAt = Date.now()
setAiStatus('Drafting…')
const poll = async () => {
try {
const job = await api.get(`/articles/job/${res.data.job_id}`)
if (job.data.status === 'completed') { setAiStatus(''); setShowAi(false); load() }
if (job.data.status === 'completed') {
setAiStatus('')
setShowAi(false)
// Straight into the draft. It finished into a list otherwise, and
// the educator who asked for it was left looking at a panel that
// had closed and a library that looked unchanged.
if (job.data.article_id) { navigate(`/articles/${job.data.article_id}?edit=1`); return }
load()
}
else if (job.data.status === 'failed') { setAiStatus(''); setError(`Drafting failed: ${job.data.error || 'unknown error'}`) }
else { setAiStatus(`Working… ${job.data.steps.at(-1)?.message || ''}`); setTimeout(poll, 2000) }
else {
// The model writes for about half a minute and reports one step, so
// the wait is said in seconds rather than repeating "Drafting" at
// somebody who cannot tell whether anything is happening.
const seconds = Math.round((Date.now() - startedAt) / 1000)
setAiStatus(`${job.data.steps.at(-1)?.message || 'Working…'} ${seconds}s`)
setTimeout(poll, 2000)
}
} catch {
// A poll that throws used to reject into nothing and leave the panel
// saying "Drafting" for ever. The job may well still be running.

View file

@ -162,7 +162,11 @@ export default function EditorialPage() {
<ul className="ed-list">
{rows.map(article => (
<li key={article.id}>
<Link className="ed-title" to={`/articles/${article.id}`}>{article.title}</Link>
{/* Straight into the editor. Editorial is the queue of
things to *do* to an article, and a queue whose rows
open the reader makes an editor press Edit on every
one of them. */}
<Link className="ed-title" to={`/articles/${article.id}?edit=1`}>{article.title}</Link>
<span className={`ed-status is-${article.status}`}>{article.status.replace('_', ' ')}</span>
{(article.variants || []).length > 0 && (
<span className="ed-variants">{article.variants.length} views</span>