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 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-04 02:11:08 +03:00
co-authored by Claude Sonnet 5.5
parent 74cbd3c3bb
commit b3717973fe
9 changed files with 32 additions and 13 deletions
+1 -1
View File
@@ -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();
+4 -4
View File
@@ -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;
+1 -1
View File
@@ -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); };
},
};
+1 -1
View File
@@ -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 };
+3 -3
View File
@@ -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);
+2 -1
View File
@@ -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 || []));
}
+6
View File
@@ -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/);
});
+4 -2
View File
@@ -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
+10
View File
@@ -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"] });
});