41 KiB
Multi-Branch Picker Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Stop emitting all branches of a multi-mod Workshop item (e.g. AuthenticZ) into Mods=, which silently bricks PZ servers. Add a per-wsid picker UI plus a /api/resort endpoint that recomputes load order and warnings against the user's selection.
Architecture: Spec at /opt/sortof/docs/specs/2026-04-30-multi-branch-picker.md (v2, 155 lines, all decisions locked in §10). Backend grows one new POST endpoint and one new WARNINGS tag. Frontend adds picker state, localStorage persistence, a BranchPicker sub-component, and resort fetch with sequence-number race protection. No schema migration. No new venv.
Tech Stack: FastAPI + asyncpg + Pydantic on the backend; React 18 + Babel-standalone (in-browser JSX, no build) on the frontend; CSS in index.html. Persistence via localStorage only.
File structure
| Path | Action | Responsibility |
|---|---|---|
/opt/sortof/api/app.py |
Modify | Add ResortRequest model + POST /api/resort route. No other change. |
/opt/sortof/api/adapters.py |
Modify | Add _compute_ambiguous_warnings(). Inject result into build_response. |
/opt/sortof/frontend/sortof-app.jsx |
Modify | Add COLUMN_COUNT, branchSelections state + hydration + storage listener, BranchPicker component, refactor ModTable to group multi-branch wsids, resort() with sequence number, 5xx handling. |
/opt/sortof/frontend/index.html |
Modify | CSS for .branch-affordance, .branch-panel, .branch-row. |
No new files. The two mlos_sort.py copies are unchanged - dangling-deps detection already exists in mlos_sort.sort_mods.
Verification fixtures (already cached, no /api/sort cold-start required):
- 3-mod canonical (single-mod wsids, regression baseline):
2169435993;2392709985;2487022075→MODS_LINE="modoptions;tsarslib;TMC_TrueActions". - AuthenticZ multi-branch (3 mod_ids on one wsid, all
incompatible_mods=[]): wsid2335368829→ mod_idsAuthentic Z - Current,AuthenticZBackpacks+,AuthenticZLite.
Task 1: Backend - POST /api/resort endpoint
Files:
-
Modify:
/opt/sortof/api/app.py(add Pydantic model + route) -
Step 1: Backup app.py
cp /opt/sortof/api/app.py /opt/sortof/api/app.py.bak-$(date +%Y%m%d-%H%M)
- Step 2: Add
ResortRequestPydantic model
Find the existing class SortRequest(BaseModel): block (around line 76). Replace it with both models:
class SortRequest(BaseModel):
input: str = Field(default="")
rules: Optional[str] = Field(default=None)
class ResortRequest(BaseModel):
selected_mod_ids: List[str] = Field(default_factory=list)
- Step 3: Add
/api/resortroute
Find the line right after the existing sort_endpoint function returns (the line return payload near the bottom of the function - currently line ~236). The static-files mount block follows. Insert the new route between them:
@app.post("/api/resort")
async def resort_endpoint(req: ResortRequest, request: Request) -> Dict[str, Any]:
"""Re-run mlos_sort against a user-supplied subset of mod_ids.
Stateless. No Steam round-trip. Filters mod_parsed by the requested
mod_ids, runs sort_mods, returns the same shape as /api/sort.
"""
t0 = time.monotonic()
ids = list(req.selected_mod_ids or [])
if not ids:
raise HTTPException(status_code=400, detail="selected_mod_ids must be non-empty")
if len(ids) > MAX_IDS:
raise HTTPException(status_code=413, detail=f"too many mod_ids ({len(ids)} > {MAX_IDS})")
for mod_id in ids:
if not isinstance(mod_id, str) or not (1 <= len(mod_id) <= 256):
raise HTTPException(status_code=400, detail="invalid mod_id")
pool = request.app.state.db
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT mp.workshop_id, mp.mod_id, mp.name, mp.category,
mp.requirements, mp.load_after, mp.load_before,
mp.incompatible_mods, mp.load_first, mp.load_last,
mp.tags, mp.maps
FROM mod_parsed mp
JOIN workshop_meta wm ON wm.workshop_id = mp.workshop_id
WHERE mp.mod_id = ANY($1::text[])
AND mp.parsed_at_time_updated = wm.time_updated
ORDER BY mp.workshop_id, mp.mod_id
""",
ids,
)
found_ids = {r["mod_id"] for r in rows}
missing = [mid for mid in ids if mid not in found_ids]
if missing:
log.info("resort dropped unknown mod_ids count=%d sample=%s",
len(missing), missing[:3])
if not rows:
raise HTTPException(status_code=400, detail="no known mod_ids in selection")
mods: List[ModInfo] = [
ModInfo(
id=r["mod_id"],
name=r["name"] or r["mod_id"],
workshop_id=r["workshop_id"],
category=r["category"] or "undefined",
requirements=list(r["requirements"] or []),
loadAfter=list(r["load_after"] or []),
loadBefore=list(r["load_before"] or []),
incompatibleMods=list(r["incompatible_mods"] or []),
loadFirst=r["load_first"] or "off",
loadLast=r["load_last"] or "off",
tags=list(r["tags"] or []),
maps=list(r["maps"] or []),
)
for r in rows
]
sort_result = sort_mods(mods, {})
payload = adapters.build_response(
input_ids=[],
hit_ids=ids,
mods=mods,
sort_result=sort_result,
status="success",
)
payload["pending"] = []
elapsed_ms = int((time.monotonic() - t0) * 1000)
log.info("resort done count=%d ms=%d", len(rows), elapsed_ms)
return payload
- Step 4: py_compile + smoke import
/opt/sortof/api/.venv/bin/python -m py_compile /opt/sortof/api/app.py && echo PY_OK
cd /opt/sortof/api && .venv/bin/python -c "import app" && echo IMPORT_OK
Expected: both lines print PY_OK and IMPORT_OK respectively. If either fails, fix the syntax/import before restarting.
- Step 5: Restart API
sudo systemctl restart sortof-api && sleep 2 && sudo systemctl is-active sortof-api
Expected: active.
- Step 6: Verify happy path
curl -sS -X POST http://100.114.205.53:8801/api/resort \
-H 'Content-Type: application/json' \
-d '{"selected_mod_ids":["modoptions","tsarslib","TMC_TrueActions"]}' \
| jq '{status, MODS_LINE, mod_db_count: (.MOD_DB|length), pending}'
Expected:
{"status":"success","MODS_LINE":"modoptions;tsarslib;TMC_TrueActions","mod_db_count":3,"pending":[]}
- Step 7: Verify validation 400s
# Empty selection → 400
curl -sS -o /dev/null -w 'empty=%{http_code}\n' -X POST http://100.114.205.53:8801/api/resort \
-H 'Content-Type: application/json' -d '{"selected_mod_ids":[]}'
# All-unknown selection → 400
curl -sS -o /dev/null -w 'unknown=%{http_code}\n' -X POST http://100.114.205.53:8801/api/resort \
-H 'Content-Type: application/json' -d '{"selected_mod_ids":["ghostA","ghostB"]}'
# Mixed (one valid + one unknown) → 200, ghost dropped
curl -sS -X POST http://100.114.205.53:8801/api/resort \
-H 'Content-Type: application/json' -d '{"selected_mod_ids":["modoptions","ghostMod"]}' \
| jq '{status, mod_db_count: (.MOD_DB|length)}'
Expected:
empty=400
unknown=400
{"status":"success","mod_db_count":1}
- Step 8: Checkpoint - task complete; backup file remains as rollback.
Task 2: Backend - ambiguous-multi-branch warning
Files:
-
Modify:
/opt/sortof/api/adapters.py -
Step 1: Backup adapters.py
cp /opt/sortof/api/adapters.py /opt/sortof/api/adapters.py.bak-$(date +%Y%m%d-%H%M)
- Step 2: Add helper for ambiguous-multi-branch detection
Open /opt/sortof/api/adapters.py. Just below the existing build_warnings() function (around line 56), insert:
def _compute_ambiguous_warnings(mods: List[ModInfo]) -> List[Dict[str, str]]:
"""Emit one amber WARNINGS entry per wsid that has >=2 selected mod_ids,
none of them flagging others in `incompatibleMods`. The user is at risk
of shipping conflicting alternates (e.g. AuthenticZ Lite + Current);
show this even when they don't expand the picker.
Spec: 2026-04-30-multi-branch-picker.md §4 "default-selection safety net"
(review #1 fix).
"""
by_wsid: Dict[str, List[ModInfo]] = {}
for m in mods:
wsid = m.workshop_id or ""
if not wsid:
continue
by_wsid.setdefault(wsid, []).append(m)
out: List[Dict[str, str]] = []
for wsid, group in by_wsid.items():
if len(group) < 2:
continue
ids_in_group = {m.id for m in group}
any_flags_sibling = any(
any(other in ids_in_group for other in m.incompatibleMods)
for m in group
)
if any_flags_sibling:
continue # author marked alternates; picker will run in radio mode
title = group[0].name or wsid
out.append({
"tag": "ambiguous-multi-branch",
"level": "amber",
"msg": (
f"{len(group)} branches selected from {title} ({wsid}) - "
f"author didn't declare alternates; verify these aren't "
f"mutually exclusive (e.g., AuthenticZ Lite vs Current). "
f"Expand the row to pick one."
),
})
return out
- Step 3: Wire helper into
build_response
Find the existing return { ... } at the bottom of build_response() (around line 103-112). Replace the "WARNINGS": build_warnings(...) line with:
"WARNINGS": (
build_warnings(sort_result.get("warnings", {}) or {})
+ _compute_ambiguous_warnings(mods)
),
- Step 4: py_compile + smoke import
/opt/sortof/api/.venv/bin/python -m py_compile /opt/sortof/api/adapters.py && echo PY_OK
cd /opt/sortof/api && .venv/bin/python -c "import app" && echo IMPORT_OK
- Step 5: Restart API
sudo systemctl restart sortof-api && sleep 2 && sudo systemctl is-active sortof-api
- Step 6: Verify warning fires for AuthenticZ
curl -sS -X POST http://100.114.205.53:8801/api/sort \
-H 'Content-Type: application/json' \
-d '{"input":"2335368829"}' \
| jq '.WARNINGS[] | select(.tag == "ambiguous-multi-branch")'
Expected: one object with tag:"ambiguous-multi-branch", level:"amber", and a msg mentioning "3 branches" and 2335368829.
- Step 7: Verify warning DOES NOT fire for the canonical 3-mod input
curl -sS -X POST http://100.114.205.53:8801/api/sort \
-H 'Content-Type: application/json' \
-d '{"input":"2169435993;2392709985;2487022075"}' \
| jq '[.WARNINGS[] | select(.tag == "ambiguous-multi-branch")] | length'
Expected: 0 (each wsid has only 1 mod row → no ambiguity).
- Step 8: Verify warning DOES NOT fire when subset is selected via /api/resort
curl -sS -X POST http://100.114.205.53:8801/api/resort \
-H 'Content-Type: application/json' \
-d '{"selected_mod_ids":["Authentic Z - Current"]}' \
| jq '[.WARNINGS[] | select(.tag == "ambiguous-multi-branch")] | length'
Expected: 0 (one mod from the wsid, not a multi-branch ambiguity).
Task 3: Frontend - COLUMN_COUNT + branchSelections state + hydration + storage listener
Files:
-
Modify:
/opt/sortof/frontend/sortof-app.jsx -
Step 1: Backup
cp /opt/sortof/frontend/sortof-app.jsx /opt/sortof/frontend/sortof-app.jsx.bak-$(date +%Y%m%d-%H%M)
- Step 2: Add
COLUMN_COUNTconstant
Find the helper functions near the top of the file (after buildModsLine, around line 35). Add:
// Shared column count for the Mod Details table. Keep in sync with the
// <thead> in ModTable; expansion-panel <tr colSpan> reads this constant.
// Spec C will add a 7th column; bump here, do not hardcode the integer.
const COLUMN_COUNT = 6;
- Step 3: Add
branchSelectionsApp-level state with localStorage hydration
Find the App function's state declarations (around line 456). Below the existing pzBuild block, add:
const [branchSelections, setBranchSelections] = useState(() => {
try {
const raw = localStorage.getItem('sortof.branch.selections');
if (!raw) return {};
const parsed = JSON.parse(raw);
return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {};
} catch { return {}; }
});
const [resortSeq, setResortSeq] = useState(0); // monotonic; for stale-response drop
- Step 4: Add useEffect for persist + cross-tab
storagelistener
Below the pzBuild localStorage useEffect (around line 467), add:
useEffect(() => {
try {
localStorage.setItem('sortof.branch.selections', JSON.stringify(branchSelections));
} catch {}
}, [branchSelections]);
useEffect(() => {
function onStorage(e) {
if (e.key !== 'sortof.branch.selections' || e.newValue === null) return;
try {
const parsed = JSON.parse(e.newValue);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
setBranchSelections(parsed);
}
} catch {}
}
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
- Step 5: Verify served file picks up the new constants
curl -sS http://100.114.205.53:8801/sortof-app.jsx | grep -cE 'COLUMN_COUNT|branchSelections|sortof.branch.selections'
Expected: >= 6 (constant declaration + state name uses + localStorage key uses).
- Step 6: Manual browser smoke test
In a browser tab on http://100.114.205.53:8801/:
- Open DevTools console.
- Run
localStorage.setItem('sortof.branch.selections', JSON.stringify({"2335368829": ["Authentic Z - Current"]})). - Hard-refresh the page.
- In console:
localStorage.getItem('sortof.branch.selections')- should return the same JSON. - App should render without errors (no behavior change yet - picker UI is Task 4-5).
If the console shows JSON parse errors or "branchSelections is not defined", revert to backup and re-do.
Task 4: Frontend - ModTable refactor to group multi-branch wsids
Files:
-
Modify:
/opt/sortof/frontend/sortof-app.jsx(replaceModTablebody around line 306; passbranchSelectionsfrom App) -
Step 1: Backup (if more than ~10 minutes since last)
cp /opt/sortof/frontend/sortof-app.jsx /opt/sortof/frontend/sortof-app.jsx.bak-$(date +%Y%m%d-%H%M)
- Step 2: Replace
ModTablewith the wsid-grouping version
Find the existing function ModTable({ defaultOpen = false }) { block (around line 306) and the closing } of that function (around line 351). Replace the entire function with:
function ModTable({ defaultOpen = false, branchSelections, onToggleBranch, expandedWsids, onToggleExpansion }) {
const [open, setOpen] = useState(defaultOpen);
// Group MOD_DB rows by wsid. For wsids with >1 mod, render a single parent
// row and an expansion <tr colSpan={COLUMN_COUNT}> with the picker.
const groupedByWsid = useMemo(() => {
const g = {};
for (const m of (D.MOD_DB || [])) {
const w = m.wsid || '';
if (!w) continue;
(g[w] = g[w] || []).push(m);
}
return g;
}, [D.MOD_DB]);
// Walk SORTED_ORDER and emit one row-spec per wsid (deduped).
const rowSpecs = useMemo(() => {
const specs = [];
const seenWsid = new Set();
for (const modId of (D.SORTED_ORDER || [])) {
const m = (D.MOD_DB || []).find(x => x.modId === modId);
if (!m || !m.wsid) continue;
if (seenWsid.has(m.wsid)) continue;
seenWsid.add(m.wsid);
const branches = groupedByWsid[m.wsid] || [m];
const isMulti = branches.length >= 2;
specs.push({ wsid: m.wsid, primary: m, branches, isMulti });
}
// Wsids whose parent had zero selected mods: not in SORTED_ORDER. Append.
for (const w of Object.keys(groupedByWsid)) {
if (!seenWsid.has(w)) {
const branches = groupedByWsid[w];
specs.push({ wsid: w, primary: branches[0], branches, isMulti: branches.length >= 2 });
}
}
return specs;
}, [D.SORTED_ORDER, D.MOD_DB, groupedByWsid]);
return (
<div className="panel table-section">
<div className="tbl-head" onClick={() => setOpen(!open)}>
<span>mod details</span>
<span className="count">· {(D.SORTED_ORDER || []).length} mods</span>
<span style={{ color: 'var(--fg-3)', fontSize: 10.5, marginLeft: 8 }}>
why everything ended up where it did
</span>
<span className="chev">{open ? '▾' : '▸'}</span>
</div>
{open && (
<table className="mods-table">
<thead>
<tr>
<th>#</th>
<th>mod id</th>
<th>workshop id</th>
<th>category</th>
<th>dependencies</th>
<th>load</th>
</tr>
</thead>
<tbody>
{rowSpecs.map((spec, i) => {
const idx = String(i + 1).padStart(2, '0');
if (!spec.isMulti) {
const m = spec.primary;
return (
<tr key={spec.wsid}>
<td className="idx">{idx}</td>
<td><span className="modid">{m.modId}</span></td>
<td><span className="wsid">{spec.wsid}</span></td>
<td><span className={'cat ' + m.cat}>{m.cat}</span></td>
<td><span className="deps">{m.deps && m.deps.length ? m.deps.join(', ') : '-'}</span></td>
<td>
{m.pos === 'first' && <span className="pos first">first</span>}
{m.pos === 'last' && <span className="pos last">last</span>}
{!m.pos && <span className="pos">-</span>}
</td>
</tr>
);
}
// Multi-branch wsid: parent row + (optional) expansion panel.
const selected = branchSelections[spec.wsid] || [];
const N = spec.branches.length;
const X = selected.length;
const userTouched = (spec.wsid in branchSelections);
const affordance = userTouched ? `✓ ${X} of ${N}` : `▾ ${N} branches`;
const firstSelected = spec.branches.find(b => selected.includes(b.modId)) || spec.branches[0];
const showAsZero = userTouched && X === 0;
const display = showAsZero ? null : firstSelected;
const expanded = expandedWsids.has(spec.wsid);
return (
<React.Fragment key={spec.wsid}>
<tr>
<td className="idx">{idx}</td>
<td>
<button className="branch-affordance"
onClick={() => onToggleExpansion(spec.wsid)}>
{affordance}
</button>
</td>
<td><span className="wsid">{spec.wsid}</span></td>
<td>{display ? <span className={'cat ' + display.cat}>{display.cat}</span> : '-'}</td>
<td>{display && display.deps && display.deps.length ? display.deps.join(', ') : '-'}</td>
<td>
{display && display.pos === 'first' && <span className="pos first">first</span>}
{display && display.pos === 'last' && <span className="pos last">last</span>}
{(!display || !display.pos) && <span className="pos">-</span>}
</td>
</tr>
{expanded && (
<BranchPicker
wsid={spec.wsid}
branches={spec.branches}
selected={selected}
userTouched={userTouched}
onToggle={onToggleBranch}
/>
)}
</React.Fragment>
);
})}
</tbody>
</table>
)}
</div>
);
}
- Step 3: Update App to maintain
expandedWsidsstate and pass everything down
Find App's other useState calls. Add:
const [expandedWsids, setExpandedWsids] = useState(() => new Set());
const onToggleExpansion = (wsid) => {
setExpandedWsids(prev => {
const next = new Set(prev);
if (next.has(wsid)) next.delete(wsid); else next.add(wsid);
return next;
});
};
(onToggleBranch is added in Task 5 - leave it for now; the existing <ModTable defaultOpen={modTableDefault} /> invocation in RightColumn needs updating in Step 4 below.)
- Step 4: Pass props through
RightColumntoModTable
Find the RightColumn function signature (around line 372). Add branchSelections, onToggleBranch, expandedWsids, onToggleExpansion to the destructured props:
function RightColumn({ state, counts, progress, emptyVariant, successVariant, modTableDefault, pzBuild, setPzBuild, branchSelections, onToggleBranch, expandedWsids, onToggleExpansion }) {
Then find the <ModTable defaultOpen={modTableDefault} /> invocation inside RightColumn (around line 437). Replace with:
<ModTable
defaultOpen={modTableDefault}
branchSelections={branchSelections}
onToggleBranch={onToggleBranch}
expandedWsids={expandedWsids}
onToggleExpansion={onToggleExpansion}
/>
- Step 5: Pass props from App to
RightColumn
Find the <RightColumn …/> invocation in App (around line 613). Append:
branchSelections={branchSelections}
onToggleBranch={() => {}}
expandedWsids={expandedWsids}
onToggleExpansion={onToggleExpansion}
(onToggleBranch is a placeholder no-op for now; replaced in Task 5.)
- Step 6: Verify served file
curl -sS http://100.114.205.53:8801/sortof-app.jsx | grep -cE 'BranchPicker|expandedWsids|branch-affordance|onToggleExpansion'
Expected: >= 6.
- Step 7: Manual browser smoke test
Hard-refresh the page. Submit a sort with 2335368829 (AuthenticZ alone) and any cached single-mod input. Open the Mod Details table. Expectation:
- AuthenticZ appears as one row with
▾ 3 branches(or✓ X of Nif hydrated from earlier Task 3 testing). - Single-mod wsids appear as normal rows.
- Clicking the
▾affordance togglesexpandedWsids(panel visibility - content is empty until Task 5 addsBranchPicker). - Console: no errors. (
BranchPicker is not definedis expected at this point if you click - Task 5 fixes.)
Task 5: Frontend - BranchPicker expansion panel + default selection + radio/checkbox modes
Files:
-
Modify:
/opt/sortof/frontend/sortof-app.jsx -
Step 1: Backup
cp /opt/sortof/frontend/sortof-app.jsx /opt/sortof/frontend/sortof-app.jsx.bak-$(date +%Y%m%d-%H%M)
- Step 2: Add
defaultSelectionForBranches()helper near top of file
Right after buildModsLine (around line 35), insert:
// Default selection for a multi-branch wsid (spec §4):
// - if any branch lists another in `incompatible_mods`, default = [first only]
// - else default = [all branches]
// "First" tiebreaker: branches arrive sorted by parsed_at ASC, mod_id ASC from
// the API; we trust that order and pick branches[0].
function defaultSelectionForBranches(branches) {
if (!branches || !branches.length) return [];
const ids = new Set(branches.map(b => b.modId));
const isRadio = branches.some(b => (b.conflicts || []).some(c => ids.has(c)));
if (isRadio) return [branches[0].modId];
return branches.map(b => b.modId);
}
function isRadioMode(branches) {
if (!branches || branches.length < 2) return false;
const ids = new Set(branches.map(b => b.modId));
return branches.some(b => (b.conflicts || []).some(c => ids.has(c)));
}
- Step 3: Add
BranchPickercomponent
Just above function ModTable( (around line 306, before the table), insert:
function BranchPicker({ wsid, branches, selected, userTouched, onToggle }) {
const radio = isRadioMode(branches);
const inputType = radio ? 'radio' : 'checkbox';
// If user hasn't touched yet, render the default selection (so checkboxes
// reflect the implicit state). The actual default is also assumed by the
// rest of the system whenever branchSelections[wsid] is missing.
const effective = userTouched ? selected : defaultSelectionForBranches(branches);
return (
<tr>
<td colSpan={COLUMN_COUNT} className="branch-panel">
<div className="branch-panel-inner">
<div className="branch-panel-meta">
workshop {wsid} · {branches.length} mod_ids · {radio ? 'pick one' : 'multi-select'}
</div>
{branches.map(b => {
const checked = effective.includes(b.modId);
return (
<label key={b.modId} className={'branch-row' + (checked ? ' is-checked' : '')}>
<input
type={inputType}
name={radio ? `branch-${wsid}` : undefined}
className="branch-input"
checked={checked}
onChange={() => onToggle(wsid, b.modId, branches)}
/>
<span className="branch-modid">{b.modId}</span>
<span className="branch-name">{b.name}</span>
<span className={'cat ' + b.cat}>{b.cat}</span>
<span className="branch-deps">
{b.deps && b.deps.length ? b.deps.join(', ') : '-'}
</span>
<span className="branch-pos">
{b.pos === 'first' ? 'first' : (b.pos === 'last' ? 'last' : '-')}
</span>
</label>
);
})}
</div>
</td>
</tr>
);
}
- Step 4: Implement
onToggleBranchin App
Find the App function (around line 454). Below the expandedWsids state, add:
const onToggleBranch = (wsid, modId, branches) => {
setBranchSelections(prev => {
const radio = isRadioMode(branches);
const cur = prev[wsid] !== undefined
? prev[wsid]
: defaultSelectionForBranches(branches);
let next;
if (radio) {
next = [modId]; // exactly one
} else {
next = cur.includes(modId)
? cur.filter(x => x !== modId)
: [...cur, modId];
}
return { ...prev, [wsid]: next };
});
// Resort fetch is wired in Task 6; for now this just updates state.
};
- Step 5: Replace the no-op
onToggleBranchplaceholder in<RightColumn>
Find the <RightColumn …/> invocation in App (Task 4 added onToggleBranch={() => {}}). Replace with:
onToggleBranch={onToggleBranch}
- Step 6: Verify served file
curl -sS http://100.114.205.53:8801/sortof-app.jsx | grep -cE 'BranchPicker|defaultSelectionForBranches|isRadioMode|branch-row'
Expected: >= 8.
- Step 7: Manual browser smoke
Hard-refresh. Submit AuthenticZ (2335368829). Click the ▾ 3 branches affordance.
Expected:
- Panel expands with 3 rows.
- All 3 boxes are checked (default-all per §4 since
incompatible_mods=[]on all). - Click one box to uncheck. Affordance changes to
✓ 2 of 3. Storage event listener writes to localStorage (localStorage.getItem('sortof.branch.selections')). - The Mods= line in the output panel currently still includes all 3 mod_ids (resort hookup is Task 6).
Console: no errors.
Task 6: Frontend - /api/resort integration with sequence number + 5xx handling
Files:
-
Modify:
/opt/sortof/frontend/sortof-app.jsx -
Step 1: Backup
cp /opt/sortof/frontend/sortof-app.jsx /opt/sortof/frontend/sortof-app.jsx.bak-$(date +%Y%m%d-%H%M)
- Step 2: Add
runResort()async function in App
Find the App function. Below onToggleBranch (added in Task 5), add:
async function runResort(nextSelections) {
// Compose the flat list of selected mod_ids from MOD_DB + nextSelections.
// For wsids not in nextSelections, use the §4 default (all-ticked or
// first-only depending on radio mode). For wsids with N=1, include the
// sole mod_id unconditionally.
const byWsid = {};
for (const m of (_liveSortData?.MOD_DB || window.SORTOF_DATA?.MOD_DB || [])) {
const w = m.wsid || '';
if (!w) continue;
(byWsid[w] = byWsid[w] || []).push(m);
}
const ids = [];
for (const w of Object.keys(byWsid)) {
const branches = byWsid[w];
if (branches.length === 1) {
ids.push(branches[0].modId);
continue;
}
const stored = nextSelections[w];
const eff = stored !== undefined ? stored : defaultSelectionForBranches(branches);
for (const id of eff) ids.push(id);
}
if (!ids.length) {
// Nothing selected; no point hitting the API. Synthesize an empty payload
// so the UI shows "0 of N" without dispatching a 400.
_liveSortData = { ..._liveSortData, MOD_DB: [], SORTED_ORDER: [],
MODS_LINE: '', WORKSHOP_ITEMS_LINE: '', MAP_LINE: 'Muldraugh, KY',
WARNINGS: [{ tag: 'cycle', level: 'amber', msg: 'no mods selected - pick at least one branch' }],
pending: [], status: 'success' };
setCounts(c => ({ ...c, cached: 0, warnings: 1 }));
return;
}
const seq = resortSeq + 1;
setResortSeq(seq);
try {
const res = await fetch('/api/resort', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Sortof-Seq': String(seq) },
body: JSON.stringify({ selected_mod_ids: ids }),
});
// Drop stale: another resort has issued since this one started.
if (seq < resortSeq) return;
if (!res.ok) {
console.error('resort failed', res.status);
_liveSortData = {
..._liveSortData,
WARNINGS: [
...(_liveSortData?.WARNINGS || []),
{ tag: 'cycle', level: 'red', msg: "couldn't recompute sort - try again" },
],
};
setCounts(c => ({ ...c, warnings: (c.warnings || 0) + 1 }));
return;
}
const json = await res.json();
_liveSortData = json;
const cached = (json.MOD_DB || []).length;
const warns = (json.WARNINGS || []).length;
setCounts({ cached, queued: 0, parsing: 0, warnings: warns });
setState(json.status || 'success');
} catch (e) {
console.error('resort threw', e);
_liveSortData = {
..._liveSortData,
WARNINGS: [
...(_liveSortData?.WARNINGS || []),
{ tag: 'cycle', level: 'red', msg: "couldn't recompute sort - try again" },
],
};
setCounts(c => ({ ...c, warnings: (c.warnings || 0) + 1 }));
}
}
(Note: _liveSortData is the module-level let-binding from Task 0 of the original frontend - already present at the top of sortof-app.jsx.)
- Step 3: Update
onToggleBranchto trigger resort
Find the existing onToggleBranch (Task 5). Replace its body with:
const onToggleBranch = (wsid, modId, branches) => {
setBranchSelections(prev => {
const radio = isRadioMode(branches);
const cur = prev[wsid] !== undefined
? prev[wsid]
: defaultSelectionForBranches(branches);
let next;
if (radio) {
next = [modId];
} else {
next = cur.includes(modId)
? cur.filter(x => x !== modId)
: [...cur, modId];
}
const updated = { ...prev, [wsid]: next };
// Fire-and-forget: state set + resort dispatch.
runResort(updated);
return updated;
});
};
- Step 4: Cross-tab
storagelistener also triggers resort
Find the storage useEffect from Task 3. Replace with:
useEffect(() => {
function onStorage(e) {
if (e.key !== 'sortof.branch.selections' || e.newValue === null) return;
try {
const parsed = JSON.parse(e.newValue);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
setBranchSelections(parsed);
runResort(parsed);
}
} catch {}
}
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, [resortSeq]);
- Step 5: Trigger an initial resort if hydrated
branchSelectionsdiffers from defaults
Add another useEffect just below the previous, fired once on mount when _liveSortData first becomes truthy (i.e. after the first /api/sort response):
useEffect(() => {
// Hydrate-driven resort: after the user submits and gets MOD_DB,
// if branchSelections has entries, re-run sort against them.
if (state === 'success' || state === 'partial') {
if (Object.keys(branchSelections).length > 0) {
runResort(branchSelections);
}
}
// eslint-disable-next-line
}, [state]);
- Step 6: Verify served file
curl -sS http://100.114.205.53:8801/sortof-app.jsx | grep -cE "runResort|/api/resort|X-Sortof-Seq|couldn't recompute sort"
Expected: >= 5.
- Step 7: Manual browser smoke - happy path
Hard-refresh. Submit AuthenticZ. Expand. Untick AuthenticZLite.
Expected:
-
Network tab shows POST
/api/resortwith body{"selected_mod_ids":["Authentic Z - Current","AuthenticZBackpacks+"]}. -
Response 200,
MODS_LINEcontains 2 mod_ids only. -
Output panel updates.
-
Affordance reads
✓ 2 of 3. -
WARNINGS no longer contains
ambiguous-multi-branchfor that wsid (because user touched it). -
Step 8: Manual browser smoke - race
Click toggle 2-3 times rapidly (untick, retick, untick). Inspect Network tab - multiple resort requests fire. Whichever response is the latest wins; older responses are dropped without state mutation. Final UI state matches the last toggle.
- Step 9: Manual browser smoke - 5xx (optional)
Edit /opt/sortof/api/app.py to add raise HTTPException(500) at the top of resort_endpoint. py_compile, restart api. Toggle a branch in the browser. Expect:
- Output state retains prior MOD_DB/MODS_LINE.
- A red WARNINGS entry appears:
couldn't recompute sort - try again. - Restore
app.pyfrom backup, restart api, toggle again - works again.
# Restore after the 5xx test
cp /opt/sortof/api/app.py.bak-* /opt/sortof/api/app.py
sudo systemctl restart sortof-api
Task 7: Frontend - CSS for picker
Files:
-
Modify:
/opt/sortof/frontend/index.html -
Step 1: Backup
cp /opt/sortof/frontend/index.html /opt/sortof/frontend/index.html.bak-$(date +%Y%m%d-%H%M)
- Step 2: Add CSS rules
Find the existing .copy-btn.copied rule in index.html (around line 374). Below it, add:
/* multi-branch picker (Spec A) */
.branch-affordance {
appearance: none;
border: 1px solid var(--line);
background: transparent;
color: var(--acc-blue);
font-family: var(--mono);
font-size: 11px;
padding: 1px 6px;
border-radius: 3px;
cursor: pointer;
}
.branch-affordance:hover { color: var(--fg); border-color: var(--line-2); background: var(--bg-3); }
.branch-panel {
background: var(--bg-2);
border-top: 1px dashed var(--line);
border-bottom: 1px dashed var(--line);
padding: 0;
}
.branch-panel-inner {
padding: 8px 12px 10px;
font-family: var(--mono);
font-size: 11px;
}
.branch-panel-meta { color: var(--fg-3); margin-bottom: 6px; letter-spacing: 0.04em; }
.branch-row {
display: grid;
grid-template-columns: 18px 220px 1fr 70px 1fr 60px;
gap: 10px;
align-items: center;
padding: 4px 0;
border-top: 1px solid var(--line);
cursor: pointer;
}
.branch-row:hover { background: var(--bg-3); }
.branch-row.is-checked .branch-modid { color: var(--acc-green); }
.branch-input { accent-color: var(--acc-green); }
.branch-modid { color: var(--fg-2); }
.branch-name { color: var(--fg-3); }
.branch-deps { color: var(--fg-3); font-size: 10.5px; }
.branch-pos { color: var(--fg-3); font-size: 10.5px; text-align: right; }
- Step 3: Verify served file
curl -sS http://100.114.205.53:8801/ | grep -c 'branch-affordance\|branch-panel\|branch-row'
Expected: >= 6.
- Step 4: Manual browser smoke
Hard-refresh. Open AuthenticZ in the picker. Expected:
- Affordance is a small bordered button-like element with blue text.
- Expansion panel has dashed top/bottom borders, slightly inset background.
- Each branch row has a checkbox/radio, mod_id, name, category pill, deps, and position cell.
- Hovering a branch row tints it slightly. Checked rows show the mod_id in green.
Task 8: Verification - run §13 test cases against running stack
For each case, document expected vs actual. If any fails, return to the relevant task and fix.
- Step 1: §13.1 AuthenticZ canonical
curl -sS -X POST http://100.114.205.53:8801/api/sort \
-H 'Content-Type: application/json' \
-d '{"input":"2335368829"}' \
| jq '{warns: [.WARNINGS[] | select(.tag=="ambiguous-multi-branch")] | length, mod_db_count: (.MOD_DB|length)}'
Expected: {"warns":1,"mod_db_count":3}. In the browser: parent row reads ▾ 3 branches, expansion panel mode = checkboxes, all 3 ticked.
- Step 2: §13.2 Cooperative pack
(No 3-mod cooperative wsid is in the cache yet. Skip or use AuthenticZ as a stand-in: same UI behavior, the difference is only conceptual.)
- Step 3: §13.3 Mutually exclusive 2-branch
(No such wsid in cache. To test, manually inject in DB:
-- Run via sudo docker exec -i sortof_db psql -U sortof -d sortof -c "..."
UPDATE mod_parsed SET incompatible_mods = '{AuthenticZBackpacks+}'::text[]
WHERE workshop_id='2335368829' AND mod_id='Authentic Z - Current';
UPDATE mod_parsed SET incompatible_mods = '{Authentic Z - Current}'::text[]
WHERE workshop_id='2335368829' AND mod_id='AuthenticZBackpacks+';
Then re-submit 2335368829 in the browser. Expected: panel mode = radios, default = Authentic Z - Current only (first by parsed_at, mod_id). Restore via:
UPDATE mod_parsed SET incompatible_mods = '{}'::text[] WHERE workshop_id='2335368829';
```)
- [ ] **Step 4: §13.4 Persistence across reload**
In browser: untick `AuthenticZLite`. Hard-refresh. After the next /api/sort, the resort fires automatically (Task 6 step 5 useEffect) and the picker shows `✓ 2 of 3` again. `localStorage.getItem('sortof.branch.selections')` shows the persisted entry.
- [ ] **Step 5: §13.5 Stored mod_id no longer exists (checkbox mode)**
Console: `localStorage.setItem('sortof.branch.selections', JSON.stringify({"2335368829": ["GhostBranchThatDoesNotExist"]}))`. Hard-refresh. Submit AuthenticZ. Expected: panel default applied (all 3 ticked); no console error.
- [ ] **Step 6: §13.6 Cross-wsid incompatibility**
(Use the canonical 3-mod input; `TMC_TrueActions` requires `tsarslib` but they're on different wsids - both N=1.) Expected: no picker UI, no `ambiguous-multi-branch` warning.
- [ ] **Step 7: §13.7 Zero-tick wsid**
Untick all 3 AuthenticZ branches in the browser. Expected: parent row reads `✓ 0 of N`, MODS_LINE excludes all 3, a synthetic warning appears (`no mods selected - pick at least one branch` if multiple wsids present, or the resort returns 400 → red retry warning if AuthenticZ was the only input). Re-tick at least one branch - UI recovers.
- [ ] **Step 8: §13.8 Radio-mode eviction-to-empty**
Apply the SQL from Step 3 (radio mode). Console: `localStorage.setItem('sortof.branch.selections', JSON.stringify({"2335368829": ["GhostMod"]}))`. Hard-refresh. Submit. Expected: silently drops the ghost; default-first applied (only `Authentic Z - Current` ticked). Restore SQL.
- [ ] **Step 9: §13.10 Stale resort response discarded**
Throttle Network in DevTools to "Slow 3G". Click toggle 1 (untick Backpacks), wait ~200ms, click toggle 2 (untick Lite). Inspect Network: two requests fire. The slower (toggle 1) finishes second. Expected: only toggle 2's response is applied; UI shows `✓ 1 of 3` (only Current selected), not `✓ 2 of 3`.
- [ ] **Step 10: §13.12 Cross-tab sync**
Open two tabs at `http://100.114.205.53:8801/`. Submit AuthenticZ in both. In tab A, untick a branch. Expected: tab B's affordance updates to match, network tab in B shows a /api/resort (triggered by the storage event listener).
- [ ] **Step 11: §13.13 Unknown selected_mod_id from server**
```bash
curl -sS -X POST http://100.114.205.53:8801/api/resort \
-H 'Content-Type: application/json' \
-d '{"selected_mod_ids":["modoptions","ghostMod"]}' \
| jq '.MOD_DB | map(.modId)'
Expected: ["modoptions"]. Server log: INFO "resort dropped unknown mod_ids count=1 sample=['ghostMod']".
sudo journalctl -u sortof-api --since "2 min ago" | grep "resort dropped"
- Step 12: Final regression - canonical 3-mod
curl -sS -X POST http://100.114.205.53:8801/api/sort \
-H 'Content-Type: application/json' \
-d '{"input":"2169435993;2392709985;2487022075"}' \
| jq '{status, MODS_LINE, warns: (.WARNINGS|length)}'
Expected: {"status":"success","MODS_LINE":"modoptions;tsarslib;TMC_TrueActions","warns":0}. No regression from baseline.
Self-review (already applied)
- Spec coverage: all of §3–§10 acceptance criteria mapped to a task.
- Placeholders: none in the plan body.
- Type consistency:
branchSelectionsis{[wsid]: string[]}everywhere;expandedWsidsisSet<string>;runResorttakesnextSelectionsof the same shape asbranchSelections;defaultSelectionForBranchesandisRadioModeboth takebranches: MOD_DB[]and read.modId/.conflictsconsistently. - No git, by design: every code-changing task starts with a
cp file file.bak-$(date)step in lieu of a commit. - Restart-vs-no-restart: backend tasks (1, 2) end with
sudo systemctl restart sortof-api; frontend tasks (3–7) do not -StaticFilesserves from disk, hard-refresh in browser is sufficient.