From b3717973fe216356d27f82d0a77ffe93ecc620ad Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:11:08 +0300 Subject: [PATCH] fix(ui): Chat reads the shared last-run record, refreshes after a run, undo keeps the chosen pipeline, a throwing input hook is a note Co-Authored-By: Claude Sonnet 5.5 --- app/ui/boot.js | 2 +- app/ui/features/composer/chat.js | 8 ++++---- app/ui/features/composer/index.js | 2 +- app/ui/features/generate/hub.test.js | 2 +- app/ui/features/generate/index.js | 6 +++--- app/ui/features/generate/inputs.js | 3 ++- app/ui/features/generate/inputs.test.js | 6 ++++++ app/ui/shell/project.js | 6 ++++-- app/ui/shell/tests/project.test.js | 10 ++++++++++ 9 files changed, 32 insertions(+), 13 deletions(-) diff --git a/app/ui/boot.js b/app/ui/boot.js index 72a7cfc6..882d3694 100644 --- a/app/ui/boot.js +++ b/app/ui/boot.js @@ -29,7 +29,7 @@ linkPipeline({ project, pipeline, onOpen: (fn) => on((what) => { if (what === "o const frame = build(document.getElementById("app")); const selection = createSelection({ project }); selection.on(() => say("select")); -const app = { editorSettings: [], inputHooks: [], project, pipeline, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, mode: createMode(), panel: frame.togglePanel, closePanels: frame.closePanels, dock: createDock(), maintenance: createMaintenance({ api, flush: async () => { await project.flush(); if (project.unsaved) throw new Error("Your latest edits could not be saved, so nothing was changed. Try again in a moment."); } }), keys: createKeys(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } }; +const app = { editorSettings: [], inputHooks: [], lastRun: { typed: undefined, n: 0 }, project, pipeline, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, mode: createMode(), panel: frame.togglePanel, closePanels: frame.closePanels, dock: createDock(), maintenance: createMaintenance({ api, flush: async () => { await project.flush(); if (project.unsaved) throw new Error("Your latest edits could not be saved, so nothing was changed. Try again in a moment."); } }), keys: createKeys(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } }; const settings = createSettings({ menubar: hostFor("menubar.menus") }); const result = await loadFeatures(paths, { app }); settings.addButton(); diff --git a/app/ui/features/composer/chat.js b/app/ui/features/composer/chat.js index d628a8e6..1a87dfe3 100644 --- a/app/ui/features/composer/chat.js +++ b/app/ui/features/composer/chat.js @@ -15,7 +15,7 @@ export function chatHook({ project, scene, slots }) { export function chat(app, own) { const p = app.project, page = c.region.stack({ gap: "sm" }); - let last = null, alive = true, key = "", draft = ""; + let last = null, alive = true, key = "", draft = "", runs = app.lastRun.n; own(() => { alive = false; }); const rounds = () => { const r = p.pref("enhance_chat", []); return Array.isArray(r) ? r : []; }; const typed = () => { // the prompt a comment is about: the picked scene's own text, as typed @@ -27,7 +27,7 @@ export function chat(app, own) { function draw(force) { if (!alive) return; const list = rounds(), ok = last && last.ok, next = JSON.stringify([p.project && p.project.id, list, last && last.after]); - if (!force && next === key) return; + if (!force && (next === key || (page.node.contains(document.activeElement) && document.activeElement.tagName === "TEXTAREA"))) return; // not under the caret key = next; if (!p.project) return page.set([c.hint.default({ text: "Open a project first." })]); const bubbles = [...list.flatMap((r) => [first(r) ? c.text.sm({ text: `Model: ${first(r)}` }) : null, c.text.sm({ text: `You: ${r.comment}` })]), @@ -38,7 +38,7 @@ export function chat(app, own) { const comment = draft.trim(), now = rounds(); if (!comment) return; const prev = now.length ? first(now[now.length - 1]) : null, here = typed(); - const fresh = ok && last.after !== prev && app.enhanceTyped !== undefined && app.enhanceTyped === here; // a rewrite of THIS prompt that nobody has commented on yet + const fresh = ok && last.after !== prev && app.lastRun.typed !== undefined && app.lastRun.typed === here; // a rewrite of THIS prompt that nobody has commented on yet draft = ""; p.setPref("enhance_chat", [...now, { rewrites: fresh ? { whole: last.after } : {}, comment, ...(here === undefined ? {} : { original: here }) }]); draw(true); @@ -48,7 +48,7 @@ export function chat(app, own) { c.toolbar.default({ items: [c.button.sm({ label: "Send", tone: "primary", onClick: send })], trailing: [c.button.sm({ label: "Reset", tone: "ghost", disabled: !list.length, onClick: () => { p.setPref("enhance_chat", []); draw(true); } })] })]); } const look = () => app.api.enhancerRuns().then((r) => { last = (r.runs || []).slice(-1)[0] || null; draw(); }).catch(() => {}); - own(app.on((what) => { if (what === "open") look(); else draw(); })); + own(app.on((what) => { if (what === "open" || app.lastRun.n !== runs) { runs = app.lastRun.n; look(); } else draw(); })); draw(true); look(); return page; diff --git a/app/ui/features/composer/index.js b/app/ui/features/composer/index.js index 7a3422dd..a3c3f2ee 100644 --- a/app/ui/features/composer/index.js +++ b/app/ui/features/composer/index.js @@ -56,7 +56,6 @@ export default { needs: ["project", "api", "pipeline"], setup({ host, app }) { let win = null, owned = []; - app.inputHooks.push(chatHook); const cleanup = () => { owned.forEach((f) => f()); owned = []; }; const open = () => { if (win) return; @@ -67,6 +66,7 @@ export default { onClose: () => { cleanup(); win = null; } }); }; host.append(c.button.sm({ label: "◆ Composer", tone: "neutral", onClick: open }).node); + app.inputHooks.push(chatHook); // last, so a setup that failed above leaves nothing behind return () => { cleanup(); app.inputHooks.splice(app.inputHooks.indexOf(chatHook) >>> 0, 1); }; }, }; diff --git a/app/ui/features/generate/hub.test.js b/app/ui/features/generate/hub.test.js index 4bd5e189..72555392 100644 --- a/app/ui/features/generate/hub.test.js +++ b/app/ui/features/generate/hub.test.js @@ -14,7 +14,7 @@ const rig = (phase, extra = {}) => { waitForTerminal: () => Object.assign(Promise.resolve("done"), { cancel() {} }), ...extra }; const project = { project: doc, get scenes() { return doc.scenes; }, selected: null, edit: (fn) => fn(doc), editFor: async (_id, fn) => fn(doc) }; - const app = { project, pipeline: { slots: () => [] }, generate: g, api: { newTasteGeneration: () => Promise.resolve({}) }, on: () => () => {} }; + const app = { project, lastRun: { n: 0 }, pipeline: { slots: () => [] }, generate: g, api: { newTasteGeneration: () => Promise.resolve({}) }, on: () => () => {} }; const host = document.createElement("div"); hub.setup({ host, app }); return { handlers, doc, host }; diff --git a/app/ui/features/generate/index.js b/app/ui/features/generate/index.js index 67b4198c..24f2fc25 100644 --- a/app/ui/features/generate/index.js +++ b/app/ui/features/generate/index.js @@ -24,7 +24,7 @@ export default { g.on("hold", () => { held = true; draw(); }); g.on("release", () => { held = false; draw(); }); // while the page asks ComfyUI whether a run is already going // the pipeline check's refusals: said where the person is looking let busy = false, stopped = false, held = false; - const record = (pid, unit, image, frames, promptId) => p.editFor(pid, (pr) => { + const record = (pid, unit, image, frames, promptId) => { app.lastRun.n += 1; return p.editFor(pid, (pr) => { const group = pr.scenes.filter((s) => genUnitId(s) === unit); if (!group.length) return false; const secs = (frames || group.reduce((t, s) => t + effFrames(s, pr), 0) - (group.length - 1)) / (pr.frame_rate || 25); // as queued, not as the project reads now @@ -35,7 +35,7 @@ export default { if (!(s.cut_offset_frames > 0)) s.rating = ""; // a new render has not been rated yet // a fresh render was made at this length: an earlier trim's window no longer applies }); return true; - }).catch(() => tell("The result could not be saved to its project.")); + }).catch(() => tell("The result could not be saved to its project.")); }; async function runUnits(units) { if (busy) return; @@ -53,7 +53,7 @@ export default { // The unit is made once at the length of its clips together (each cut adds one shared frame). const frames = group.reduce((t, s) => t + effFrames(s, p.project), 0) - (group.length - 1); const { inputs, unwired, noPrompt, notes } = await buildInputs({ project: p.project, scene: root, slots: app.pipeline.slots(), expand, frames, hooks: app.inputHooks }); - app.enhanceTyped = (root.text || "").trim(); // what a Chat comment made now would be about + app.lastRun.typed = (root.text || "").trim(); // what a Chat comment made now would be about if (stopped) break; if (noPrompt && !made) tell("This pipeline has no prompt input, so the scene text is not sent."); notes.forEach(tell); diff --git a/app/ui/features/generate/inputs.js b/app/ui/features/generate/inputs.js index 005e6e7e..06eb215d 100644 --- a/app/ui/features/generate/inputs.js +++ b/app/ui/features/generate/inputs.js @@ -30,7 +30,8 @@ export async function buildInputs({ project, scene, slots, expand, frames, hooks // Features that add inputs of their own (a Chat comment for the enhancer, say) say so through a hook: { inputs: {slot: {name: value}}, notes: [text] }. const notes = []; for (const hook of hooks) { - const out = hook({ project, scene, slots }) || {}; + let out; + try { out = hook({ project, scene, slots }) || {}; } catch (err) { notes.push(`An add-on's input could not be added to this run: ${err.message}`); continue; } for (const [id, fields] of Object.entries(out.inputs || {})) raw[id] = { ...raw[id], ...fields }; notes.push(...(out.notes || [])); } diff --git a/app/ui/features/generate/inputs.test.js b/app/ui/features/generate/inputs.test.js index 8bd7b0cb..13515a99 100644 --- a/app/ui/features/generate/inputs.test.js +++ b/app/ui/features/generate/inputs.test.js @@ -48,3 +48,9 @@ test("a hook's inputs are merged and its notes come back", async () => { assert.equal(r.inputs.k.extra, "x"); assert.deepEqual(r.notes, ["said"]); }); + +test("a hook that throws does not stop the run: its problem comes back as a note", async () => { + const r = await buildInputs({ project: {}, scene: { text: "x" }, slots, hooks: [() => { throw new Error("boom"); }], expand: async () => null }); + assert.equal(r.inputs.p.text, "x"); + assert.match(r.notes[0], /boom/); +}); diff --git a/app/ui/shell/project.js b/app/ui/shell/project.js index 15570d43..d507b52d 100644 --- a/app/ui/shell/project.js +++ b/app/ui/shell/project.js @@ -53,6 +53,8 @@ const put = (project) => json("PUT", `${BASE}/${encodeURIComponent(project.id)}` * `onChange` fires for anything that alters what is on screen, including the * selection. Nothing here draws; the timeline subscribes. */ +const KEPT = ["editor_settings", "models"]; + export function createProject({ onChange, onError, onOpen, keepConsistent } = {}) { let project = null; let selected = null; @@ -107,9 +109,9 @@ export function createProject({ onChange, onError, onOpen, keepConsistent } = {} // before this must not still be allowed to land on top of it once its // own fetch catches up. generation += 1; - const prefs = project.editor_settings; // preferences are not edits: stepping back never takes them with it + const kept = KEPT.map((k) => project[k]); // preferences and the chosen pipeline are not edits: stepping back never takes them with it project = was.project; - if (prefs !== undefined) project.editor_settings = prefs; + KEPT.forEach((k, i) => { if (kept[i] !== undefined) project[k] = kept[i]; }); selected = was.selected; // The scene that was selected may be gone -- stepping FORWARD into a state // where it had been removed. Fall back rather than leaving a selection that diff --git a/app/ui/shell/tests/project.test.js b/app/ui/shell/tests/project.test.js index d0564900..9aa0716b 100644 --- a/app/ui/shell/tests/project.test.js +++ b/app/ui/shell/tests/project.test.js @@ -651,3 +651,13 @@ test("an undo takes edits back but never an editor preference", async () => { p.redo(); assert.equal(p.pref("upscaleMode", "never"), "always"); }); + +test("an undo keeps the chosen pipeline too", async () => { + server(); + const p = createProject({}); + await p.start(); + p.setVideo("width", 832); + p.setField("models", { slots: ["B"] }, { quiet: true }); + p.undo(); + assert.deepEqual(p.project.models, { slots: ["B"] }); +});