fix(ui): apply round-2 review + fix-sanity findings (composer caches)

An unprimed convergence re-review found a real login-recovery seam gap plus
cleanups (round 1's fix round manufactured one of them); fix-sanity vetted the plan.

- console onLoginSuccess recovery seam [0]+[2]: it re-warmed only skills+models
  after an in-place login; projects+personas (same pre-auth-401 gap) stayed empty
  (rail group-by-project flat, saved-coordinator raw slugs). Now force-refreshes
  ALL FOUR caches on login — force so a still-in-flight failing pre-auth fetch
  yields a trailing AUTHENTICATED refetch rather than coalescing onto the 401
  (skills/personas have no *_changed event to recover). Threads an optional
  callOpts through the four cache modules + console wrappers (backward-compatible;
  every non-console caller passes nothing).

- fork skill paint [4]: the round-1 wrapper extraction left the modal skill paint
  unconditional on a fork (wasted GET /v1/api/skills + hidden-select rebuild);
  fork-gate it like model/persona/project.

- persona wrapper [5]: extract _paintPersonaSelect so all four composer pickers
  share the sync-then-refresh wrapper instead of persona being inline-duplicated.

- dead machinery [6]: remove the zero-subscriber onModelsChange/onSkillsChange and
  the models fpExtra fingerprint fold (and the now-orphaned core fpExtra branch).
  The console repaints models via its direct models_changed handler, not a
  subscription; the fold only fed the subscriber-only fingerprint.

- O(1) modelLabel [7]: index the models cache by alias (keyField) so modelLabel is
  a getByKey, not a per-paint scan.

Declines documented in-code: forks-inherit-model [1] (deliberate) and the
fail-open cache [3] (intended, same policy as projects/personas). Deferral comment
at the ui onLoginSuccess twin (recovers on dashboard re-focus; follow-up).

Tests: rewrote the 7 guards the code changes moved (persona relocation, callOpts
threading, force, fpExtra removal) preserving their ordering intent, and added
fork-skill-gate, persona-wrapper, all-four-force, keyField, and
onModelsChange-removed coverage. 106 pass; ruff + mypy green.
This commit is contained in:
Patrick Buckley
2026-07-18 23:20:36 -07:00
parent 8d57697b2a
commit c3beb202eb
8 changed files with 155 additions and 116 deletions
+56 -28
View File
@@ -2412,12 +2412,10 @@ def test_new_ws_modal_paints_project_and_persona_from_cache_synchronously() -> N
assert "_paintProjectPicker(projSelect" in fn, (
"the modal must paint the project picker via the shared _paintProjectPicker helper"
)
sync_persona = fn.find("_populatePersonaSelect(personaSelect")
async_persona = fn.find("refreshPersonas().then")
assert sync_persona >= 0, "the modal must paint the persona picker from cache synchronously"
assert async_persona >= 0, "the modal must keep refreshPersonas().then"
assert sync_persona < async_persona, (
"the synchronous persona paint must precede the async refreshPersonas().then repaint"
# Persona is painted via the shared _paintPersonaSelect wrapper (fork-gated);
# its sync-before-async ordering is pinned in the wrapper-internals test.
assert "_paintPersonaSelect(personaSelect" in fn, (
"the modal must paint the persona picker via the shared _paintPersonaSelect helper"
)
@@ -2433,12 +2431,9 @@ def test_dashboard_paints_project_and_persona_from_cache_synchronously() -> None
assert "_paintProjectPicker(projSel" in fn, (
"the dashboard must paint the project picker via the shared _paintProjectPicker helper"
)
sync_persona = fn.find("_populatePersonaSelect(personaSel")
async_persona = fn.find("refreshPersonas().then")
assert sync_persona >= 0, "the dashboard must paint the persona picker from cache synchronously"
assert async_persona >= 0, "the dashboard must keep refreshPersonas().then"
assert sync_persona < async_persona, (
"the synchronous dashboard persona paint must precede the async refresh repaint"
# Persona is painted via the shared _paintPersonaSelect wrapper (freshOnOpen:false).
assert "_paintPersonaSelect(personaSel, { freshOnOpen: false })" in fn, (
"the dashboard must paint the persona picker via _paintPersonaSelect (preserving)"
)
# require_project label-hint parity: the dashboard Project label gained a
# .label-hint span, seeded synchronously from requireProject() like the modal.
@@ -2460,17 +2455,17 @@ def test_console_launcher_paints_project_and_persona_from_cache_synchronously()
# 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().then")
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().then"
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"
)
persona_fn = _slice_top_level_fn(body, "function _refreshAndPopulatePersonas(")
sync_persona = persona_fn.find("_populateHomePersonaDropdown()")
async_persona = persona_fn.find("refreshPersonas().then")
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().then"
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"
)
@@ -2532,6 +2527,11 @@ def test_paint_model_and_skill_wrappers_sync_before_refresh() -> None:
for wrapper, populate, refresh in (
("function _paintModelSelects(", "_populateModelSelect(", "refreshModels().then"),
("function _paintSkillSelect(", "_populateSkillSelect(", "refreshSkills().then"),
(
"function _paintPersonaSelect(",
"_populatePersonaSelect(",
"refreshPersonas().then",
),
):
fn = _slice_top_level_fn(body, wrapper)
sync = fn.find(populate)
@@ -2557,8 +2557,8 @@ def test_new_ws_modal_renders_all_selects_fresh_on_open() -> None:
assert "_paintSkillSelect(tplSelect, { freshOnOpen: true })" in fn, (
"the modal must paint skill fresh-on-open"
)
assert "_populatePersonaSelect(personaSelect, { fresh: true })" in fn, (
"the modal must paint persona fresh-on-open (kind default, no stale carryover)"
assert "_paintPersonaSelect(personaSelect, { freshOnOpen: true })" in fn, (
"the modal must paint persona fresh-on-open via the shared wrapper"
)
proj = fn[fn.find("_paintProjectPicker(projSelect") :]
assert "freshOnOpen: true" in proj[:120], (
@@ -2601,6 +2601,17 @@ def test_new_ws_modal_fork_inherits_model_and_judge() -> None:
assert "judgeSelect.hidden = !!_forkFromWsId" in modal, (
"modal must hide the judge select for a fork"
)
# [4] fix: the skill paint is fork-gated too (skill is hidden for a fork).
# Tie the gate to the skill paint SPECIFICALLY — a first-gate check would be
# satisfied by the model gate at line ~352 even if the skill paint were left
# unconditional, so require the nearest preceding gate to be right above it.
skill_paint = modal.find("_paintSkillSelect(tplSelect")
assert skill_paint >= 0, "the modal must paint the skill picker"
gate = modal.rfind("if (!_forkFromWsId) {", 0, skill_paint)
assert gate >= 0 and (skill_paint - gate) < 50, (
"the modal skill paint must be directly wrapped in `if (!_forkFromWsId)` "
"(skip the wasted fetch + hidden-select rebuild on a fork)"
)
submit = _slice_top_level_fn(body, "function submitNewWs(")
assert "if (model && !_forkFromWsId) body.model = model;" in submit, (
"submitNewWs must fork-gate body.model (distinct from the judge line)"
@@ -2624,21 +2635,26 @@ def test_console_launcher_paints_model_and_skill_from_cache_synchronously() -> N
)
skill_fn = _slice_top_level_fn(body, "function _refreshAndPopulateSkills(")
sync_skill = skill_fn.find("_populateHomeSkillDropdown()")
async_skill = skill_fn.find("refreshSkills().then")
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"
)
def test_console_relogin_rewarms_model_and_skill() -> None:
"""onLoginSuccess re-warms BOTH the skill and model caches after auth lands
the boot-time pass runs pre-login (401), so without the model re-warm the
console model dropdown would stay at its placeholder until a reload."""
def test_console_relogin_rewarms_all_four_caches_with_force() -> None:
"""onLoginSuccess re-warms ALL FOUR composer caches after auth lands (the boot
pass runs pre-login -> 401 -> fail-open empty), EACH with {force:true} so a
still-in-flight failing pre-auth fetch yields a trailing authenticated refetch
(skills/personas have no *_changed event to recover otherwise). Fixes [0]+[2]."""
body = _CONSOLE_APP_JS.read_text(encoding="utf-8")
start = body.index("window.onLoginSuccess = function ()")
# The four re-warm calls live before the // Active-coordinators marker; an
# assert falling outside this slice fails loudly rather than silently passing.
login = body[start : body.index("// Active-coordinators", start)]
assert "_refreshAndPopulateSkills()" in login, "onLoginSuccess must re-warm skills"
assert "_refreshAndPopulateModels()" in login, "onLoginSuccess must re-warm models"
for name in ("Skills", "Models", "Projects", "Personas"):
assert f"_refreshAndPopulate{name}({{ force: true }})" in login, (
f"onLoginSuccess must force-re-warm {name.lower()} after login"
)
def test_models_changed_forces_trailing_single_repaint_path() -> None:
@@ -2668,6 +2684,13 @@ def test_models_label_centralized_on_bridge() -> None:
"models.js must register modelLabel on the window.TurnstoneModels bridge "
"(classic bundles call it via the bridge)"
)
# [7] fix: modelLabel resolves via the core's O(1) keyField index, not a scan.
assert 'keyField: "alias"' in models_src, (
"models.js must index by alias (keyField) so modelLabel is an O(1) getByKey"
)
assert "getByKey(alias)" in models_src, (
"modelLabel must resolve via the core's getByKey index (not a per-paint scan)"
)
assert "_resolveModelLabel" not in _APP_JS.read_text(encoding="utf-8"), (
"the ui app must not keep a local _resolveModelLabel (use TurnstoneModels.modelLabel)"
)
@@ -2730,14 +2753,19 @@ def test_reset_extra_policy_per_cache() -> None:
def test_models_cache_exposes_both_server_schemas() -> None:
"""models.js must carry ALL default-alias fields — the node server sends
default_alias, the console sends coordinator_default_alias, both send
judge_default_alias — so each app reads its own, and fold them into the
fingerprint so a default-only change still fires onChange (R6)."""
judge_default_alias — so each app reads its own (via modelDefaults/captureExtra,
which drive the "Default — <alias>" placeholder). The dead onChange
subscription (+ its fingerprint fold) was removed: models has no live-render
subscriber (the console repaints via its direct models_changed handler), so it
exposes no onModelsChange."""
src = _MODELS_JS.read_text(encoding="utf-8")
for field in ("default_alias", "judge_default_alias", "coordinator_default_alias"):
assert field in src, f"models.js must carry {field} for the two server schemas"
assert "window.TurnstoneModels" in src, "models.js must install the classic bridge"
assert "makeListCache" in src, "models.js must build on the shared list_cache core"
assert "fpExtra" in src, "models.js must fold the default aliases into the fingerprint (R6)"
assert "onModelsChange" not in src, (
"models.js must not expose the dead onModelsChange subscription (no subscribers)"
)
def test_skills_cache_returns_raw_rows() -> None:
+27 -13
View File
@@ -5,16 +5,25 @@ window.onLoginSuccess = function () {
if (typeof _refreshHomeComposerVisibility === "function") {
_refreshHomeComposerVisibility();
}
// Re-warm the home-composer skill AND model pickers now that auth has
// landed. The initial page-load pass runs before login completes, so
// /v1/api/skills and /v1/api/models 401 (fail-open: the caches keep their
// empty state); without this re-run the dropdowns stay at their placeholders
// until a reload (models used to only recover on a chance models_changed).
// Re-warm ALL FOUR home-composer caches now that auth has landed. The initial
// page-load pass runs before login completes, so /v1/api/{skills,models,
// projects,personas} all 401 (fail-open: the caches keep their empty state);
// without this re-run the launcher dropdowns — and the rail's group-by-project +
// the saved-coordinator project/persona columns — stay empty until a reload.
// {force:true} so a still-in-flight failing pre-auth fetch yields a trailing
// AUTHENTICATED refetch rather than coalescing onto the 401 (skills/personas
// have no *_changed event to recover otherwise).
if (typeof _refreshAndPopulateSkills === "function") {
_refreshAndPopulateSkills();
_refreshAndPopulateSkills({ force: true });
}
if (typeof _refreshAndPopulateModels === "function") {
_refreshAndPopulateModels();
_refreshAndPopulateModels({ force: true });
}
if (typeof _refreshAndPopulateProjects === "function") {
_refreshAndPopulateProjects({ force: true });
}
if (typeof _refreshAndPopulatePersonas === "function") {
_refreshAndPopulatePersonas({ force: true });
}
// Active-coordinators list is SSE-driven via the console pseudo-node
// (#9) — no poller to restart after login. The home-view renderer
@@ -1492,7 +1501,7 @@ function _ensureHomeComposerInit() {
// 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() {
function _refreshAndPopulatePersonas(callOpts) {
const TP = window.TurnstonePersonas;
if (!TP) return;
// Paint from the warm cache SYNCHRONOUSLY first so the Persona picker isn't
@@ -1501,7 +1510,7 @@ function _refreshAndPopulatePersonas() {
// 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().then(_populateHomePersonaDropdown);
TP.refreshPersonas(callOpts).then(_populateHomePersonaDropdown);
}
// Populate the launcher's Persona picker for the ACTIVE kind, preselecting
@@ -1532,7 +1541,7 @@ function _populateHomePersonaDropdown() {
// rail's group-by-project) then repaint the launcher's Project picker. Safe
// when the bridge is absent (project.read denied / module still loading): the
// picker simply keeps its "No project" placeholder.
function _refreshAndPopulateProjects() {
function _refreshAndPopulateProjects(callOpts) {
const TP = window.TurnstoneProjects;
if (!TP) return;
// Sync paint from the warm cache first (the launcher keeps its "No project"
@@ -1541,7 +1550,7 @@ function _refreshAndPopulateProjects() {
// console launcher intentionally does NOT gate on requireProject() (§8) — the
// node create endpoint is the authoritative gate.
_populateHomeProjectDropdown();
TP.refreshProjects().then(_populateHomeProjectDropdown);
TP.refreshProjects(callOpts).then(_populateHomeProjectDropdown);
}
// Populate the launcher's Project picker from the shared cache, preserving the
@@ -1731,11 +1740,11 @@ function _mountHomeCoordComposer() {
// then refresh-and-repaint to catch a skill created elsewhere. Safe when the
// bridge is absent (module still loading / 401 pre-auth — onLoginSuccess re-runs
// this once auth lands).
function _refreshAndPopulateSkills() {
function _refreshAndPopulateSkills(callOpts) {
const TS = window.TurnstoneSkills;
if (!TS) return;
_populateHomeSkillDropdown();
TS.refreshSkills().then(_populateHomeSkillDropdown);
TS.refreshSkills(callOpts).then(_populateHomeSkillDropdown);
}
// Populate the launcher's Skill picker from the shared cache, preserving the
@@ -1746,6 +1755,11 @@ function _populateHomeSkillDropdown() {
if (!_homeCoordComposer) return;
const TS = window.TurnstoneSkills;
if (!TS) return;
// Reads the shared skills cache, which is FAIL-OPEN by design: a transient
// non-OK refresh keeps the last-known rows rather than blanking to the
// placeholder (main blanked via `r.ok ? json : {skills:[]}`; this matches the
// projects/personas policy). A since-removed skill is rejected server-side on
// launch — a narrow stale-selection window traded for not blanking on a blip.
const previous = _homeCoordComposer.getOptionValue("skill");
const choices = TS.getSkills().map(function (t) {
return {
+1 -11
View File
@@ -30,12 +30,6 @@ import { authFetch } from "./auth.js";
* @param {(row:object)=>any} opts.fpRow per-row fingerprint tuple — the fields
* subscribers render, so a refresh that
* returns identical data skips the fan-out
* @param {(extra:object)=>any} [opts.fpExtra] extra top-level fields to fold
* into the fingerprint (e.g. models' default
* aliases, so a default-only change still
* fires onChange). Omit to fingerprint rows
* only (a `captureExtra` value that changes
* without a row change then does NOT fire).
* @param {(data:object)=>object} [opts.captureExtra] pull extra top-level
* response fields into cache state on a
* successful refresh (returns the new
@@ -64,7 +58,6 @@ export function makeListCache(opts) {
const name = opts.name;
const keyField = opts.keyField || null;
const fpRow = opts.fpRow;
const fpExtra = opts.fpExtra || null;
const captureExtra = opts.captureExtra || null;
const extraDefaults = opts.extraDefaults || null;
// Default true: an advisory that gates UI (projects' require_project) must fail
@@ -88,10 +81,7 @@ export function makeListCache(opts) {
// (it escapes anything inside a string field) and needs no separator
// chars; an earlier per-module version joined on raw control bytes,
// which made git see the whole file as binary.
return JSON.stringify([
_cache.map(fpRow),
fpExtra ? fpExtra(_extra) : null,
]);
return JSON.stringify(_cache.map(fpRow));
}
function _setCache(rows) {
+11 -24
View File
@@ -32,19 +32,12 @@ const _core = makeListCache({
url: "/v1/api/models",
dataKey: "models",
name: "models",
// alias is the unique <select> key — index by it so modelLabel() is an O(1)
// getByKey rather than a per-paint scan.
keyField: "alias",
fpRow: function (m) {
return [m.alias, m.model];
},
// Fold the default-alias fields into the fingerprint so a role-alias change
// (server emits `models_changed` for it) still fires onChange even when the
// model rows themselves are unchanged.
fpExtra: function (e) {
return [
e.default_alias,
e.judge_default_alias,
e.coordinator_default_alias,
];
},
captureExtra: function (data) {
return {
default_alias: data.default_alias || "",
@@ -113,15 +106,12 @@ export function modelChoices() {
/** The "alias (model)" label for a model alias, or "" when the alias is empty or
* unknown — lets a composer annotate its "Default — <resolved>" placeholder
* without re-implementing the format. The models cache has no key index, so
* this scans the (small) row list. */
* without re-implementing the format. Resolves via the keyField:"alias" index
* (getByKey), O(1). */
export function modelLabel(alias) {
if (!alias) return "";
const rows = _core.get();
for (let i = 0; i < rows.length; i++) {
if (rows[i].alias === alias) return _fmtLabel(rows[i]);
}
return "";
const row = _core.getByKey(alias);
return row ? _fmtLabel(row) : "";
}
/** The resolved default aliases the local server reported:
@@ -134,12 +124,10 @@ export function modelDefaults() {
return _core.extra();
}
/** Subscribe to post-refresh changes. Idempotent. (No composer subscribes
* today — the console repaints via its direct `models_changed` handler — but
* the hook is exposed for parity with the other data layers.) */
export function onModelsChange(cb) {
_core.onChange(cb);
}
// 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.
// Classic (non-module) app.js bundles reach the data layer through this bridge.
window.TurnstoneModels = {
@@ -150,5 +138,4 @@ window.TurnstoneModels = {
modelChoices: modelChoices,
modelLabel: modelLabel,
modelDefaults: modelDefaults,
onModelsChange: onModelsChange,
};
+2 -2
View File
@@ -39,8 +39,8 @@ const _core = makeListCache({
* Failures are recorded (see {@link personasError}) and warned, rather
* than silently masqueraded as "no personas".
*/
export function refreshPersonas() {
return _core.refresh();
export function refreshPersonas(callOpts) {
return _core.refresh(callOpts);
}
/** Cached persona rows (empty array until the first refresh resolves). */
+6 -6
View File
@@ -23,10 +23,10 @@ import { makeListCache } from "./list_cache.js";
// The require_project advisory rides the same /v1/api/projects response as an
// extra top-level field. It fails OPEN to false: a stale-true value would make
// the composer hide options on a transient error, so `extraDefaults` resets it
// on every refresh failure and it seeds false before the first refresh. It is
// deliberately NOT in the fingerprint (no fpExtra) — a require_project-only
// toggle with an unchanged project list should not force a rail rebuild; the
// composers re-read it synchronously on their next open.
// on every refresh failure and it seeds false before the first refresh. The
// fingerprint is the project rows only, so a require_project-only toggle (with an
// unchanged project list) does NOT force a rail rebuild; the composers re-read it
// synchronously on their next open.
const _core = makeListCache({
url: "/v1/api/projects",
dataKey: "projects",
@@ -53,8 +53,8 @@ const _core = makeListCache({
* a half-open picker. A failure is recorded (see {@link projectsError})
* and warned, rather than silently masqueraded as an empty project list.
*/
export function refreshProjects() {
return _core.refresh();
export function refreshProjects(callOpts) {
return _core.refresh(callOpts);
}
/** Cached project rows (empty array until the first refresh resolves). */
+9 -9
View File
@@ -36,11 +36,13 @@ 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}) and warned rather than
* masqueraded as "no skills".
* blanks). Recorded (see {@link skillsError}) 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).
*/
export function refreshSkills() {
return _core.refresh();
export function refreshSkills(callOpts) {
return _core.refresh(callOpts);
}
/** Cached skill rows (`{name, is_default, origin, ...}`; empty until the
@@ -61,10 +63,9 @@ export function skillsError() {
return _core.error();
}
/** Subscribe to post-refresh changes. Idempotent. */
export function onSkillsChange(cb) {
_core.onChange(cb);
}
// No onChange subscription is exposed (unlike projects.js / personas.js): the
// skills cache has no live-render consumer — the composers repaint on open.
// Omitted rather than exposed-and-unused.
// Classic (non-module) app.js bundles reach the data layer through this bridge.
window.TurnstoneSkills = {
@@ -72,5 +73,4 @@ window.TurnstoneSkills = {
getSkills: getSkills,
skillsLoaded: skillsLoaded,
skillsError: skillsError,
onSkillsChange: onSkillsChange,
};
+43 -23
View File
@@ -105,6 +105,12 @@ setInterval(pollHealth, 30000);
// ===========================================================================
window.onLoginSuccess = function () {
// Deferred (follow-up): a cold in-place login leaves the dashboard composer
// caches (models/skills/projects/personas) warmed pre-auth as empty (401 ->
// fail-open) until the Dashboard pane is re-focused (loadDashboard ->
// _loadDashboardOptionsLists) or the page reloads. The CONSOLE force-re-warms
// all four in its onLoginSuccess; the ui relies on the re-focus repaint and is
// NOT force-warmed here (out of the composer-cache scope) — revisit if it bites.
initWorkstreams();
};
@@ -353,9 +359,13 @@ function showNewWsModal(forkFromWsId) {
_paintModelSelects(modelSelect, judgeSelect, { freshOnOpen: true });
}
// Skill picker — paint fresh-on-open from the warm cache, then refresh.
// Skill picker — hidden for a fork (inherited + submit-gated), so skip its
// paint too (no wasted /v1/api/skills fetch + hidden-select rebuild); a fresh
// create paints fresh-on-open from the warm cache, then refreshes.
const tplSelect = document.getElementById("new-ws-skill");
_paintSkillSelect(tplSelect, { freshOnOpen: true });
if (!_forkFromWsId) {
_paintSkillSelect(tplSelect, { freshOnOpen: true });
}
// Project picker — populated from the shared projects cache, refreshed on
// open. Fresh creates SHOW it; forks HIDE it — a fork's project is its
@@ -388,15 +398,11 @@ function showNewWsModal(forkFromWsId) {
const personaSelect = document.getElementById("new-ws-persona");
if (personaLabel) personaLabel.hidden = !!_forkFromWsId;
if (personaSelect) personaSelect.hidden = !!_forkFromWsId;
if (personaSelect && !_forkFromWsId && window.TurnstonePersonas) {
// Fresh-on-open sync paint (renders the kind default; the reused dialog has
// no prior pick to carry over), then refresh-and-repaint. The async paint
// passes fresh:false so it preserves a mid-window pick and only re-applies
// the kind default when nothing valid is selected.
_populatePersonaSelect(personaSelect, { fresh: true });
window.TurnstonePersonas.refreshPersonas().then(function () {
_populatePersonaSelect(personaSelect, { fresh: false });
});
// Fresh-on-open (the reused dialog has no prior pick); the async repaint
// preserves a mid-window pick and re-applies the kind default only when nothing
// valid is selected. Shared with the dashboard via _paintPersonaSelect.
if (!_forkFromWsId) {
_paintPersonaSelect(personaSelect, { freshOnOpen: true });
}
document.getElementById("new-ws-name").value = "";
@@ -566,6 +572,21 @@ function _paintSkillSelect(sel, opts) {
}
}
// Persona twin of _paintModelSelects (fourth composer picker on the same shape,
// so the modal + dashboard don't maintain the persona sync-then-refresh dance
// two different ways). _populatePersonaSelect keeps the kind default when
// nothing valid is selected, so the async fresh:false repaint can't clobber a
// mid-window pick.
function _paintPersonaSelect(sel, opts) {
const freshOnOpen = !!(opts && opts.freshOnOpen);
_populatePersonaSelect(sel, { fresh: freshOnOpen });
if (window.TurnstonePersonas) {
window.TurnstonePersonas.refreshPersonas().then(function () {
_populatePersonaSelect(sel, { fresh: false });
});
}
}
// Fill the model + judge-model <select>s from the shared models cache. One list
// feeds BOTH selects with the same "alias (model)" labels; each placeholder is
// annotated with the server-resolved default alias ("Default — gpt-5"), resolved
@@ -728,8 +749,13 @@ function submitNewWs() {
const initEl = document.getElementById("new-ws-initial-message");
const initial_message = initEl ? initEl.value.trim() : "";
if (name) body.name = name;
// Forks inherit their source's model + judge (the selects are hidden for a
// fork), so never override them — matches the skill/persona/project fork guards.
// Forks DELIBERATELY inherit their source's model + judge: the selects are
// hidden for a fork (showNewWsModal) and never sent here — matching the
// skill/persona/project fork guards. A fork resumes the source session
// (resume_ws), which already carries its model, so there is intentionally no
// fork model/judge override. Do NOT drop these !_forkFromWsId guards without
// also un-hiding the selects (a bare gate-removal ships a hidden-select's stale
// value).
if (model && !_forkFromWsId) body.model = model;
if (judge_model && !_forkFromWsId) body.judge_model = judge_model;
if (skill && !_forkFromWsId) body.skill = skill;
@@ -1514,17 +1540,11 @@ function _loadDashboardOptionsLists() {
const projHint = projLabel ? projLabel.querySelector(".label-hint") : null;
_paintProjectPicker(projSel, projHint, { fork: false });
// Persona picker — same paint-from-cache-then-refresh policy; kind default
// preselected so a zero-touch launch behaves exactly like today.
// 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");
if (personaSel && window.TurnstonePersonas) {
// Sync paint first, then refresh-and-repaint; the helper preserves a mid-
// window pick and only applies the kind default when nothing valid is chosen.
_populatePersonaSelect(personaSel);
window.TurnstonePersonas.refreshPersonas().then(function () {
_populatePersonaSelect(personaSel);
});
}
_paintPersonaSelect(personaSel, { freshOnOpen: false });
}
// localStorage key for the dashboard composer's Options-panel disclosure