fix(ui): apply round-3 review findings (composer caches)

- ui: _paintFromCache returns its async-repaint promise;
  _paintProjectPicker routes through it (fork/hint stay bespoke) and the
  dashboard chains an Options-chip recompute on EVERY paint — an async
  repaint can drop a server-removed pick (or revert persona to its kind
  default) without firing 'change', and the chip must always name what
  submit will send
- ui/console: the false "never worse than the pre-cache behavior" claim
  replaced with the accepted-tradeoff ruling for module-load failure
  (no per-picker retry — cache-busted re-imports split-brain the cache;
  no inline-fetch fallback — that resurrects the deleted dual path)
- console: _paintHomeFromCache collapses the four verbatim
  _refreshAndPopulate* wrapper bodies; _restorePick collapses the four
  preserve-pick blocks (persona keeps its kind-default revert, now
  pinned by a test)
- models/skills: drop the consumer-less loaded/error readers from the
  modules + bridges (same omitted-not-exposed doctrine as onChange;
  projects/personas keep theirs as pre-existing public surface)
- tests: boot-order guard pins ALL FOUR data-layer module tags before
  shell.js (the boot anchor) in both index.html; wrapper/project-picker
  guards redirected to the chokepoints; chip-recompute chains asserted
This commit is contained in:
Patrick Buckley
2026-07-19 02:21:38 -07:00
parent 0d8be572c7
commit 1d144c331b
5 changed files with 214 additions and 173 deletions
+95 -61
View File
@@ -2446,39 +2446,44 @@ def test_dashboard_paints_project_and_persona_from_cache_synchronously() -> None
def test_console_launcher_paints_project_and_persona_from_cache_synchronously() -> None:
"""FOUC fix (console launcher composer): the launcher's project + persona
pickers paint synchronously from the warm shared cache before the async
refresh-and-repaint, mirroring the standalone app. The console launcher keeps
its ungated 'No project' semantics (§8); this only adds the sync pre-paint."""
"""FOUC fix (console launcher composer): the project + persona wrappers route
through the _paintHomeFromCache chokepoint sync paint from the warm cache,
then refresh(callOpts)-and-repaint; the ordering discipline is asserted ONCE
on the helper (in the model/skill twin test). Each wrapper must pair its OWN
bridge refresh with its OWN populate helper. Also pins the persona
kind-default revert: _populateHomePersonaDropdown must fall back to
defaultPersona(kind) when the previous pick is no longer a valid choice (the
interactive/coordinator persona shelves are disjoint), or a kind toggle
silently degrades the picker to a bare placeholder."""
body = _CONSOLE_APP_JS.read_text(encoding="utf-8")
proj_fn = _slice_top_level_fn(body, "function _refreshAndPopulateProjects(")
# The bare _populateHomeProjectDropdown() call is the sync paint; the refresh's
# .then argument has no parens, so this matches only the standalone sync call.
sync_proj = proj_fn.find("_populateHomeProjectDropdown()")
async_proj = proj_fn.find("refreshProjects(callOpts).then")
assert sync_proj >= 0, "the launcher must paint the project picker from cache synchronously"
assert async_proj >= 0, "the launcher must keep refreshProjects(callOpts).then"
assert sync_proj < async_proj, (
"the synchronous launcher project paint must precede the async refresh repaint"
)
assert re.search(
r"_paintHomeFromCache\(\s*TP && TP\.refreshProjects,\s*_populateHomeProjectDropdown",
proj_fn,
), "the launcher project wrapper must pair its bridge refresh + populate via the chokepoint"
persona_fn = _slice_top_level_fn(body, "function _refreshAndPopulatePersonas(")
sync_persona = persona_fn.find("_populateHomePersonaDropdown()")
async_persona = persona_fn.find("refreshPersonas(callOpts).then")
assert sync_persona >= 0, "the launcher must paint the persona picker from cache synchronously"
assert async_persona >= 0, "the launcher must keep refreshPersonas(callOpts).then"
assert sync_persona < async_persona, (
"the synchronous launcher persona paint must precede the async refresh repaint"
assert re.search(
r"_paintHomeFromCache\(\s*TP && TP\.refreshPersonas,\s*_populateHomePersonaDropdown",
persona_fn,
), "the launcher persona wrapper must pair its bridge refresh + populate via the chokepoint"
pop = _slice_top_level_fn(body, "function _populateHomePersonaDropdown(")
assert '_restorePick("persona"' in pop, (
"the persona populate must restore a still-valid pick via _restorePick"
)
assert "defaultPersona(_launcherKind)" in pop, (
"the persona populate must revert to the kind default when the pick is gone"
)
def test_paint_project_picker_syncs_before_refresh() -> None:
"""The shared _paintProjectPicker (used by BOTH the modal and dashboard, so
the two can't drift and silently re-introduce the FOUC) seeds the required/
optional hint + paints the picker via _populateProjectSelect SYNCHRONOUSLY,
then refreshes-and-repaints. It skips a fork, and both paints route through
the same _populateProjectSelect (preserving the #867 strict-picker invariant).
The sync paint passes freshOnOpen; the async repaint MUST pass fresh=false, or
it clobbers a project the user picked mid-refresh (require_project -> 400)."""
optional hint + paints via _populateProjectSelect, and routes its
sync-then-refresh tail through the _paintFromCache chokepoint — where the
sync-before-async + always-fresh:false discipline is asserted once. Its
fork/absent-bridge path returns a resolved promise so the dashboard's chained
Options-chip recompute still runs. Both paints reuse _populateProjectSelect
(preserving the #867 strict-picker invariant)."""
body = _APP_JS.read_text(encoding="utf-8")
fn = _slice_top_level_fn(body, "function _paintProjectPicker(")
assert "_populateProjectSelect(" in fn, (
@@ -2486,15 +2491,13 @@ def test_paint_project_picker_syncs_before_refresh() -> None:
)
assert "hint.textContent" in fn, "_paintProjectPicker must seed the required/optional hint"
assert "opts.fork" in fn, "_paintProjectPicker must skip for a fork"
sync_call = fn.find("paint(freshOnOpen)")
async_refresh = fn.find("refreshProjects().then")
assert async_refresh >= 0, "_paintProjectPicker must keep refreshProjects().then"
assert 0 <= sync_call < async_refresh, (
"_paintProjectPicker must paint synchronously (paint(freshOnOpen)) BEFORE the "
"async refreshProjects().then repaint"
assert "return Promise.resolve();" in fn, (
"_paintProjectPicker's fork/absent-bridge path must return a resolved "
"promise (the dashboard chains its Options-chip recompute on the return)"
)
assert "paint(false)" in fn[async_refresh:], (
"_paintProjectPicker's async repaint must pass fresh=false (preserve a mid-window pick)"
assert "return _paintFromCache(window.TurnstoneProjects.refreshProjects, paint, opts)" in fn, (
"_paintProjectPicker's tail must route through the _paintFromCache "
"chokepoint (sync paint(freshOnOpen) then always-fresh:false repaint)"
)
@@ -2537,6 +2540,17 @@ def test_paint_model_and_skill_wrappers_sync_before_refresh() -> None:
"_paintFromCache's async repaint must pass fresh:false (never leak "
"freshOnOpen, or it clobbers a mid-window pick)"
)
# Promise contract: callers (the dashboard Options-chip recompute) chain on
# the async repaint landing; the no-refresh path must still hand back a
# resolved promise or the chain throws on a cold bridge.
assert "return Promise.resolve()" in helper, (
"_paintFromCache must return an already-resolved promise when there is "
"no refresh (dashboard callers chain the Options-chip recompute)"
)
assert "return refresh().then" in helper, (
"_paintFromCache must return the async-repaint promise (callers act "
"after the repaint lands)"
)
for wrapper, bridge_refresh, populate in (
(
"function _paintModelSelects(",
@@ -2555,7 +2569,7 @@ def test_paint_model_and_skill_wrappers_sync_before_refresh() -> None:
),
):
fn = _slice_top_level_fn(body, wrapper)
assert "_paintFromCache(" in fn, f"{wrapper} must route through _paintFromCache"
assert "return _paintFromCache(" in fn, f"{wrapper} must return the _paintFromCache promise"
assert bridge_refresh in fn, f"{wrapper} must pass its own bridge refresh"
assert populate in fn, f"{wrapper} must thread fresh through to its own populate helper"
@@ -2593,17 +2607,23 @@ def test_new_ws_modal_renders_all_selects_fresh_on_open() -> None:
def test_dashboard_paints_model_and_skill_via_wrappers_preserving() -> None:
"""Dashboard twin — model + skill paint via the SAME wrappers but freshOnOpen:false
(a persistent panel preserves a pick across a repaint), and the old fetch-once
``options.length <= 1`` guard is gone."""
"""Dashboard twin — all four pickers paint via the SAME wrappers but
freshOnOpen:false (a persistent panel preserves a pick across a repaint), the
old fetch-once ``options.length <= 1`` guard is gone, and EVERY paint chains
_refreshDashboardOptionsSummary on its async repaint: a repaint can drop a
server-removed pick (or revert persona to its kind default) without firing
'change', and the collapsed Options chip must always name what submit sends."""
body = _APP_JS.read_text(encoding="utf-8")
fn = _slice_top_level_fn(body, "function _loadDashboardOptionsLists(")
assert "_paintModelSelects(modelSel, judgeSel, { freshOnOpen: false })" in fn, (
"the dashboard must paint model+judge via the wrapper, preserving (freshOnOpen:false)"
)
assert "_paintSkillSelect(skillSel, { freshOnOpen: false })" in fn, (
"the dashboard must paint skill via the wrapper, preserving"
)
for paint in (
"_paintModelSelects(modelSel, judgeSel, { freshOnOpen: false })",
"_paintSkillSelect(skillSel, { freshOnOpen: false })",
"_paintProjectPicker(projSel, projHint, { fork: false })",
"_paintPersonaSelect(personaSel, { freshOnOpen: false })",
):
assert re.search(re.escape(paint) + r"\.then\(\s*_refreshDashboardOptionsSummary", fn), (
f"dashboard paint must chain the Options-chip recompute: {paint[:36]}"
)
assert "options.length <= 1" not in fn, (
"the dashboard model/skill fetch-once guard must be removed (refresh-on-open now)"
)
@@ -2645,23 +2665,28 @@ def test_new_ws_modal_fork_inherits_model_and_judge() -> None:
def test_console_launcher_paints_model_and_skill_from_cache_synchronously() -> None:
"""Console launcher — the model + skill refresh wrappers paint synchronously
from the warm cache before the async refresh-and-repaint. The model wrapper
threads callOpts so models_changed can force a trailing refresh."""
"""Console launcher — the four _refreshAndPopulate* wrappers route through the
_paintHomeFromCache chokepoint: sync paint from the warm cache BEFORE the
async refresh(callOpts)-and-repaint (callOpts threads {force:true} for the
models_changed / onLoginSuccess invalidation callers). The ordering
discipline is asserted once, on the helper; the wrappers are pairing-checked
(model/skill here, project/persona in their twin test)."""
body = _CONSOLE_APP_JS.read_text(encoding="utf-8")
helper = _slice_top_level_fn(body, "function _paintHomeFromCache(")
sync = helper.find("populate()")
async_ = helper.find("refresh(callOpts).then")
assert 0 <= sync < async_, (
"_paintHomeFromCache must sync-paint (populate()) BEFORE the async "
"refresh(callOpts).then repaint"
)
model_fn = _slice_top_level_fn(body, "function _refreshAndPopulateModels(")
sync_model = model_fn.find("_populateHomeModelDropdowns()")
async_model = model_fn.find("refreshModels(callOpts).then")
assert sync_model >= 0, "the launcher must paint the model pickers from cache synchronously"
assert sync_model < async_model, (
"the synchronous launcher model paint must precede the async refresh repaint"
)
assert re.search(
r"_paintHomeFromCache\(\s*TM && TM\.refreshModels,\s*_populateHomeModelDropdowns", model_fn
), "the launcher model wrapper must pair its bridge refresh + populate via the chokepoint"
skill_fn = _slice_top_level_fn(body, "function _refreshAndPopulateSkills(")
sync_skill = skill_fn.find("_populateHomeSkillDropdown()")
async_skill = skill_fn.find("refreshSkills(callOpts).then")
assert 0 <= sync_skill < async_skill, (
"the synchronous launcher skill paint must precede the async refresh repaint"
)
assert re.search(
r"_paintHomeFromCache\(\s*TS && TS\.refreshSkills,\s*_populateHomeSkillDropdown", skill_fn
), "the launcher skill wrapper must pair its bridge refresh + populate via the chokepoint"
def test_console_relogin_rewarms_all_four_caches_with_force() -> None:
@@ -2723,13 +2748,22 @@ def test_models_label_centralized_on_bridge() -> None:
def test_models_skills_module_tagged_in_both_apps() -> None:
"""models.js + skills.js are ``<script type=module>``-tagged before app.js in
BOTH index.html so their window bridges install before the classic bundle
boots and reads window.TurnstoneModels / window.TurnstoneSkills."""
"""All four data-layer modules (projects/personas/models/skills) are
``<script type=module>``-tagged BEFORE /shared/shell.js in BOTH index.html.
shell.js is the LAST module tag and calls TS_APP.boot (the first dashboard /
launcher paint); module execution follows tag order, and classic app.js is
parse-time definitions only — so a data-layer tag reordered below shell.js
boots the app before that bridge installs: the sync paint no-ops AND the
refresh is skipped, a silent test-green FOUC regression without this guard."""
for idx in (_INDEX_HTML, _CONSOLE_INDEX):
html = idx.read_text(encoding="utf-8")
assert "/shared/models.js" in html, f"models.js must be module-tagged in {idx.name}"
assert "/shared/skills.js" in html, f"skills.js must be module-tagged in {idx.name}"
for mod in ("projects", "personas", "models", "skills"):
path = f"/shared/{mod}.js"
assert path in html, f"{mod}.js must be module-tagged in {idx.name}"
assert html.index(path) < html.index("/shared/shell.js"), (
f"{mod}.js must be tagged BEFORE shell.js in {idx.name} — its "
"bridge must install before TS_APP.boot paints the composers"
)
def test_list_cache_core_is_failopen_coalesced_and_gated() -> None:
+58 -52
View File
@@ -1497,20 +1497,51 @@ function _ensureHomeComposerInit() {
_refreshHomeComposerVisibility();
}
// One chokepoint for the launcher paint discipline shared by the four
// _refreshAndPopulate* wrappers below: sync-paint NOW from the warm cache (no
// empty flash for the refresh round-trip), then refresh-and-repaint (callOpts
// threads {force:true} for invalidation callers — models_changed,
// onLoginSuccess). `refresh` absent = bridge missing (module still loading /
// pre-auth): the wrapper no-ops and onLoginSuccess re-runs each once auth
// lands. If the module graph itself failed to load, the picker stays empty
// until a reload — the ACCEPTED shared-cache tradeoff; see _paintFromCache in
// ui/static/app.js for the full ruling (no per-picker retry, no inline-fetch
// fallback).
function _paintHomeFromCache(refresh, populate, callOpts) {
if (!refresh) return;
populate();
refresh(callOpts).then(function () {
populate();
});
}
// Restore a launcher-composer option pick after a choices rebuild IF it is
// still a valid choice; returns whether it was restored (the persona caller
// reverts to the kind default otherwise). Console-composer setter idiom
// ONLY — the ui twin (_populatePersonaSelect etc.) works on raw <select>
// elements via sel.value/_optionExists and is deliberately NOT unified
// across files.
function _restorePick(fieldId, previous, choices) {
const still =
previous &&
choices.some(function (c) {
return c.value === previous;
});
if (still) _homeCoordComposer.setOptionValue(fieldId, previous);
return !!still;
}
// Refresh the shared personas cache (window.TurnstonePersonas — also feeds
// the saved-list / rail labels) then repaint the launcher's Persona picker.
// Safe when the bridge is absent (module still loading): the picker keeps
// its "Default" placeholder, which the server resolves to the kind default.
function _refreshAndPopulatePersonas(callOpts) {
const TP = window.TurnstonePersonas;
if (!TP) return;
// Paint from the warm cache SYNCHRONOUSLY first so the Persona picker isn't
// empty for the refresh round-trip (the rail warms personas at startup), then
// refresh-and-repaint to catch a persona created elsewhere. _populateHome-
// PersonaDropdown preserves a mid-window pick and only applies the kind default
// when nothing valid is selected, so the second paint can't clobber a choice.
_populateHomePersonaDropdown();
TP.refreshPersonas(callOpts).then(_populateHomePersonaDropdown);
_paintHomeFromCache(
TP && TP.refreshPersonas,
_populateHomePersonaDropdown,
callOpts,
);
}
// Populate the launcher's Persona picker for the ACTIVE kind, preselecting
@@ -1524,14 +1555,7 @@ function _populateHomePersonaDropdown() {
const previous = _homeCoordComposer.getOptionValue("persona");
const choices = TP.personaChoices(_launcherKind);
_homeCoordComposer.setOptionChoices("persona", choices);
const stillValid =
previous &&
choices.some(function (c) {
return c.value === previous;
});
if (stillValid) {
_homeCoordComposer.setOptionValue("persona", previous);
} else {
if (!_restorePick("persona", previous, choices)) {
const dflt = TP.defaultPersona(_launcherKind);
if (dflt) _homeCoordComposer.setOptionValue("persona", dflt.name);
}
@@ -1543,14 +1567,11 @@ function _populateHomePersonaDropdown() {
// picker simply keeps its "No project" placeholder.
function _refreshAndPopulateProjects(callOpts) {
const TP = window.TurnstoneProjects;
if (!TP) return;
// Sync paint from the warm cache first (the launcher keeps its "No project"
// placeholder when cold), then refresh-and-repaint to catch a project created
// elsewhere. _populateHomeProjectDropdown preserves a mid-window pick. The
// console launcher intentionally does NOT gate on requireProject() (§8) — the
// node create endpoint is the authoritative gate.
_populateHomeProjectDropdown();
TP.refreshProjects(callOpts).then(_populateHomeProjectDropdown);
_paintHomeFromCache(
TP && TP.refreshProjects,
_populateHomeProjectDropdown,
callOpts,
);
}
// Populate the launcher's Project picker from the shared cache, preserving the
@@ -1742,9 +1763,11 @@ function _mountHomeCoordComposer() {
// this once auth lands).
function _refreshAndPopulateSkills(callOpts) {
const TS = window.TurnstoneSkills;
if (!TS) return;
_populateHomeSkillDropdown();
TS.refreshSkills(callOpts).then(_populateHomeSkillDropdown);
_paintHomeFromCache(
TS && TS.refreshSkills,
_populateHomeSkillDropdown,
callOpts,
);
}
// Populate the launcher's Skill picker from the shared cache, preserving the
@@ -1769,12 +1792,7 @@ function _populateHomeSkillDropdown() {
};
});
_homeCoordComposer.setOptionChoices("skill", choices);
const stillValid =
previous &&
choices.some(function (c) {
return c.value === previous;
});
if (stillValid) _homeCoordComposer.setOptionValue("skill", previous);
_restorePick("skill", previous, choices);
}
// Refresh the shared models cache then repaint the launcher's Model + Judge
@@ -1786,9 +1804,11 @@ function _populateHomeSkillDropdown() {
// (subscription) path that would double-repaint.
function _refreshAndPopulateModels(callOpts) {
const TM = window.TurnstoneModels;
if (!TM) return;
_populateHomeModelDropdowns();
TM.refreshModels(callOpts).then(_populateHomeModelDropdowns);
_paintHomeFromCache(
TM && TM.refreshModels,
_populateHomeModelDropdowns,
callOpts,
);
}
// Populate the launcher's Model + Judge Model pickers from the shared cache —
@@ -1829,22 +1849,8 @@ function _populateHomeModelDropdowns() {
judgeDefault ? "Default — " + judgeDefault : "Default model",
);
// Preserve a mid-window pick on each select independently.
if (
prevModel &&
choices.some(function (c) {
return c.value === prevModel;
})
) {
_homeCoordComposer.setOptionValue("model", prevModel);
}
if (
prevJudge &&
choices.some(function (c) {
return c.value === prevJudge;
})
) {
_homeCoordComposer.setOptionValue("judge_model", prevJudge);
}
_restorePick("model", prevModel, choices);
_restorePick("judge_model", prevJudge, choices);
}
function _refreshHomeComposerVisibility() {
+7 -18
View File
@@ -61,9 +61,10 @@ const _core = makeListCache({
* Fetch /v1/api/models into the cache. Resolves to the row list and NEVER
* rejects — a failed/forbidden fetch keeps the prior cache (a picker never
* blanks) AND keeps the last-known default aliases; a first-load failure still
* shows all-"". Recorded (see {@link modelsError}) rather than masqueraded as
* "no models". Pass `{force:true}` to force a fresh fetch that converges to the
* latest server state even mid-flight (the `models_changed` SSE path uses this).
* shows all-"". Failures are warned and fail open (see list_cache.js) rather
* than masqueraded as "no models". Pass `{force:true}` to force a fresh fetch
* that converges to the latest server state even mid-flight (the
* `models_changed` SSE path uses this).
*/
export function refreshModels(callOpts) {
return _core.refresh(callOpts);
@@ -75,18 +76,6 @@ export function getModels() {
return _core.get();
}
/** Whether the first refresh has resolved — distinguishes "no models" from
* "not loaded yet". */
export function modelsLoaded() {
return _core.loaded();
}
/** Last refresh failure status (HTTP status, 0 for network/parse), or null
* when the last refresh succeeded. */
export function modelsError() {
return _core.error();
}
// The one place the "alias (model)" label (or just "alias" when they coincide)
// is formatted — modelChoices, modelLabel, and every composer placeholder
// annotation resolve through here so the option labels and the "Default — …"
@@ -127,14 +116,14 @@ export function modelDefaults() {
// No onChange subscription is exposed (unlike projects.js / personas.js, whose
// onChange is consumed by rail.js): the models cache has no live-render consumer
// — the console repaints on `models_changed` via its own direct handler, the ui
// composers repaint on open. Omitted rather than exposed-and-unused.
// composers repaint on open. The loaded/error readers are omitted for the same
// no-consumer reason; projects.js/personas.js keep theirs as pre-existing
// public surface. Omitted rather than exposed-and-unused.
// Classic (non-module) app.js bundles reach the data layer through this bridge.
window.TurnstoneModels = {
refreshModels: refreshModels,
getModels: getModels,
modelsLoaded: modelsLoaded,
modelsError: modelsError,
modelChoices: modelChoices,
modelLabel: modelLabel,
modelDefaults: modelDefaults,
+4 -16
View File
@@ -36,8 +36,8 @@ const _core = makeListCache({
/**
* Fetch /v1/api/skills into the cache. Resolves to the row list and NEVER
* rejects — a failed/forbidden fetch keeps the prior cache (a picker never
* blanks). Recorded (see {@link skillsError}) rather than masqueraded as "no
* skills". Pass `{force:true}` to force a fresh fetch that converges to the
* blanks). Failures are warned and fail open (see list_cache.js) rather than
* masqueraded as "no skills". Pass `{force:true}` to force a fresh fetch that converges to the
* latest even mid-flight (onLoginSuccess uses this to recover a failed pre-auth
* warm — skills has no *_changed event to recover otherwise).
*/
@@ -51,26 +51,14 @@ export function getSkills() {
return _core.get();
}
/** Whether the first refresh has resolved — distinguishes "no skills" from
* "not loaded yet". */
export function skillsLoaded() {
return _core.loaded();
}
/** Last refresh failure status (HTTP status, 0 for network/parse), or null
* when the last refresh succeeded. */
export function skillsError() {
return _core.error();
}
// No onChange subscription is exposed (unlike projects.js / personas.js): the
// skills cache has no live-render consumer — the composers repaint on open.
// The loaded/error readers are omitted for the same no-consumer reason;
// projects.js/personas.js keep theirs as pre-existing public surface.
// Omitted rather than exposed-and-unused.
// Classic (non-module) app.js bundles reach the data layer through this bridge.
window.TurnstoneSkills = {
refreshSkills: refreshSkills,
getSkills: getSkills,
skillsLoaded: skillsLoaded,
skillsError: skillsError,
};
+50 -26
View File
@@ -527,8 +527,11 @@ function _optionExists(sel, val) {
// skips entirely (a fork inherits its source's project server-side; the modal
// hides the picker for forks). Both paints reuse _populateProjectSelect, so the
// #867 strict-picker invariant (never auto-select a real project) holds on each.
// The sync-then-refresh tail routes through _paintFromCache (the fork skip and
// the hint stay bespoke here); returns its async-repaint promise.
function _paintProjectPicker(sel, hint, opts) {
if ((opts && opts.fork) || !sel || !window.TurnstoneProjects) return;
if ((opts && opts.fork) || !sel || !window.TurnstoneProjects)
return Promise.resolve();
// paint(fresh): a fresh open renders the actual default (no prior selection);
// the async repaint MUST pass fresh=false, or it clobbers a project the user
// picked during the refresh round-trip — under require_project that reconciles
@@ -538,36 +541,41 @@ function _paintProjectPicker(sel, hint, opts) {
if (hint) hint.textContent = strict ? "required" : "optional";
_populateProjectSelect(sel, { requireProject: strict, fresh: fresh });
};
const freshOnOpen = !!(opts && opts.freshOnOpen);
paint(freshOnOpen); // sync from the warm cache (no-op when cold; the async fills it)
window.TurnstoneProjects.refreshProjects().then(function () {
paint(false);
});
return _paintFromCache(window.TurnstoneProjects.refreshProjects, paint, opts);
}
// One chokepoint for the composer paint discipline, shared by the three
// cache-backed wrappers below (mirrors _paintProjectPicker, which keeps its own
// tail — fork/hint logic): sync-paint NOW from the warm cache, then
// refresh-and-repaint. The sync paint mirrors the caller's freshOnOpen (a
// reused-dialog open renders the actual defaults); the async repaint is ALWAYS
// fresh:false — it must preserve a pick made during the fetch window, never
// re-blank. `refresh` may be absent (bridge module still loading): the
// populate helpers no-op without their bridge and the refresh is skipped —
// never worse than the pre-cache behavior.
// cache-backed wrappers below and _paintProjectPicker's tail: sync-paint NOW
// from the warm cache, then refresh-and-repaint. The sync paint mirrors the
// caller's freshOnOpen (a reused-dialog open renders the actual defaults); the
// async repaint is ALWAYS fresh:false — it must preserve a pick made during
// the fetch window, never re-blank. Returns the async-repaint promise
// (already-resolved when there is nothing to refresh) so a caller can act
// after the repaint lands — the dashboard chains its Options-chip recompute.
//
// `refresh` may be absent (bridge missing): the sync populate no-ops and the
// refresh is skipped. If the module graph itself failed to load (a failed
// /shared/models.js, skills.js, personas.js, projects.js, or list_cache.js
// fetch at page load), that picker stays empty until a reload — an ACCEPTED
// tradeoff of the shared-cache architecture, the same class the projects/
// personas pickers + rail have carried since #867/#868. Do NOT add a
// per-picker retry (a cache-busted dynamic re-import creates a second cache
// instance with split-brain state) or an inline-fetch fallback (it resurrects
// the dual-path this refactor deleted); surfacing a failed bridge, if ever
// wanted, belongs in the app shell for all bridges at once.
function _paintFromCache(refresh, repaint, opts) {
repaint(!!(opts && opts.freshOnOpen));
if (refresh) {
refresh().then(function () {
repaint(false);
});
}
if (!refresh) return Promise.resolve();
return refresh().then(function () {
repaint(false);
});
}
// Paint the model + judge pickers from the warm cache then refresh-and-repaint,
// collapsing the identical sync-then-refresh dance the modal and dashboard both
// need.
function _paintModelSelects(modelSel, judgeSel, opts) {
_paintFromCache(
return _paintFromCache(
window.TurnstoneModels && window.TurnstoneModels.refreshModels,
function (fresh) {
_populateModelSelect(modelSel, judgeSel, { fresh: fresh });
@@ -578,7 +586,7 @@ function _paintModelSelects(modelSel, judgeSel, opts) {
// Skill twin of _paintModelSelects.
function _paintSkillSelect(sel, opts) {
_paintFromCache(
return _paintFromCache(
window.TurnstoneSkills && window.TurnstoneSkills.refreshSkills,
function (fresh) {
_populateSkillSelect(sel, { fresh: fresh });
@@ -591,7 +599,7 @@ function _paintSkillSelect(sel, opts) {
// default when nothing valid is selected, so the fresh:false repaint can't
// clobber a mid-window pick.
function _paintPersonaSelect(sel, opts) {
_paintFromCache(
return _paintFromCache(
window.TurnstonePersonas && window.TurnstonePersonas.refreshPersonas,
function (fresh) {
_populatePersonaSelect(sel, { fresh: fresh });
@@ -1539,11 +1547,23 @@ function _loadDashboardOptionsLists() {
// (not a reused dialog), so freshOnOpen:false — it preserves a pick across a
// repaint. Previously fetch-once (guarded on options.length); now
// refresh-on-open via the coalesced caches, matching the project/persona pickers.
//
// EVERY paint chains a recompute of the collapsed Options chip on its async
// repaint: a repaint can drop a server-removed pick (the select falls back to
// its placeholder) or revert the persona to its kind default WITHOUT firing
// 'change' — the optionsPanel change listener covers user edits only — and
// the chip must always name what submit will send. The project paint is
// chained for symmetry/future chip coverage; _refreshDashboardOptionsSummary
// reads persona/model/judge/skill only today.
const modelSel = document.getElementById("dashboard-model");
const judgeSel = document.getElementById("dashboard-judge-model");
_paintModelSelects(modelSel, judgeSel, { freshOnOpen: false });
_paintModelSelects(modelSel, judgeSel, { freshOnOpen: false }).then(
_refreshDashboardOptionsSummary,
);
const skillSel = document.getElementById("dashboard-skill");
_paintSkillSelect(skillSel, { freshOnOpen: false });
_paintSkillSelect(skillSel, { freshOnOpen: false }).then(
_refreshDashboardOptionsSummary,
);
// Project picker — paint from the warm cache then refresh-and-repaint (also
// feeds the rail's group-by-project). Dashboard quick-create is ALWAYS a fresh
@@ -1551,13 +1571,17 @@ function _loadDashboardOptionsLists() {
const projSel = document.getElementById("dashboard-project");
const projLabel = document.querySelector('label[for="dashboard-project"]');
const projHint = projLabel ? projLabel.querySelector(".label-hint") : null;
_paintProjectPicker(projSel, projHint, { fork: false });
_paintProjectPicker(projSel, projHint, { fork: false }).then(
_refreshDashboardOptionsSummary,
);
// Persona picker — via the shared wrapper; the dashboard is a persistent panel
// so freshOnOpen:false (preserve a pick across a repaint), kind default when
// nothing valid is selected.
const personaSel = document.getElementById("dashboard-persona");
_paintPersonaSelect(personaSel, { freshOnOpen: false });
_paintPersonaSelect(personaSel, { freshOnOpen: false }).then(
_refreshDashboardOptionsSummary,
);
}
// localStorage key for the dashboard composer's Options-panel disclosure