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:
parent
672e1c2014
commit
e1b8ba012d
6 changed files with 57 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue