Stage warning actions; defer sort to explicit click

Add/remove/swap warning-action handlers no longer auto-fire /api/sort.
They mutate the input textarea idempotently; the sort button gets a
pending cue when current input != last-sorted input. Branch-picker
(/api/resort, cheap) keeps instant behavior. Spec lives in
docs/specs/2026-05-04-staged-warning-actions.md.
This commit is contained in:
2026-05-04 14:16:33 +00:00
parent 55d3794bfb
commit a15d35214e
3 changed files with 255 additions and 34 deletions

View File

@@ -0,0 +1,137 @@
# Spec — Staged warning actions
**Date:** 2026-05-04
**Status:** Draft (awaiting review)
**Lineage:** Sits on top of Spec C §3 (Dep Add) and Spec A (multi-branch picker). Modifies the post-click behavior of warning action buttons; does not change the warnings themselves, the backend, or the picker contract.
## 1. Summary
Today, clicking `[add modId]`, `[✕ remove]`, or `[↔ swap]` in a warning row mutates the input textarea **and immediately fires a fresh `/api/sort`**. This spec defers the sort: those three handlers become pure textarea mutators, the user accumulates as many add/remove/swap edits as they want, and the existing `sort` button is the explicit "apply now" trigger. The branch-picker (cheap `/api/resort`) keeps its current instant behavior.
Visual feedback is twofold: each warning row whose action target is now satisfied by the current input gets a strikethrough + `✓ pending` button label; the main sort button gets a "pending" cue when `current input ≠ last-sorted input`.
## 2. Problem
The current "instant resort on click" loop punishes the user when they want to triage warnings in a batch:
- `/api/sort` is the **slow** endpoint — every wsid hits Steam's metadata lookup; uncached mods queue a DepotDownloader pull (~30s cold per mod). Three sequential clicks = three slow round-trips.
- Users who reconsider mid-flight (add X, change mind, remove X) eat two full sort cycles for net-zero state change.
- The chained reflows feel jumpy: each click rerenders the result panel and the warnings list, which can shuffle the row the user was about to click next.
Branch-picker doesn't have this problem — `/api/resort` uses the cached MOD_DB and returns in ~100ms — so it's left alone.
## 3. Behavior
### 3.1 Action handlers
`onAddWsid` / `onRemoveWsid` / `onSwapWsid` (sortof-app.jsx ~1348 / ~1363 / ~1381) drop their trailing `onSort(newInput)` call. They become pure mutators of `input`. The `setBranchSelections + runResort` handler `onPickBranch` is **untouched**.
These handlers also become **idempotent toggles**: if the action's effect is already present in the current input (e.g., `add-wsid` for a wsid already in input, or `remove-wsid` for a wsid already absent), the second click reverses the first. This is what powers the per-warning "click to undo" UX without a separate undo affordance.
| Action | First click | Second click |
|---|---|---|
| `add-wsid X` | append `X` to textarea | remove `X` from textarea |
| `remove-wsid X` | remove `X` from textarea | append `X` back to textarea |
| `swap-wsid A→B` | replace `A` with `B` | replace `B` with `A` |
### 3.2 Warning row "staged" derivation (stateless)
For each warning row with at least one mutating action, derive a boolean `staged` from current `input`:
- `add-wsid` action targeting `wsid W``staged = (W ∈ parseWorkshopInput(input))`
- `remove-wsid` action targeting `wsid W``staged = (W ∉ parseWorkshopInput(input))`
- `swap-wsid` action `from A, to B``staged = (A ∉ input ∧ B ∈ input)`
A warning may have multiple actions; if any one is satisfied, the row is staged.
When `staged === true`:
- Row gets `.staged` class — strikethrough text + reduced opacity (~0.55).
- Mutating action button label flips to `✓ pending` and `title` changes to `click to undo` (or per-type analogue).
- Non-mutating action buttons on the row (`search-workshop`, branch-picker expand) are unchanged.
This derivation runs every render against `parseWorkshopInput(input)`. No new state, no syncing logic. Free benefit: if the user manually edits the textarea, all stage indicators update on the next render automatically.
### 3.3 Sort button "pending" cue
A new `lastSortedInputRef` (React `useRef`) tracks the input string at the moment the most recent successful `onSort` completed.
- On `onSort` success path (existing fetch handler): `lastSortedInputRef.current = inputThatWasSent`.
- On `onSort` failure path: do **not** update — preserves the pending cue so the user knows the resort didn't land.
- Initial value: `null`. The cue only appears once the user has done at least one sort and *then* drifted from it.
The main sort button reads `pending = (input !== lastSortedInputRef.current && lastSortedInputRef.current !== null)` and applies a visual modifier (CSS class `.sort-pending`) when true. Concrete treatment: a small filled dot suffix or pill (`var(--info)` or `var(--warning)`, exact pick during impl).
### 3.4 Multi-warning resolution
Because `staged` is derived purely from `input`, a single textarea mutation marks **every** warning whose action target matches. Example: three mods all missing dep `tsarslib`; clicking `[add 2392709985]` on warning A marks warnings A, B, and C as `✓ pending` simultaneously, since all three have `add-wsid` actions targeting the same wsid. No event-broadcasting code needed; it's a free property of the stateless derivation.
### 3.5 Branch-picker (unchanged)
`onPickBranch` continues to call `runResort(updated)` synchronously. Rationale: `/api/resort` is fast, doesn't hit Steam, and has no slow path; the current "instant" feel is correct for it. The pending cue/strikethrough machinery does not apply to branch warnings.
## 4. Components touched
All changes are in **`/opt/sortof/frontend/sortof-app.jsx`** (one file). No backend, no schema, no CSS file outside the inline `<style>` blocks already in `index.html` / co-located in the JSX.
| Symbol | Change |
|---|---|
| `onAddWsid` (~1348) | Drop `onSort(newInput)`. Add idempotent toggle: if `wsid` already in input, remove it instead of appending. |
| `onRemoveWsid` (~1363) | Drop `onSort(newInput)`. Add idempotent toggle: if `wsid` already absent, re-append it. |
| `onSwapWsid` (~1381) | Drop `onSort(newInput)`. Add idempotent toggle: if input is in `from→to` swapped state already, swap back to `to→from`. |
| `onPickBranch` (~1408) | **No change.** |
| `onSort` success branch | Set `lastSortedInputRef.current = <the input string just sorted>`. |
| `lastSortedInputRef` | New `useRef(null)`. Lives next to existing `latestResortSeqRef` (~964). |
| Warning render helper (~564611) | Compute `staged` per warning. Apply `.staged` class to the row, swap mutating action button labels/titles when `staged`. |
| Main sort button (~1809) | Append `.sort-pending` class when `input !== lastSortedInputRef.current` (and ref is non-null). |
| CSS (`index.html`) | Add `.warn-row.staged { opacity: .55; text-decoration: line-through; }` (or equivalent on the existing warning-row selector — exact selector verified during impl). Add `.sort-pending` indicator rule (small dot or border accent using `var(--info)` / `var(--warning)`). |
`parseWorkshopInput` is already used elsewhere in the file (e.g., `onAddWsid` itself); no new helper needed.
## 5. Out of scope
- **Per-op undo affordance separate from the action button.** The action button's idempotent toggle behavior is the undo. No "discard all pending" button.
- **Predictive cascade.** We don't simulate the sort to predict which warnings would be resolved by a staged change beyond a direct match against the action target. If adding mod X would *also* resolve a missing-dep on a different mod Y, that warning's row stays un-staged until the next real sort surfaces fresh warnings.
- **Result-panel staleness overlay.** The right panel keeps showing the prior sort. The sort-button cue + strikethroughs are deemed sufficient signal. (Considered and rejected during brainstorming.)
- **Pending-changes summary panel** (e.g., "+3 / 1 / 1 swap" badge). YAGNI; the strikethroughs already enumerate exactly what will change.
- **Backend changes.** The `/api/sort` and `/api/resort` contracts, warning shapes, and action descriptors are all unchanged. Pure frontend.
- **Branch-picker staging.** Out of scope per §3.5; reconsider only if `/api/resort` becomes slow.
## 6. Acceptance criteria
- [ ] Clicking `[add modId]` on a missing-dep warning appends the wsid to the textarea and does **not** fire `/api/sort` or `/api/resort`.
- [ ] Clicking `[✕ remove]` on a build-mismatch warning removes the wsid from the textarea and does not fire any sort.
- [ ] Clicking `[↔ swap A→B]` on a build-mismatch warning replaces A with B in the textarea and does not fire any sort.
- [ ] Clicking the same `[add X]` button twice leaves the textarea in its pre-click state (idempotent toggle).
- [ ] Clicking branch-picker still triggers `/api/resort` immediately (unchanged).
- [ ] When a warning's action target is satisfied by the current input, the warning row renders with reduced opacity + line-through, and the action button label changes to `✓ pending`.
- [ ] When two warnings share an action target (e.g., two missing-dep warnings both pointing at the same wsid), clicking the action on one stages **both**.
- [ ] When `current input !== last-sorted input` (and at least one prior sort has happened), the main sort button shows a pending visual cue.
- [ ] After a successful `/api/sort` completes, `lastSortedInputRef` is updated and the pending cue disappears.
- [ ] After a *failed* `/api/sort`, the pending cue persists.
- [ ] Manually editing the textarea (typing) updates the staged-warning indicators on the next render without explicit dispatch.
- [ ] No backend file is modified; `app.py`, `worker.py`, `mlos_sort.py`, `adapters.py` untouched.
## 7. Test cases
1. **Single add, no sort.** Input has 3 mods producing one missing-dep warning for `tsarslib`. Click `[add 2392709985]`. Expect: textarea now shows 4 wsids; right panel still shows 3-mod result; warning row strikethrough; sort button has pending cue. No network request fired.
2. **Two-warning shared-target stage.** Input produces two missing-dep warnings, both for `tsarslib`. Click `[add 2392709985]` on either. Expect: both warning rows strike through.
3. **Idempotent add.** Click `[add X]`, then click it again. Expect: textarea returns to original; warning row un-stages; sort button pending cue clears (assuming this was the only edit).
4. **Add-then-manual-delete.** Click `[add X]`. Manually delete `X` from textarea. Expect: warning row un-stages on next render; sort-button cue may stay (textarea still differs from `lastSortedInput` if user added/removed extra whitespace, otherwise clears).
5. **Swap then sort.** Two B41↔B42 swap warnings on different wsids. Click swap on both. Expect: both rows stage. Click sort. Expect: `/api/sort` fires once with both swaps applied; on success, both rows now reflect fresh post-sort state (warnings either gone or different).
6. **Branch-picker untouched.** Open the picker on a multi-branch wsid; tick a different branch. Expect: `/api/resort` fires immediately; result panel updates; no pending cue persists.
7. **Failed sort preserves cue.** Stage one add. Force `/api/sort` to fail (e.g., simulate 500). Expect: result panel doesn't update, warning rows remain staged, sort-button cue remains.
8. **Sort with no pending changes.** No prior edits, click sort. Expect: normal sort fires (existing behavior); cue does not appear before nor after.
## 8. Implementation notes
- The idempotent-toggle logic for `onAddWsid` reuses the existing `parseWorkshopInput(input).includes(wid)` check; today that path early-returns (silent dedupe), the spec replaces the early return with a remove-and-set.
- The `staged` derivation can be a small helper inside the warnings renderer (`deriveStaged(actions, inputWsids)`) to avoid duplicating the per-action-type logic across the three button types. `inputWsids` should be computed once per render via `useMemo(() => new Set(parseWorkshopInput(input)), [input])` to avoid quadratic cost when there are many warnings.
- The `.sort-pending` styling lives in `frontend/index.html`'s `<style>` block alongside the other `.icon-btn.report` / sort-button rules. Use existing tokens (`var(--info)`, `var(--warning)`); no new color literals.
## 9. Locked decisions (do not relitigate)
- **Stateless derivation** is the data model — no separate "pending ops" list. (§3.2)
- **Branch picker stays live** — not staged. (§3.5)
- **No result-panel overlay.** Sort button + strikethroughs are the only visual cues. (§5)
- **Action button is the undo.** No separate undo button per warning. (§3.1)
- **No predictive cascade** beyond direct action-target matches. (§5)

View File

@@ -529,6 +529,17 @@
box-shadow: var(--brand-shadow-card);
}
.sort-btn:active { filter: brightness(0.92); transform: translateY(1px); }
/* Pending changes: input differs from last-sorted input. Subtle pulsing
* info-toned ring around the brand-filled button so the CTA still reads as
* primary but the user sees "you have unapplied edits". */
.sort-btn.sort-pending {
box-shadow: 0 0 0 2px var(--info, var(--acc-blue)), var(--brand-shadow-card);
animation: sort-pending-pulse 1.6s ease-in-out infinite;
}
@keyframes sort-pending-pulse {
0%, 100% { box-shadow: 0 0 0 2px var(--info, var(--acc-blue)), var(--brand-shadow-card); }
50% { box-shadow: 0 0 0 4px var(--info, var(--acc-blue)), var(--brand-shadow-card); }
}
.sort-btn[disabled] {
cursor: not-allowed;
border-color: var(--border);
@@ -1029,6 +1040,26 @@
color: var(--brand-ink);
border-color: var(--error);
}
/* Staged warning row: edit applied to input but not yet sorted. Subtle —
* strike + dim — so the row stays readable but visibly "addressed". The
* action button itself flips to the success palette via .warn-action.staged
* to signal "click again to undo". */
.warn-list li.staged .w-msg,
.warn-list li.staged .w-tag {
opacity: 0.55;
text-decoration: line-through;
}
.warn-action.staged {
border-color: var(--success, var(--acc-green));
background: var(--success-bg, rgba(80,180,120,0.18));
color: var(--success, var(--acc-green));
opacity: 1;
text-decoration: none;
}
.warn-action.staged:hover {
background: var(--success, var(--acc-green));
color: var(--brand-ink);
}
/* Inline mod-name hyperlink inside warning messages. Inherits the message
* color so reading flow isn't disrupted; underline + brand color on hover
* signals it's clickable. */

View File

@@ -546,15 +546,28 @@ function WarnMsg({ msg, wsid }) {
);
}
function WarnRow({ w, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onToggleBranch }) {
// True when this action's intended effect is already present in the current
// input — i.e., the user has staged this change but hasn't sorted yet. Drives
// the per-button ✓-pending label and the row-level strikethrough.
function actionStaged(a, inputWsids) {
if (a.type === 'add-wsid') return inputWsids.has(String(a.wsid));
if (a.type === 'remove-wsid') return !inputWsids.has(String(a.wsid));
if (a.type === 'swap-wsid') return !inputWsids.has(String(a.from)) && inputWsids.has(String(a.to));
return false;
}
function WarnRow({ w, inputWsids, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onToggleBranch }) {
// Branch picker collapses by default; click "pick another" chev to expand.
const [branchOpen, setBranchOpen] = useState(false);
// Any warning that ships an `alternatives` array gets an inline picker.
// Covers auto-picked-branch (mutually exclusive primary pick) AND
// unmatched-addons (additive addon ticking) — both drive the same UI.
const hasPicker = Array.isArray(w.alternatives);
// Row is staged when ANY mutating action's effect is satisfied by the
// current input. Per-button labels flip independently below.
const rowStaged = (Array.isArray(w.actions) ? w.actions : []).some(a => actionStaged(a, inputWsids));
return (
<li>
<li className={rowStaged ? 'staged' : undefined}>
<span className={'w-tag ' + w.level}>
<span aria-hidden="true" className="w-tag-glyph">{w.level === 'red' ? '!' : '⚠'}</span>
{w.tag}
@@ -563,36 +576,39 @@ function WarnRow({ w, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onToggl
<WarnMsg msg={w.msg} wsid={w.wsid} />
{Array.isArray(w.actions) && w.actions.map((a, j) => {
if (a.type === 'add-wsid') {
const staged = actionStaged(a, inputWsids);
return (
<button
key={j}
type="button"
className="warn-action"
className={'warn-action' + (staged ? ' staged' : '')}
onClick={() => onAddWsid && onAddWsid(a.wsid, a.modId)}
title={`add ${a.wsid} to your input and resort`}
>{a.label}</button>
title={staged ? `click to undo (will remove ${a.wsid})` : `add ${a.wsid} to your input — click sort when ready`}
>{staged ? `${a.label}` : a.label}</button>
);
}
if (a.type === 'swap-wsid') {
const staged = actionStaged(a, inputWsids);
return (
<button
key={j}
type="button"
className="warn-action swap"
className={'warn-action swap' + (staged ? ' staged' : '')}
onClick={() => onSwapWsid && onSwapWsid(a.from, a.to)}
title={`replace ${a.from} with ${a.to} in your input and resort`}
> {a.label}</button>
title={staged ? `click to undo (will swap back to ${a.from})` : `replace ${a.from} with ${a.to} — click sort when ready`}
>{staged ? '✓' : '↔'} {a.label}</button>
);
}
if (a.type === 'remove-wsid') {
const staged = actionStaged(a, inputWsids);
return (
<button
key={j}
type="button"
className="warn-action remove"
className={'warn-action remove' + (staged ? ' staged' : '')}
onClick={() => onRemoveWsid && onRemoveWsid(a.wsid)}
title={`remove ${a.wsid} from your input and resort`}
> {a.label}</button>
title={staged ? `click to undo (will re-add ${a.wsid})` : `remove ${a.wsid} from your input — click sort when ready`}
>{staged ? '✓' : '✕'} {a.label}</button>
);
}
if (a.type === 'search-workshop') {
@@ -668,7 +684,7 @@ function WarnRow({ w, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onToggl
);
}
function Warnings({ items, defaultOpen = true, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onToggleBranch }) {
function Warnings({ items, defaultOpen = true, inputWsids, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onToggleBranch }) {
const [open, setOpen] = useState(defaultOpen);
if (!items || items.length === 0) return null;
const reds = items.filter(w => w.level === 'red').length;
@@ -695,7 +711,7 @@ function Warnings({ items, defaultOpen = true, onAddWsid, onPickBranch, onSwapWs
{open && (
<ul className="warn-list">
{items.map((w, i) => (
<WarnRow key={i} w={w} onAddWsid={onAddWsid} onPickBranch={onPickBranch} onSwapWsid={onSwapWsid} onRemoveWsid={onRemoveWsid} onToggleBranch={onToggleBranch} />
<WarnRow key={i} w={w} inputWsids={inputWsids} onAddWsid={onAddWsid} onPickBranch={onPickBranch} onSwapWsid={onSwapWsid} onRemoveWsid={onRemoveWsid} onToggleBranch={onToggleBranch} />
))}
</ul>
)}
@@ -927,7 +943,7 @@ function BuildToggle({ value, onChange }) {
);
}
function RightColumn({ state, counts, progress, emptyVariant, successVariant, modTableDefault, pzBuild, setPzBuild, branchSelections, onToggleBranch, expandedWsids, onToggleExpansion, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onRetry, previousResult, diffOpen, setDiffOpen }) {
function RightColumn({ state, counts, progress, emptyVariant, successVariant, modTableDefault, pzBuild, setPzBuild, branchSelections, onToggleBranch, expandedWsids, onToggleExpansion, inputWsids, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onRetry, previousResult, diffOpen, setDiffOpen }) {
// Phase-state mapping: legacy `success` ≈ B+F `done`; legacy `partial` ≈ B+F `queued`/`draining`.
const isTerminalDone = state === 'success' || state === 'done';
const isInflightPartial = state === 'partial' || state === 'queued' || state === 'draining';
@@ -945,6 +961,7 @@ function RightColumn({ state, counts, progress, emptyVariant, successVariant, mo
<Warnings
items={D.WARNINGS}
defaultOpen={isTerminalDone}
inputWsids={inputWsids}
onAddWsid={onAddWsid}
onPickBranch={onPickBranch}
onSwapWsid={onSwapWsid}
@@ -1296,6 +1313,12 @@ function App() {
} catch { return {}; }
});
const latestResortSeqRef = useRef(0); // monotonic; for stale-response drop
// Tracks the input string at the moment the most recent /api/sort response
// arrived. Drives the sort-button "pending" cue and the per-warning staged
// strikethrough: when current `input` !== this ref, the user has staged
// edits via warning action buttons (or manual textarea typing) since their
// last sort. Stays null until the first successful sort.
const lastSortedInputRef = useRef(null);
// Contract: WORKSHOP_ITEMS_LINE, counts.queued, unknown[], non_mod[] are owned
// by /api/sort's response for the lifetime of that result set. /api/resort
// returns them but the client ignores those fields - wsid subscription and
@@ -1311,6 +1334,13 @@ function App() {
// ticks don't snapshot so the user's prior visible state stays available.
const previousResultRef = useRef(null);
const [diffOpen, setDiffOpen] = useState(false);
// Set of wsids currently in the input textarea, used by warning rows to
// derive their staged state. Memoized off `input` so re-renders triggered
// by unrelated state changes don't churn the Set.
const inputWsids = useMemo(() => new Set(parseWorkshopInput(input)), [input]);
// True when the user has staged edits since the last successful sort. Stays
// false until the first sort completes (lastSortedInputRef starts null).
const sortPending = lastSortedInputRef.current !== null && input !== lastSortedInputRef.current;
const onToggleExpansion = (wsid) => {
setExpandedWsids(prev => {
@@ -1342,47 +1372,66 @@ function App() {
});
};
// Append a wsid to the input textarea AND immediately resort so the user
// doesn't have to click sort again. Dedupes silently if the wsid is already
// in the input. Used by the missing-dep warning's [add modId] action button.
// Append a wsid to the input textarea. Idempotent toggle: a second click
// on the same [add wsid] button removes the wsid (i.e., undoes the stage).
// Sort is NOT fired here — the user collects edits, then clicks sort.
// Used by the missing-dep warning's [add modId] action button.
const onAddWsid = (wsid /*, modId */) => {
if (!wsid) return;
const wid = String(wsid);
const existing = parseWorkshopInput(input);
if (existing.includes(wid)) return;
if (existing.includes(wid)) {
const newInput = (input || '').replace(
new RegExp(`(?:^|(?<=[\\s;,]))${wid}(?=$|[\\s;,])`, 'g'),
''
).replace(/[\s;,]{2,}/g, m => m.includes('\n') ? '\n' : m[0]).trim();
setInput(newInput);
return;
}
const trimmed = (input || '').replace(/\s+$/, '');
const sep = trimmed ? '\n' : '';
const newInput = trimmed + sep + wid;
setInput(newInput);
onSort(newInput);
setInput(trimmed + sep + wid);
};
// Remove a wsid from the input textarea token-by-token (preserving the
// user's separators) and resort. Used by the build-mismatch warning's
// [✕ remove] button. No-op if the wsid isn't present.
// user's separators). Idempotent toggle: a second click on the same
// [✕ remove] button re-appends the wsid. No sort fired.
const onRemoveWsid = (wsid) => {
if (!wsid) return;
const w = String(wsid);
const existing = parseWorkshopInput(input);
if (!existing.includes(w)) return;
if (!existing.includes(w)) {
const trimmed = (input || '').replace(/\s+$/, '');
const sep = trimmed ? '\n' : '';
setInput(trimmed + sep + w);
return;
}
const newInput = (input || '').replace(
new RegExp(`(?:^|(?<=[\\s;,]))${w}(?=$|[\\s;,])`, 'g'),
''
).replace(/[\s;,]{2,}/g, m => m.includes('\n') ? '\n' : m[0]).trim();
setInput(newInput);
onSort(newInput);
};
// Replace `from` with `to` in the input textarea and resort. Used by the
// build-mismatch warning's [swap to <other-build version>] button when
// the same mod publishes both a B41 and a B42 wsid (e.g., tsarslib at
// 2392709985 ↔ 3402491515). Preserves the user's original separators
// (newlines, semicolons, commas) by doing a token-level replacement.
// Replace `from` with `to` in the input textarea. Idempotent toggle: if the
// input is already in the swapped state (from absent, to present), a second
// click reverses to the original (to → from). No sort fired. Used by the
// build-mismatch warning's [swap to <other-build>] button (e.g., tsarslib
// at 2392709985 ↔ 3402491515). Preserves the user's separators.
const onSwapWsid = (from, to) => {
if (!from || !to) return;
const f = String(from);
const t = String(to);
const existing = parseWorkshopInput(input);
// Already-swapped state → reverse direction.
if (!existing.includes(f) && existing.includes(t)) {
const newInput = (input || '').replace(
new RegExp(`(?<=^|[\\s;,])${t}(?=$|[\\s;,])`, 'g'),
f
);
setInput(newInput);
return;
}
if (!existing.includes(f)) return;
if (existing.includes(t)) {
// Target already present: just remove `from`. Otherwise we'd dupe.
@@ -1391,7 +1440,6 @@ function App() {
''
).replace(/[\s;,]{2,}/g, m => m.includes('\n') ? '\n' : m[0]).trim();
setInput(newInput);
onSort(newInput);
return;
}
const newInput = (input || '').replace(
@@ -1399,7 +1447,6 @@ function App() {
t
);
setInput(newInput);
onSort(newInput);
};
// Branch-warning button: lock the wsid to the chosen mod_id and resort.
@@ -1651,6 +1698,10 @@ function App() {
return;
}
const json = await res.json();
// Server accepted the input — clear the staged-edit pending cue. Both
// sync and async paths fall through here; failure paths early-return
// above and DO NOT update this ref (so the cue persists on retry).
lastSortedInputRef.current = submitInput;
if (json.job_id) {
// Async path - start polling and let the loop drive state.
if (pollAbortRef.current) { pollAbortRef.current.abort(); }
@@ -1805,14 +1856,15 @@ function App() {
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button
type="button"
className="sort-btn"
className={'sort-btn' + (sortPending && state !== 'loading' ? ' sort-pending' : '')}
onClick={onSort}
disabled={state === 'loading' || !input.trim()}
title={sortPending ? 'input has staged changes click to apply' : undefined}
>
{state === 'loading'
? <span className="spin" aria-hidden="true"></span>
: <span className="btn-glyph" aria-hidden="true">{(!input.trim()) ? '' : ''}</span>}
{state === 'loading' ? 'sorting' : 'sort'}
{state === 'loading' ? 'sorting' : (sortPending ? 'sort ' : 'sort')}
</button>
{activeJobId && (
<button
@@ -1881,6 +1933,7 @@ function App() {
onToggleBranch={onToggleBranch}
expandedWsids={expandedWsids}
onToggleExpansion={onToggleExpansion}
inputWsids={inputWsids}
onAddWsid={onAddWsid}
onPickBranch={onPickBranch}
onSwapWsid={onSwapWsid}