Prompt queue: pre-fetch 5, instant local fallback, emoji source icon

- Refill a queue of 5 AI prompts in the background on page load
- Track in-flight fetches with pendingFetches ref so we never over-request
- fetchPrompt() is now synchronous: pops from queue or instantly returns a
  shuffled built-in (LOCAL_PROMPTS, 12 entries, no-repeat cycling)
- Source badge replaced with a small emoji in the top-right corner of the
  popover: 🤖 for AI prompts, 📚 for built-ins
- Removed promptLoading state and spinner entirely

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chris 2026-05-24 20:48:31 -04:00
parent c5b7d7f774
commit 48d3533bfc
2 changed files with 68 additions and 49 deletions

View File

@ -14,6 +14,33 @@ import NotesPanel from '../components/NotesPanel'
const MIN_FONT = 13
const MAX_FONT = 26
// Small client-side fallback pool used instantly when the AI queue is empty.
// Shuffled so the same prompt doesn't appear twice before cycling through all.
const LOCAL_PROMPTS = (() => {
const list = [
"A door appears in your bedroom wall overnight. It wasn't there when you went to sleep.",
"You find a letter in your pocket written in your own handwriting — dated next week.",
"The last dragon in the world lands in your garden. It is very small and very scared.",
"Your shadow starts leaving notes under your pillow.",
"Every story you write comes true — but always with one small, wrong detail.",
"A train arrives at your station that isn't on any timetable. The conductor says you have been expected.",
"You discover you have one magical power: you can always find things that are lost. Not everything that is lost wants to be found.",
"The stars rearrange themselves every night. Last night you finally worked out what language they're writing in.",
"A bookshop appears on your street that wasn't there yesterday. Inside are books about your life — including things that haven't happened yet.",
"Everyone in your town wakes up speaking a different language. Everyone except you.",
"The person chosen to save the world is seventy-three years old, very tired, and was just about to have a cup of tea.",
"You are the only one who can hear the sea talking. It is furious about something.",
]
let queue = []
const shuffle = arr => [...arr].sort(() => Math.random() - 0.5)
return {
next() {
if (!queue.length) queue = shuffle(list)
return { prompt: queue.pop(), source: 'local', model: null }
},
}
})()
export default function EditorPage() {
const { id } = useParams()
const navigate = useNavigate()
@ -33,7 +60,6 @@ export default function EditorPage() {
const [promptText, setPromptText] = useState(null)
const [promptSource, setPromptSource] = useState(null) // 'ollama' | 'local'
const [promptModel, setPromptModel] = useState(null)
const [promptLoading, setPromptLoading] = useState(false)
const [fontSize, setFontSize] = useState(
() => Math.max(MIN_FONT, Math.min(MAX_FONT, parseInt(localStorage.getItem('sw-fontsize')) || 17))
)
@ -43,9 +69,10 @@ export default function EditorPage() {
const latestContent = useRef({})
const prevWordCount = useRef(0)
const coverRef = useRef()
// Holds the in-flight prompt request so the click can await it rather than
// starting a brand-new fetch (which would have to wait all over again).
const pendingPrompt = useRef(null)
// Prompt queue up to 5 AI prompts pre-fetched and ready to go.
// pendingFetches tracks in-flight requests so we know how many more to start.
const promptQueue = useRef([])
const pendingFetches = useRef([])
const editorRef = useRef()
const saveRef = useRef(null)
@ -62,9 +89,8 @@ export default function EditorPage() {
.catch(() => setLoadError(true))
}, [id])
// Kick off a prompt fetch in the background as soon as the page loads so
// Ollama has the full response time before the user ever clicks the button.
useEffect(() => { kickPromptFetch() }, []) // eslint-disable-line react-hooks/exhaustive-deps
// Start filling the prompt queue as soon as the page loads.
useEffect(() => { refillPrompts() }, []) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
localStorage.setItem('sw-fontsize', fontSize)
@ -167,32 +193,28 @@ export default function EditorPage() {
await api.updateStory(id, { cover_image: null })
}
// Returns the in-flight prompt Promise, or starts a fresh one.
// Calling this twice before the first resolves hands back the same Promise.
function kickPromptFetch() {
if (!pendingPrompt.current) {
pendingPrompt.current = api.getPrompt()
// Keep up to 5 AI prompts pre-fetched. Each request is tracked individually
// so we always know exactly how many are in-flight vs. ready.
function refillPrompts() {
const needed = 5 - promptQueue.current.length - pendingFetches.current.length
for (let i = 0; i < needed; i++) {
const p = api.getPrompt()
.then(data => { promptQueue.current.push(data) })
.catch(() => {}) // server always returns something; this is a safety net
.finally(() => {
pendingFetches.current = pendingFetches.current.filter(x => x !== p)
})
pendingFetches.current = [...pendingFetches.current, p]
}
return pendingPrompt.current
}
async function fetchPrompt() {
setPromptLoading(true)
setPromptText(null)
const promise = kickPromptFetch()
try {
const data = await promise
pendingPrompt.current = null // clear so the next kick starts fresh
setPromptText(data.prompt)
setPromptSource(data.source ?? null)
setPromptModel(data.model ?? null)
kickPromptFetch() // pre-load the next one immediately
} catch {
pendingPrompt.current = null
toast('Could not fetch a prompt', 'error')
} finally {
setPromptLoading(false)
}
function fetchPrompt() {
// Use an AI prompt if one is ready, otherwise fall back to a built-in instantly
const item = promptQueue.current.shift() ?? LOCAL_PROMPTS.next()
setPromptText(item.prompt)
setPromptSource(item.source ?? 'local')
setPromptModel(item.model ?? null)
refillPrompts() // top the queue back up to 5
}
function insertPrompt() {
@ -251,19 +273,20 @@ export default function EditorPage() {
</div>
<div className="topbar-right">
<div className="prompt-wrap">
<button className="btn btn-ghost" onClick={fetchPrompt} disabled={promptLoading} title="Get a writing prompt">
{promptLoading ? '…' : <>💡 <span>Prompt</span></>}
<button className="btn btn-ghost" onClick={fetchPrompt} title="Get a writing prompt">
💡 <span>Prompt</span>
</button>
{promptText && (
<div className="prompt-popover">
<p className="prompt-text">{promptText}</p>
{promptSource && (
<div className={`prompt-source prompt-source--${promptSource}`}>
{promptSource === 'ollama'
? <>🤖 AI · {promptModel || 'ollama'}</>
: <>📚 Built-in prompt</>}
</div>
<span
className={`prompt-source-icon prompt-source--${promptSource}`}
title={promptSource === 'ollama' ? `AI · ${promptModel || 'ollama'}` : 'Built-in prompt'}
>
{promptSource === 'ollama' ? '🤖' : '📚'}
</span>
)}
<p className="prompt-text">{promptText}</p>
<div className="prompt-actions">
<button className="btn btn-ghost" onClick={fetchPrompt}> Another</button>
<button className="btn btn-primary" onClick={insertPrompt}>Insert</button>

View File

@ -1488,18 +1488,14 @@ button { cursor: pointer; font-family: inherit; }
margin-bottom: 0.875rem;
}
.prompt-source {
font-size: 0.7rem;
letter-spacing: 0.05em;
font-family: var(--font-head);
margin-bottom: 0.6rem;
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 99px;
border: 1px solid currentColor;
.prompt-source-icon {
position: absolute;
top: 0.6rem;
right: 0.7rem;
font-size: 0.85rem;
line-height: 1;
opacity: 0.55;
}
.prompt-source--ollama { color: var(--accent-hi); }
.prompt-source--local { color: var(--text-muted); }
.prompt-actions {
display: flex;