fix: include in-memory templates in group participant character list#3217
fix: include in-memory templates in group participant character list#3217michaelxer wants to merge 4 commits into
Conversation
|
Thanks for testing this — you're right, the current fix doesn't fully resolve it. I looked more closely and the The real issue seems to be timing: when a new character is saved via
You're very welcome to build on this or take it over entirely — I'd be happy to close this PR if you'd like to submit a better fix. Just let me know how you'd like to proceed. |
|
Pushed a fix for the timing issue. The problem was that The new commit updates All 4 CI checks pass on the updated branch. |
|
Good call — I have pushed a fix for the timing race you found (the saveCustomPreset API call is async, so getUserTemplates could return stale data before it completes). The new commit does an optimistic in-memory update immediately. For the test, I can add one that verifies the in-memory userTemplates array is updated right after saveCustomPreset succeeds, before any API round-trip. Will push that shortly. |
|
Added 5 source-level tests verifying the fix:
These follow the repo's convention of asserting source patterns for DOM-coupled JS modules (same approach as test_notes_select_esc_listener_js.py). They'll catch any accidental regression of the in-memory merge or optimistic update. |
|
Hello! I apologize for the very late response. My work was longer than intended since a bug came up. I'll review the changes tomorrow, first thing in the morning, and then look at the source code, if it still doesn't work. Thank you! |
vdmkenny
left a comment
There was a problem hiding this comment.
Good root-cause fix, merging in-memory userTemplates into _getCharacterList() is the right call, and the dedup-by-id is correct. js + tests pass. Two things to fix before merge:
1. Make the optimistic update actually effective (it is currently a no-op for new characters). In saveCustomPreset the optimistic entry uses id: (_existing && _existing.id) || "", so a brand-new character gets id: "". But your merge guard in _getCharacterList is if (t.id && t.name && ...), which filters empty ids, and "" is not a usable <option> value anyway. So the exact case this PR targets (a just-created character) still will not appear until the POST round-trips and loadUserTemplates runs. The "sees the new character immediately, before the async POST completes" behavior does not actually happen yet.
The fix is clean because the server keeps a client-supplied id (POST /api/presets/templates only assigns one when id is empty): generate the id client-side using the existing user-<hex> convention, set it on the optimistic userTemplates entry, and include it in the POST body. Then the optimistic entry has a real id, passes the merge guard, and the dropdown shows the character immediately as intended, with no risk of server/client id mismatch.
2. Do not return the live array by reference. getUserTemplates() returns userTemplates directly, so any caller can mutate module state. Return a shallow copy (return [...userTemplates]). _getCharacterList only reads it today, but the export is now public API.
With those two done this is good to merge.
|
Both items addressed:
6 tests pass (added |
|
Hello! I'm currently working on the fix for repopulating the other existing selection drop-downs. |
|
Hello again! I have looked at the code base and finished the fixes for:
I will be documenting and uploading a video in this comment in a bit. Edit: While documenting, I found a bug in the state of the data. The recently added character is stored twice. It's what is being received from Edit: I added the documentation below. I will be creating a PR to this specific PR branch. Let me know what you guys think. 2026-06-08_17-05-36.mp4ChangesI changed the groupTab click listener logic. Below are the before and after comparisons. Initialization Logic of Selection Drop-downsThis change is made to not recreate rows continuously if there is no group formed yet, which is not, I believe is the intended behavior, and somewhat defeats the purpose of the Before: const groupTab = document.querySelector('.preset-tab[data-chartab="group"]');
if (groupTab) groupTab.addEventListener('click', () => {
_modelsCache = null;
if (startBtn) startBtn.textContent = 'Start Group';
_loadGroupPresets();
if (_groupParticipants.length === 0) {
setTimeout(() => addBtn.click(), 100);
}
});After: const groupTab = document.querySelector('.preset-tab[data-chartab="group"]');
// whenever a user navigates to the Group tab
if (groupTab) groupTab.addEventListener('click', () => {
_modelsCache = null;
if (startBtn) startBtn.textContent = 'Start Group';
_loadGroupPresets();
const isGroupTabUnInitialized =
_groupParticipants.length === 0 && participantsEl.children.length === 0;
if (isGroupTabUnInitialized) {
setTimeout(() => addBtn.click(), 100);
} else {
// queue this asynchronously since repopulating the selection drop-downs
// do not need to be visible right away; it can be safely delayed before
// the next event loop
queueMicrotask(() => {
repopulateExistingSelections();
})
}
});Repopulating Existing Selection Drop-downsThis change is necessary to reflect the latest changes of the added characters. An added bonus is that the model selection is also updated. The change is an added function, called from when the group tab button is clicked and it has already been initialized. Before: async function repopulateExistingSelections() {
const EMPTY = "";
const characterSelections = participantsEl.querySelectorAll("select.preset-input[data-selection-type=character");
const modelSelections = participantsEl.querySelectorAll("select.preset-input[data-selection-type=model");
if (characterSelections.length !== 0) {
const characters = await _getCharacterList();
characterSelections.forEach((characterSelection) => {
const chosenCharacter = characterSelection.value;
const isChosenCharacterExisting = chosenCharacter !== EMPTY
&& characters.findIndex((char) => char.id === chosenCharacter) !== -1;
characterSelection.innerHTML = '<option value="">Empty...</option>' +
characters.map(c => '<option value="' + c.id + '">' + uiModule.esc(c.name) + '</option>').join('');
if (isChosenCharacterExisting) {
characterSelection.value = chosenCharacter;
}
});
}
if (modelSelections.length !== 0) {
const models = await _getModels();
modelSelections.forEach((modelSelection) => {
const chosenModel = modelSelection.value;
const isChosenModelExisting = chosenModel !== EMPTY
&& models.findIndex((model) => model.mid === chosenModel) !== -1;
modelSelection.innerHTML = '<option value="">Model…</option>' +
models.map(m => '<option value="' + m.mid + '">' + uiModule.esc(m.display) + '</option>').join('');
if (isChosenModelExisting) {
modelSelection.vale = chosenModel;
}
});
}
}Add Identifier to Selection Drop-downsTo support the change above and have a more consistent way to target the selection drop-downs, an identifier is added using Before: const charSel = document.createElement('select');
charSel.className = 'preset-input';
charSel.style.cssText = 'font-size:11px;flex:1;height:26px;';
charSel.innerHTML = '<option value="">Empty...</option>' +
characters.map(c => '<option value="' + c.id + '">' + uiModule.esc(c.name) + '</option>').join('');
const modelSel = document.createElement('select');
modelSel.className = 'preset-input';
modelSel.style.cssText = 'font-size:11px;flex:1;height:26px;';
modelSel.innerHTML = '<option value="">Model…</option>' +
models.map(m => '<option value="' + m.mid + '">' + uiModule.esc(m.display) + '</option>').join('');After: const charSel = document.createElement('select');
charSel.className = 'preset-input';
// add an identifier that this is a character selection
charSel.dataset.selectionType = "character"
charSel.style.cssText = 'font-size:11px;flex:1;height:26px;';
charSel.innerHTML = '<option value="">Empty...</option>' +
characters.map(c => '<option value="' + c.id + '">' + uiModule.esc(c.name) + '</option>').join('');
const modelSel = document.createElement('select');
modelSel.className = 'preset-input';
// add an identifier that this is a model selection
modelSel.dataset.selectionType = "model"
modelSel.style.cssText = 'font-size:11px;flex:1;height:26px;';
modelSel.innerHTML = '<option value="">Model…</option>' +
models.map(m => '<option value="' + m.mid + '">' + uiModule.esc(m.display) + '</option>').join(''); |
|
@hinode-codes this is excellent investigation, thank you. The three issues you found are all real: existing dropdowns not being repopulated (only the newly-added row updates), the row re-creation on every Group-tab revisit, and the double-store of the just-added character in One request on logistics: rather than a PR into this branch (michaelxer's fork), please open a standalone PR against Context that may help: #3234 was an earlier attempt at the same repopulation idea (tab-focus refresh of existing dropdowns); it was closed as superseded by #3217, but your @michaelxer heads-up: depending on how hinode's PR shapes up, we may land theirs instead of or on top of yours for #3207. Will compare once it is up. |
Yeah. My bad, I actually couldn't open a PR here. I'm not that used to GitHub yet hahaha. Alright. I will reference this PR in a PR that I will open. I'll also sync up to the latest changes of the dev branch that also includes the changes in this PR (I'll check if it's possible). On that note, I'll also try to investigate the behavior of having a duplicate of the recently added character. |
dd8bd27 to
4699991
Compare
_getCharacterList() only fetched user templates from the /api/presets/templates endpoint. When a character was just created in the Character tab, the async auto-save to the templates API might not have completed by the time the Group tab loaded its participant dropdown — causing newly created characters to be missing. Now also merges the in-memory userTemplates array from presets.js as a fallback. These are updated as soon as the async save completes (via the loadUserTemplates callback), so they bridge the gap between character creation and API persistence. Fixes odysseus-dev#3207
Update the in-memory userTemplates array immediately when saveCustomPreset() succeeds, before the fire-and-forget templates API POST completes. This bridges the timing gap where _getCharacterList() calls getUserTemplates() and gets stale data because loadUserTemplates() hasn't been triggered yet.
Source-level guards for the odysseus-dev#3207 fix: - group.js imports and calls getUserTemplates() to merge in-memory templates - presets.js exports getUserTemplates and does optimistic in-memory update on save 5 tests ensuring the fix can't be silently reverted.
…py from getUserTemplates 1. New characters now get a 'user-<hex>' id immediately on save, matching the server's convention (uuid.uuid4().hex[:8]). Previously the id was '' which the merge guard in _getCharacterList filtered as falsy. 2. getUserTemplates() now returns [...userTemplates] so callers cannot accidentally mutate module state.
4699991 to
1ec37ee
Compare
|
Both requested changes are addressed: client-side id generation and shallow copy from getUserTemplates(). CI is green. Ready for re-review. |
|
Closing this in favor of the approach in #3424 which has more active discussion with the maintainers. Happy to revisit if needed. |


Summary
When a character is created in the Character tab, the save to the templates API is async (fire-and-forget). If the user switches to the Group tab before that request completes,
_getCharacterList()fetches from the API and misses the newly created character. ThegetAllPresets().custompath always shows the current custom character, which is why the "chosen character" appears consistently — but other saved templates can be missing.Fix: export
getUserTemplates()frompresets.js(returns the in-memoryuserTemplatesarray) and merge it as a fallback in_getCharacterList(). The in-memory array is updated as soon as any async template save completes (via theloadUserTemplatescallback), bridging the timing gap between character creation and API persistence. The existing dedup check (!chars.find(c => c.id === t.id)) prevents duplicates.Linked Issue
Fixes #3207
Type of Change
Checklist
How to Test
Notes
data.templates→Array.isArray(data) ? data : ...). This addresses the timing gap between async save and fetch.