Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 71 additions & 47 deletions client/src/components/fableloom/LoomNodeEditor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
* queued render via the shared image-gen lane), and the AI branch action.
*
* Fields save on blur (silent PATCH, skipped when unchanged; the server
* returns the full loom, which the parent folds into state). The AI actions
* read server-side state, so they gate on in-flight saves per the client
* save-gating convention.
* returns the full loom, which the parent folds into state). Paths save one
* row at a time against the transition sub-resources — a row exists on the
* server the moment it is added, so its id is known here and nothing has to be
* reconciled back after a save. The AI actions read server-side state, so they
* gate on in-flight saves per the client save-gating convention.
*/

import { useEffect, useMemo, useState } from 'react';
Expand All @@ -18,25 +20,26 @@ import MediaImage from '../MediaImage';
import { useAsyncAction } from '../../hooks/useAsyncAction';
import { useConfirmDelete } from '../../hooks/useConfirmDelete';
import {
branchLoomNode, deleteLoomNode, generateImage, updateLoomNode,
addLoomTransition, branchLoomNode, deleteLoomNode, deleteLoomTransition,
generateImage, updateLoomNode, updateLoomTransition,
} from '../../services/api';
import { fieldClass, labelClass, sceneFieldClass } from './fieldStyles';
import { isTeleplayFormat } from './loomFormats';

const toRow = (t) => ({ ...t, triggersText: (t.triggers || []).join('; ') });
const rowsToTransitions = (rows) => rows
.filter((t) => t.targetNodeId)
.map(({ id, targetNodeId, intent, triggersText, description }) => ({
id, targetNodeId, intent,
triggers: (triggersText || '').split(';').map((s) => s.trim()).filter(Boolean),
description: description || '',
}));
const rowToPatch = ({ targetNodeId, intent, triggersText, description }) => ({
targetNodeId,
intent: intent || '',
triggers: (triggersText || '').split(';').map((s) => s.trim()).filter(Boolean),
description: description || '',
});

export default function LoomNodeEditor({ loom, episode, node, onLoomUpdate, onClearSelection, onMakeStart }) {
const [form, setForm] = useState(null);
// In-flight blur-saves; the AI buttons (which read server-side state) stay
// disabled until every pending save settles.
const [pendingSaves, setPendingSaves] = useState(0);
const [addingPath, setAddingPath] = useState(false);
const del = useConfirmDelete();
// A teleplay carries its own line breaks, so the editor gives it a taller
// monospaced field — the same surface prose gets, sized for the format.
Expand All @@ -63,11 +66,17 @@ export default function LoomNodeEditor({ loom, episode, node, onLoomUpdate, onCl
[episode.nodes, node.id],
);

const patchNode = async (patch) => {
// Every write from this panel goes through here so the AI gate sees it and a
// failure surfaces once, in one place.
const runSave = async (write) => {
setPendingSaves((n) => n + 1);
const updated = await updateLoomNode(loom.id, episode.id, node.id, patch, { silent: true })
.catch((err) => { toast.error(`Save failed: ${err.message}`); return null; });
const result = await write().catch((err) => { toast.error(`Save failed: ${err.message}`); return null; });
setPendingSaves((n) => n - 1);
return result;
};

const patchNode = async (patch) => {
const updated = await runSave(() => updateLoomNode(loom.id, episode.id, node.id, patch, { silent: true }));
if (updated) onLoomUpdate(updated);
return updated;
};
Expand All @@ -79,55 +88,65 @@ export default function LoomNodeEditor({ loom, episode, node, onLoomUpdate, onCl
return patchNode({ [key]: value });
};

const syncTransitionsFrom = (updatedLoom) => {
const saved = updatedLoom?.episodes.find((e) => e.id === episode.id)
?.nodes.find((n) => n.id === node.id)?.transitions;
if (!saved) return;
setForm((prev) => ({ ...prev, transitions: saved.map(toRow) }));
};

const saveTransitions = async (rows) => {
const payload = rowsToTransitions(rows);
const current = (node.transitions || []).map((t) => ({
id: t.id, targetNodeId: t.targetNodeId, intent: t.intent, triggers: t.triggers, description: t.description,
}));
if (JSON.stringify(payload) === JSON.stringify(current)) return;
const updated = await patchNode({ transitions: payload });
// Re-sync just the transition rows so server-minted ids replace the
// locally-added rows' missing ones (id churn otherwise re-mints per save).
syncTransitionsFrom(updated);
// Blur-save for one path. Skipped when the row already matches the record,
// so tabbing through a path doesn't rewrite the loom.
const saveTransition = async (row) => {
const saved = (node.transitions || []).find((t) => t.id === row.id);
const patch = rowToPatch(row);
// No record row to compare against means the panel is ahead of the loom in
// state, NOT that nothing changed — save rather than silently drop the edit.
if (saved && JSON.stringify(patch) === JSON.stringify(rowToPatch(toRow(saved)))) return;
const updated = await runSave(
() => updateLoomTransition(loom.id, episode.id, node.id, row.id, patch, { silent: true }),
);
if (updated) onLoomUpdate(updated);
};

// The save fires OUTSIDE the setState updater — StrictMode runs updaters
// twice, so a PATCH inside one double-fires.
const applyTransition = (index, patch, { save = false } = {}) => {
const transitions = form.transitions.map((t, i) => (i === index ? { ...t, ...patch } : t));
setForm((prev) => ({ ...prev, transitions }));
if (save) saveTransitions(transitions);
if (save) saveTransition(transitions[index]);
};

const removeTransition = (index) => {
const transitions = form.transitions.filter((_, i) => i !== index);
setForm((prev) => ({ ...prev, transitions }));
saveTransitions(transitions);
const removeTransition = async (row) => {
setForm((prev) => ({ ...prev, transitions: prev.transitions.filter((t) => t.id !== row.id) }));
const updated = await runSave(
() => deleteLoomTransition(loom.id, episode.id, node.id, row.id, { silent: true }),
);
// The row went out of the list before the round-trip; put the record back
// if the delete never landed, rather than leaving a path that only looks gone.
if (updated) onLoomUpdate(updated);
else setForm((prev) => ({ ...prev, transitions: (node.transitions || []).map(toRow) }));
};

const addTransition = () => {
// The row is created server-side first, so it arrives with its id already
// set and every later edit is a plain PATCH against it.
const addTransition = async () => {
const target = otherNodes[0];
if (!target) {
toast.error('Add another scene first — a path needs somewhere to go');
return;
}
setForm((prev) => ({
...prev,
transitions: [...prev.transitions, { targetNodeId: target.id, intent: '', triggersText: '', description: '' }],
}));
setAddingPath(true);
const result = await runSave(
() => addLoomTransition(loom.id, episode.id, node.id, { targetNodeId: target.id, intent: '' }, { silent: true }),
);
setAddingPath(false);
if (!result?.transition) return;
setForm((prev) => ({ ...prev, transitions: [...prev.transitions, toRow(result.transition)] }));
onLoomUpdate(result.loom);
};

const [runBranch, branching] = useAsyncAction(async () => {
const result = await branchLoomNode(loom.id, episode.id, node.id, { branchCount: 2 }, { silent: true });
onLoomUpdate(result.loom);
syncTransitionsFrom(result.loom);
// The AI writes new paths straight onto the record; this panel is keyed by
// node.id so it never remounts to pick them up.
const woven = result.loom?.episodes.find((e) => e.id === episode.id)
?.nodes.find((n) => n.id === node.id)?.transitions;
if (woven) setForm((prev) => ({ ...prev, transitions: woven.map(toRow) }));
toast.success('New branches woven');
}, { errorMessage: 'Branching failed' });

Expand Down Expand Up @@ -280,7 +299,12 @@ export default function LoomNodeEditor({ loom, episode, node, onLoomUpdate, onCl
{branching ? <Loader2 size={12} className="animate-spin" /> : <GitBranch size={12} />}
Branch with AI
</button>
<button type="button" onClick={addTransition} className="text-xs text-port-accent hover:underline">
<button
type="button"
onClick={addTransition}
disabled={addingPath}
className="text-xs text-port-accent hover:underline disabled:opacity-50"
>
+ Add path
</button>
</div>
Expand All @@ -290,19 +314,19 @@ export default function LoomNodeEditor({ loom, episode, node, onLoomUpdate, onCl
)}
<div className="space-y-3">
{form.transitions.map((tr, index) => (
<div key={tr.id || `new-${index}`} className="border border-port-border rounded p-2 space-y-2">
<div key={tr.id} className="border border-port-border rounded p-2 space-y-2">
<div className="flex items-center gap-2">
<input
className={fieldClass}
placeholder='Reader intent, e.g. "search the wreck"'
aria-label="Intent"
value={tr.intent}
onChange={(e) => applyTransition(index, { intent: e.target.value })}
onBlur={() => saveTransitions(form.transitions)}
onBlur={() => saveTransition(tr)}
/>
<button
type="button"
onClick={() => removeTransition(index)}
onClick={() => removeTransition(tr)}
className="text-port-text-muted hover:text-port-error shrink-0"
aria-label="Remove path"
>
Expand All @@ -325,7 +349,7 @@ export default function LoomNodeEditor({ loom, episode, node, onLoomUpdate, onCl
aria-label="Trigger phrasings"
value={tr.triggersText}
onChange={(e) => applyTransition(index, { triggersText: e.target.value })}
onBlur={() => saveTransitions(form.transitions)}
onBlur={() => saveTransition(tr)}
/>
</div>
))}
Expand Down
107 changes: 107 additions & 0 deletions client/src/components/fableloom/LoomNodeEditor.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

vi.mock('../../services/api', () => ({
addLoomTransition: vi.fn(),
branchLoomNode: vi.fn(),
deleteLoomNode: vi.fn(),
deleteLoomTransition: vi.fn(),
generateImage: vi.fn(),
updateLoomNode: vi.fn(),
updateLoomTransition: vi.fn(),
}));
vi.mock('../MediaImage', () => ({ default: () => null }));

import {
addLoomTransition, deleteLoomTransition, updateLoomNode, updateLoomTransition,
} from '../../services/api';
import LoomNodeEditor from './LoomNodeEditor';

const loom = { id: 'loom-1', name: 'Example Story', format: 'prose', styleNotes: '' };

// One scene with a single existing path, plus a second scene to point at.
const makeNodes = (transitions) => ([
{ id: 'n1', title: 'The Gate', prose: 'You stand before it.', transitions },
{ id: 'n2', title: 'Inside', prose: 'Torchlight.', transitions: [] },
]);

const existingPath = { id: 'tr-1', targetNodeId: 'n2', intent: 'enter', triggers: ['go in'], description: '' };

const renderEditor = (transitions = [existingPath]) => {
const nodes = makeNodes(transitions);
const episode = { id: 'ep-1', startNodeId: 'n1', nodes };
const onLoomUpdate = vi.fn();
render(
<LoomNodeEditor
loom={loom}
episode={episode}
node={nodes[0]}
onLoomUpdate={onLoomUpdate}
onClearSelection={() => {}}
/>,
);
return { onLoomUpdate };
};

beforeEach(() => vi.clearAllMocks());

describe('LoomNodeEditor paths', () => {
it('creates a path server-side first, so the new row already carries its id', async () => {
const user = userEvent.setup();
const minted = { id: 'tr-9', targetNodeId: 'n2', intent: '', triggers: [], description: '' };
addLoomTransition.mockResolvedValue({ loom: { id: 'loom-1' }, transition: minted });
const { onLoomUpdate } = renderEditor([]);

await user.click(screen.getByRole('button', { name: '+ Add path' }));

await waitFor(() => expect(addLoomTransition).toHaveBeenCalledTimes(1));
expect(addLoomTransition).toHaveBeenCalledWith(
'loom-1', 'ep-1', 'n1', { targetNodeId: 'n2', intent: '' }, { silent: true },
);
expect(onLoomUpdate).toHaveBeenCalledWith({ id: 'loom-1' });
await waitFor(() => expect(screen.getByText('Paths out (1)')).toBeInTheDocument());
// The whole-array node PATCH is not how a path is added any more.
expect(updateLoomNode).not.toHaveBeenCalled();
});

it('saves one edited row by id rather than replaying the array', async () => {
const user = userEvent.setup();
updateLoomTransition.mockResolvedValue({ id: 'loom-1' });
renderEditor();

const intent = screen.getByLabelText('Intent');
await user.clear(intent);
await user.type(intent, 'slip past');
await user.tab();

await waitFor(() => expect(updateLoomTransition).toHaveBeenCalledTimes(1));
expect(updateLoomTransition).toHaveBeenCalledWith('loom-1', 'ep-1', 'n1', 'tr-1', {
targetNodeId: 'n2', intent: 'slip past', triggers: ['go in'], description: '',
}, { silent: true });
expect(updateLoomNode).not.toHaveBeenCalled();
});

it('skips the round-trip when a blurred row still matches the record', async () => {
const user = userEvent.setup();
renderEditor();

await user.click(screen.getByLabelText('Intent'));
await user.tab();

expect(updateLoomTransition).not.toHaveBeenCalled();
});

it('deletes one path by id', async () => {
const user = userEvent.setup();
deleteLoomTransition.mockResolvedValue({ id: 'loom-1' });
const { onLoomUpdate } = renderEditor();

await user.click(screen.getByRole('button', { name: 'Remove path' }));

await waitFor(() => expect(deleteLoomTransition).toHaveBeenCalledTimes(1));
expect(deleteLoomTransition).toHaveBeenCalledWith('loom-1', 'ep-1', 'n1', 'tr-1', { silent: true });
expect(onLoomUpdate).toHaveBeenCalledWith({ id: 'loom-1' });
expect(screen.getByText('Paths out (0)')).toBeInTheDocument();
});
});
2 changes: 1 addition & 1 deletion client/src/services/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ toasts on throw). **Custom catch ⇒ `silent: true`** — otherwise toasts fire
| `apiMediaJobs.js` | Media generation job tracking + `refineMediaPrompt` / `promptFromMedia` (vision reverse-prompt). |
| `apiCreativeDirector.js` | Creative Director (video production). |
| `apiCreativeCommission.js` | Creative Commissions (Autonomous Creation Engine — standing recurring briefs). |
| `apiFableLoom.js` | FableLoom branching narratives — loom/episode/scene-node CRUD, deterministic graph validation, and the AI lanes (weave, branch, review, play turns). |
| `apiFableLoom.js` | FableLoom branching narratives — loom/episode/scene-node/transition CRUD, deterministic graph validation, and the AI lanes (weave, branch, review, play turns). |
| `apiGames.js` | Game studio records, managed-app binding, reusable sprite/music bindings, deterministic asset-bundle compilation/integrity preflight, and AI feedback history. |
| `apiMusicVideo.js` | Music Video projects + scene board + audio analysis. |
| `apiSprites.js` | Sprite Manager records, asset library, production-set import (#2895), reference workflow: create/generate/lock (#2896), directional walk and per-track generation/approval, animation-type definition CRUD (#3153), trim/postprocess, and per-run source-frame listing for the Loop Trimmer's re-derive (#2980). |
Expand Down
19 changes: 19 additions & 0 deletions client/src/services/apiFableLoom.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const episodePath = (id, episodeId, rest = '') =>
loomPath(id, `/episodes/${encodeURIComponent(episodeId)}${rest}`);
const nodePath = (id, episodeId, nodeId, rest = '') =>
episodePath(id, episodeId, `/nodes/${encodeURIComponent(nodeId)}${rest}`);
const transitionPath = (id, episodeId, nodeId, transitionId) =>
nodePath(id, episodeId, nodeId, `/transitions/${encodeURIComponent(transitionId)}`);

export const listLooms = (options = {}) => request('/fableloom', options);
export const getLoom = (id, options = {}) => request(loomPath(id), options);
Expand Down Expand Up @@ -41,6 +43,23 @@ export const deleteLoomNode = (id, episodeId, nodeId, options = {}) => request(n
method: 'DELETE', ...options,
});

// One path out of a scene per call. `addLoomTransition` resolves to
// `{ loom, transition }` — the row carries its server-minted id, so the editor
// never has to reconcile ids back into locally-added rows. The node PATCH's
// whole-array `transitions` key still works for bulk replaces.
export const addLoomTransition = (id, episodeId, nodeId, body, options = {}) =>
request(nodePath(id, episodeId, nodeId, '/transitions'), {
method: 'POST', body: JSON.stringify(body), ...options,
});
export const updateLoomTransition = (id, episodeId, nodeId, transitionId, patch, options = {}) =>
request(transitionPath(id, episodeId, nodeId, transitionId), {
method: 'PATCH', body: JSON.stringify(patch), ...options,
});
export const deleteLoomTransition = (id, episodeId, nodeId, transitionId, options = {}) =>
request(transitionPath(id, episodeId, nodeId, transitionId), {
method: 'DELETE', ...options,
});

export const weaveLoomEpisode = (id, episodeId, body = {}, options = {}) => request(episodePath(id, episodeId, '/weave'), {
method: 'POST', body: JSON.stringify(body), ...options,
});
Expand Down
22 changes: 22 additions & 0 deletions client/src/services/apiFableLoom.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,28 @@ describe('apiFableLoom', () => {
expect(request).toHaveBeenCalledWith('/fableloom/loom-1/episodes/ep-1/validate', { silent: true });
});

it('posts a new path to the node transitions sub-resource', async () => {
await api.addLoomTransition('loom-1', 'ep-1', 'node-1', { targetNodeId: 'node-2', intent: '' }, { silent: true });
expect(request).toHaveBeenCalledWith('/fableloom/loom-1/episodes/ep-1/nodes/node-1/transitions', {
method: 'POST',
body: JSON.stringify({ targetNodeId: 'node-2', intent: '' }),
silent: true,
});
});

it('patches and deletes one path by id, encoding every segment', async () => {
await api.updateLoomTransition('loom-1', 'ep-1', 'node-1', 'tr/1', { intent: 'press on' });
expect(request).toHaveBeenCalledWith('/fableloom/loom-1/episodes/ep-1/nodes/node-1/transitions/tr%2F1', {
method: 'PATCH',
body: JSON.stringify({ intent: 'press on' }),
});

await api.deleteLoomTransition('loom-1', 'ep-1', 'node-1', 'tr-1');
expect(request).toHaveBeenCalledWith('/fableloom/loom-1/episodes/ep-1/nodes/node-1/transitions/tr-1', {
method: 'DELETE',
});
});

it('deletes nodes with DELETE', async () => {
await api.deleteLoomNode('loom-1', 'ep-1', 'node-1');
expect(request).toHaveBeenCalledWith('/fableloom/loom-1/episodes/ep-1/nodes/node-1', { method: 'DELETE' });
Expand Down
Loading
Loading