fix: Story box -- stale draft, Generate waits for an in-flight split and refuses a failed one, empty story applies, cut word refused if a scene already holds it

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-02 11:47:10 +03:00
co-authored by Claude Sonnet 5.5
parent 26b204320b
commit 979a593d3e
5 changed files with 45 additions and 12 deletions
+13 -4
View File
@@ -189,7 +189,9 @@
// global prompt to edit — dereferencing st.project here crashed the whole editor.
const live = (st.project && st.project.global_prompt)
|| (pv && (pv.display_prompt != null ? pv.display_prompt : pv.combined_prompt)) || "";
const val = gpDraft != null ? gpDraft : live;
// The draft only stands in for the scenes while its split is still on its way; after
// that the scenes are the truth, so a tab/project/cut-word change never shows old text.
const val = gpDraft != null && S.globalPromptApplyPending() ? gpDraft : live;
// Templates: pick one to apply (prompt + variables), Save to snapshot the current state.
wrap.append(composeTemplatesBar());
@@ -730,7 +732,7 @@
const wrap = el("div", "bin");
wrap.append(el("div", "lib-form-title", "Cut words"));
wrap.append(el("div", "insp-hint",
"Type one of these words in the Story to start a new scene. The first is the one written between scenes for you."));
"Type one of these words in the Story to start a new scene. The first is the one written between scenes for you. A scene whose own text contains a cut word is split there the next time the Story is edited, so pick words you will not otherwise write."));
const list = el("div", "lib-list");
wrap.append(list);
let words = null;
@@ -753,7 +755,14 @@
});
};
const add = el("input", "lib-in"); add.placeholder = "New cut word, then Enter";
add.onkeydown = (e) => { if (e.key === "Enter" && add.value.trim()) { const v = add.value; add.value = ""; save([...words, v]); } };
add.onkeydown = (e) => {
if (e.key !== "Enter" || !add.value.trim()) return;
const v = add.value.trim();
const re = new RegExp("(?<!\\w)" + v.split(/\s+/).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+") + "(?!\\w)", "i");
const hit = ((S.get().project || {}).scenes || []).find((sc) => re.test(sc.text || ""));
if (hit) { alert(`"${v}" already appears in a scene's text, which would split that scene. Pick another word.`); return; }
add.value = ""; save([...words, v]);
};
wrap.append(add);
API.storyMarkers().then((r) => { words = r.markers; paint(); }).catch((e) => list.append(el("div", "pj-meta", e.message)));
return wrap;
@@ -937,7 +946,7 @@
// Keep the Compose textarea in sync with timeline-driven global-prompt changes,
// but never clobber what the user is actively typing.
window.addEventListener("funpack-global-prompt-updated", (e) => {
if (tab !== "Story" || !composeTextarea) return;
if (tab !== "Story" || !composeTextarea) { if (!(S.globalPromptApplyPending && S.globalPromptApplyPending())) gpDraft = null; return; }
if (document.activeElement === composeTextarea) return;
// Never rewind the draft while the user's newest text is still debouncing/mid-parse —
// the event would carry the previous apply's text and eat their last keystrokes.
+1 -1
View File
@@ -738,7 +738,7 @@
const parsed = pv.parsed || {};
// Anchor disabled (editor setting): the backend still reports a leading anchor, but
// generation folds it into Scene 1 — show it that way so the preview matches.
const foldAnchor = parsed.anchor && !S.getEditorSetting("anchorEnabled");
const foldAnchor = false;
if (parsed.anchor && !foldAnchor) { const l = el("div", "pv-line"); l.append(el("span", "pv-badge anchor", "anchor")); l.append(el("span", null, parsed.anchor)); box.append(l); }
if (foldAnchor) { const l = el("div", "pv-line"); l.append(el("span", "pv-badge", "S1")); l.append(el("span", null, parsed.anchor)); box.append(l); }
const sBase = foldAnchor ? 2 : 1;
+23 -7
View File
@@ -257,6 +257,8 @@
async function loadStoryMarkers() {
try { _storyMarker = ((await API.storyMarkers()).markers || [])[0] || "qcut"; } catch (_) {}
}
// Another tab or window may have changed the words; the box must write the server's.
window.addEventListener("focus", () => loadStoryMarkers());
function buildGlobalPromptFromTimeline(project) {
if (!project) return "";
const active = (project.scenes || []).filter((s) => !s.excluded && isGenerativeScene(s));
@@ -295,7 +297,7 @@
if (!state.project) return;
_pinnedGlobalPrompt = null;
const built = buildGlobalPromptFromTimeline(state.project);
const prev = (state.project.global_prompt || "").trim();
const prev = state.project.global_prompt || "";
if (built === prev && (state.preview?.display_prompt || "") === built) return;
state.project.global_prompt = built;
state.preview = {
@@ -338,9 +340,14 @@
// — otherwise we'd save (and generate from) stale scene text while the screen shows the new prompt.
async function flushGlobalPromptApply() {
if (_globalApplyTimer) { clearTimeout(_globalApplyTimer); _globalApplyTimer = null; }
if (_pendingGlobalPromptText == null) return;
const t = _pendingGlobalPromptText; _pendingGlobalPromptText = null;
await applyGlobalPromptQuiet(t);
if (_pendingGlobalPromptText != null) {
const t = _pendingGlobalPromptText; _pendingGlobalPromptText = null;
await applyGlobalPromptQuiet(t);
}
// A split already in flight (its debounce fired before Generate) is not "pending" any
// more -- wait for it, or Generate would run the scenes the box no longer shows.
await _lastApply.catch(() => {});
if (_applyFailed) throw new Error("The Story could not be split into scenes (is the server reachable?) -- the scenes were not updated, so nothing was generated.");
}
function _normalizeSceneText(t) {
@@ -597,7 +604,15 @@
return true;
}
async function applyGlobalPromptQuiet(text) {
let _lastApply = Promise.resolve();
let _applyFailed = false;
function applyGlobalPromptQuiet(text) {
const p = _applyGlobalPromptQuiet(text);
_lastApply = p;
return p;
}
async function _applyGlobalPromptQuiet(text) {
// Same shape as commit()'s guard above, and needed for the same reason:
// this has an internal closure-scoped caller (applyPromptTemplate) besides its exported
// property, so blocking Store.applyGlobalPromptQuiet from tour.js alone
@@ -607,8 +622,7 @@
if (window.__FUNPACK_TOUR__) return false;
if (!state.project) return false;
const trimmed = String(text || "").trim();
if (!trimmed) return false;
if (trimmed === buildGlobalPromptFromTimeline(state.project) && !_pinnedGlobalPrompt) return true;
if (trimmed === buildGlobalPromptFromTimeline(state.project) && !_pinnedGlobalPrompt) { _applyFailed = false; return true; }
const seq = ++_applySeq;
_applyInFlight++;
try {
@@ -617,8 +631,10 @@
try { res = await API.parsePrompt(state.project.id, trimmed); }
catch (e) {
console.warn("Global prompt parse failed:", e);
_applyFailed = true;
return false;
}
_applyFailed = false;
// The user typed more while this parse was in flight: a newer apply is scheduled (or
// already running). Distributing this OLDER text now would re-split the timeline to a
// stale prompt and dispatch a stale updated event — drop it and let the newer one win.
+2
View File
@@ -35,6 +35,8 @@ def markers() -> list[str]:
def _clean(raw) -> list[str]:
out = []
for m in raw if isinstance(raw, list) else []:
if not isinstance(m, str):
continue
m = " ".join(str(m).split())[:MAX_MARKER]
if m and m.lower() not in (o.lower() for o in out):
out.append(m)
+6
View File
@@ -42,3 +42,9 @@ def test_empty_marker_list_is_refused_and_keeps_the_old_one():
def test_unreadable_store_falls_back_to_default():
config.MARKERS_FILE.write_text("{nope", encoding="utf-8")
assert story.markers() == story.DEFAULT_MARKERS
def test_non_string_markers_are_ignored():
assert story.save_markers([None, 5, "cut"]) == ["cut"]
with pytest.raises(ValueError):
story.save_markers([None, 5])