# 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=[]`): wsid `2335368829` → mod_ids `Authentic 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** ```bash cp /opt/sortof/api/app.py /opt/sortof/api/app.py.bak-$(date +%Y%m%d-%H%M) ``` - [ ] **Step 2: Add `ResortRequest` Pydantic model** Find the existing `class SortRequest(BaseModel):` block (around line 76). Replace it with both models: ```python 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/resort` route** 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: ```python @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** ```bash /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** ```bash sudo systemctl restart sortof-api && sleep 2 && sudo systemctl is-active sortof-api ``` Expected: `active`. - [ ] **Step 6: Verify happy path** ```bash 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: ```json {"status":"success","MODS_LINE":"modoptions;tsarslib;TMC_TrueActions","mod_db_count":3,"pending":[]} ``` - [ ] **Step 7: Verify validation 400s** ```bash # 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** ```bash 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: ```python 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: ```python "WARNINGS": ( build_warnings(sort_result.get("warnings", {}) or {}) + _compute_ambiguous_warnings(mods) ), ``` - [ ] **Step 4: py_compile + smoke import** ```bash /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** ```bash sudo systemctl restart sortof-api && sleep 2 && sudo systemctl is-active sortof-api ``` - [ ] **Step 6: Verify warning fires for AuthenticZ** ```bash 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** ```bash 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** ```bash 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** ```bash cp /opt/sortof/frontend/sortof-app.jsx /opt/sortof/frontend/sortof-app.jsx.bak-$(date +%Y%m%d-%H%M) ``` - [ ] **Step 2: Add `COLUMN_COUNT` constant** Find the helper functions near the top of the file (after `buildModsLine`, around line 35). Add: ```jsx // Shared column count for the Mod Details table. Keep in sync with the // in ModTable; expansion-panel reads this constant. // Spec C will add a 7th column; bump here, do not hardcode the integer. const COLUMN_COUNT = 6; ``` - [ ] **Step 3: Add `branchSelections` App-level state with localStorage hydration** Find the App function's state declarations (around line 456). Below the existing `pzBuild` block, add: ```jsx 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 `storage` listener** Below the `pzBuild` localStorage useEffect (around line 467), add: ```jsx 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** ```bash 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/`: 1. Open DevTools console. 2. Run `localStorage.setItem('sortof.branch.selections', JSON.stringify({"2335368829": ["Authentic Z - Current"]}))`. 3. Hard-refresh the page. 4. In console: `localStorage.getItem('sortof.branch.selections')` - should return the same JSON. 5. 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` (replace `ModTable` body around line 306; pass `branchSelections` from App) - [ ] **Step 1: Backup** (if more than ~10 minutes since last) ```bash cp /opt/sortof/frontend/sortof-app.jsx /opt/sortof/frontend/sortof-app.jsx.bak-$(date +%Y%m%d-%H%M) ``` - [ ] **Step 2: Replace `ModTable` with 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: ```jsx 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 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 (
setOpen(!open)}> mod details · {(D.SORTED_ORDER || []).length} mods why everything ended up where it did {open ? '▾' : '▸'}
{open && ( {rowSpecs.map((spec, i) => { const idx = String(i + 1).padStart(2, '0'); if (!spec.isMulti) { const m = spec.primary; return ( ); } // 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 ( {expanded && ( )} ); })}
# mod id workshop id category dependencies load
{idx} {m.modId} {spec.wsid} {m.cat} {m.deps && m.deps.length ? m.deps.join(', ') : '-'} {m.pos === 'first' && first} {m.pos === 'last' && last} {!m.pos && -}
{idx} {spec.wsid} {display ? {display.cat} : '-'} {display && display.deps && display.deps.length ? display.deps.join(', ') : '-'} {display && display.pos === 'first' && first} {display && display.pos === 'last' && last} {(!display || !display.pos) && -}
)}
); } ``` - [ ] **Step 3: Update App to maintain `expandedWsids` state and pass everything down** Find App's other useState calls. Add: ```jsx 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 `` invocation in `RightColumn` needs updating in Step 4 below.) - [ ] **Step 4: Pass props through `RightColumn` to `ModTable`** Find the `RightColumn` function signature (around line 372). Add `branchSelections, onToggleBranch, expandedWsids, onToggleExpansion` to the destructured props: ```jsx function RightColumn({ state, counts, progress, emptyVariant, successVariant, modTableDefault, pzBuild, setPzBuild, branchSelections, onToggleBranch, expandedWsids, onToggleExpansion }) { ``` Then find the `` invocation inside `RightColumn` (around line 437). Replace with: ```jsx ``` - [ ] **Step 5: Pass props from App to `RightColumn`** Find the `` invocation in App (around line 613). Append: ```jsx branchSelections={branchSelections} onToggleBranch={() => {}} expandedWsids={expandedWsids} onToggleExpansion={onToggleExpansion} ``` (`onToggleBranch` is a placeholder no-op for now; replaced in Task 5.) - [ ] **Step 6: Verify served file** ```bash 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 N` if hydrated from earlier Task 3 testing). - Single-mod wsids appear as normal rows. - Clicking the `▾` affordance toggles `expandedWsids` (panel visibility - content is empty until Task 5 adds `BranchPicker`). - Console: no errors. (`BranchPicker is not defined` is 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** ```bash 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: ```jsx // 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 `BranchPicker` component** Just above `function ModTable(` (around line 306, **before** the table), insert: ```jsx 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 (
workshop {wsid} · {branches.length} mod_ids · {radio ? 'pick one' : 'multi-select'}
{branches.map(b => { const checked = effective.includes(b.modId); return ( ); })}
); } ``` - [ ] **Step 4: Implement `onToggleBranch` in App** Find the App function (around line 454). Below the `expandedWsids` state, add: ```jsx 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 `onToggleBranch` placeholder in ``** Find the `` invocation in App (Task 4 added `onToggleBranch={() => {}}`). Replace with: ```jsx onToggleBranch={onToggleBranch} ``` - [ ] **Step 6: Verify served file** ```bash 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** ```bash 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: ```jsx 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 `onToggleBranch` to trigger resort** Find the existing `onToggleBranch` (Task 5). Replace its body with: ```jsx 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 `storage` listener also triggers resort** Find the storage useEffect from Task 3. Replace with: ```jsx 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 `branchSelections` differs 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): ```jsx 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** ```bash 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/resort` with body `{"selected_mod_ids":["Authentic Z - Current","AuthenticZBackpacks+"]}`. - Response 200, `MODS_LINE` contains 2 mod_ids only. - Output panel updates. - Affordance reads `✓ 2 of 3`. - WARNINGS no longer contains `ambiguous-multi-branch` for 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.py` from backup, restart api, toggle again - works again. ```bash # 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** ```bash 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: ```css /* 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** ```bash 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** ```bash 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: ```sql -- 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: ```sql 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']". ```bash sudo journalctl -u sortof-api --since "2 min ago" | grep "resort dropped" ``` - [ ] **Step 12: Final regression - canonical 3-mod** ```bash 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:** `branchSelections` is `{[wsid]: string[]}` everywhere; `expandedWsids` is `Set`; `runResort` takes `nextSelections` of the same shape as `branchSelections`; `defaultSelectionForBranches` and `isRadioMode` both take `branches: MOD_DB[]` and read `.modId`/`.conflicts` consistently. - **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 - `StaticFiles` serves from disk, hard-refresh in browser is sufficient.