Skip to content

refactor(app): carve resume-session out of app.js (R3a)#892

Open
byrongamatos wants to merge 1 commit into
mainfrom
feat/r3-carve-resume
Open

refactor(app): carve resume-session out of app.js (R3a)#892
byrongamatos wants to merge 1 commit into
mainfrom
feat/r3-carve-resume

Conversation

@byrongamatos

@byrongamatos byrongamatos commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Stacked on #891. static/js/resume-session.js (157) — the snapshot taken when you leave a song, and the pill that offers it back. Bodies verbatim. app.js 7,727 → 7,601.

Fifth slice out of the strongly-connected core. One hook (playSong) plus a currentFilename getter.

S.pendingResume joins the container — on demand, exactly as intended

app.js writes it (playSong({ resume }) arms it; the song:ready listener consumes it) while this module reads it. So it can't be a plain export — an imported binding is read-only. Same reason isPlaying is there.

The container grows one field per carve that needs it, never speculatively. That's the discipline that keeps it from becoming the ~977-site big-bang lift.

The contract test caught the missing hook — again

playSong is read by a module but never wired by app.js — it would throw at runtime, on whatever path happens to reach them

Second time it has caught a real wiring gap the moment it appeared, on a path nothing executes.

A trap worth recording

I first did the S.pendingResume rewrite by feeding acorn's identifier ranges from node into python. It corrupted the file:

_pS.pendingResume null;

Acorn's offsets are UTF-16 code units; Python's string indices are code points. static/app.js contains emoji, so every offset past one drifts. Do an AST-driven rewrite in the same language that produced the offsets.

node --check caught it — but a silent version of that bug is very easy to imagine, and it would have landed as a one-character corruption somewhere in a 7,700-line file.

Verification

A/B against origin/main in two browsers, real song: the window API (resumeLastSession / _snapshotResumeSession / _readResumeSession / _clearResumeSession), snapshot, read-back, clear — identical, zero page errors.

Honest limit

My probe never got the snapshot to actually persist — there's a guard beyond the 3-second minimum position that a scripted playSong() doesn't satisfy. So that path is verified as identical to main, not as observed working. The real coverage is tests/browser/resume-session.spec.ts, which drives the flow properly.

Zero harnesses broke. pytest 2396 · node 1040/1040 · ESLint 0 (no-cycle clean) · tailwind clean · Codex 0.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a “Resume practice” option to continue a recent session.
    • Restores the previous song, playback position, arrangement, and speed when available.
    • Resume prompts are shown contextually and can be dismissed for the current session.
  • Bug Fixes

    • Improved resume handling to prevent conflicts with normal playback and autoplay behavior.
    • Invalid, stale, or nearly completed sessions are no longer offered for resumption.

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Resume-session persistence and resume-pill UI are extracted into a dedicated module. Playback now shares pending resume data through S.pendingResume, and the host seam exposes playSong for resume restoration.

Changes

Resume session integration

Layer / File(s) Summary
Resume snapshot and pill workflow
static/js/resume-session.js
Snapshots are stored and validated in localStorage, resume playback is initiated through the host seam, and the resume pill supports display, dismissal, and restoration.
Shared resume state and playback wiring
static/app.js, static/js/player-state.js
Application playback arms and consumes S.pendingResume, imports the extracted helpers, and exposes playSong through configureHost().

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PlayerScreen
  participant ResumeSession as resume-session.js
  participant Storage as localStorage
  participant Host as host.js
  PlayerScreen->>ResumeSession: show resume pill
  ResumeSession->>Storage: read and validate snapshot
  PlayerScreen->>ResumeSession: click Resume
  ResumeSession->>Host: call playSong with resume payload
  Host->>Storage: clear snapshot after success
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.60% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: extracting resume-session logic from app.js into a separate module.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/r3-carve-resume

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (5)
static/js/count-in.js (1)

376-379: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Potential stale-listener accumulation if armCreditsHideOnPlay() fires twice before a song:play.

Each call overwrites _creditsHideOnPlay with a new closure, but the previously-registered {once:true} listener on window.feedBack is never removed (only the latest reference can be .off()'d via hideSongCreditsOverlay). In practice each stale listener is harmless (it self-removes after firing once and just re-hides an already-hidden overlay), but it's worth a defensive dedupe if armCreditsHideOnPlay can realistically be invoked back-to-back without an intervening hideSongCreditsOverlay().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@static/js/count-in.js` around lines 376 - 379, Update armCreditsHideOnPlay to
remove any previously registered song:play listener before replacing
_creditsHideOnPlay and registering the new { once: true } listener. Preserve the
existing callback behavior and hideSongCreditsOverlay cleanup.
static/js/resume-session.js (1)

62-71: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Malformed snapshot data is silently ignored but not cleared.

The stale-timestamp branch (Line 68) clears storage via _clearResumeSession(), but the malformed-shape branch (Line 67, missing .f/.t) does not. Any corrupted/legacy snapshot will keep returning null on every check without ever being cleaned up from localStorage.

♻️ Proposed fix
         const snap = JSON.parse(raw);
-        if (!snap || !snap.f || !(Number(snap.t) > 0)) return null;
+        if (!snap || !snap.f || !(Number(snap.t) > 0)) { _clearResumeSession(); return null; }
         if (!snap.ts || Date.now() - snap.ts > _RESUME_MAX_AGE_MS) { _clearResumeSession(); return null; }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@static/js/resume-session.js` around lines 62 - 71, Update _readResumeSession
so malformed snapshots failing the !snap, !snap.f, or invalid snap.t validation
are cleared via _clearResumeSession() before returning null, matching the
stale-timestamp branch; preserve the existing valid-snapshot return and error
handling behavior.
static/app.js (1)

5579-5591: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Stale comment references old _pendingResume variable.

The comment still says "arms _pendingResume" but the code below now writes S.pendingResume (per player-state.js). Update the comment to avoid confusing future readers about which container holds the resume request.

✏️ Proposed comment fix
-    // song starting at t=0 for an unexpected seek from the previous song's
-    // position. audio.currentTime may not reset synchronously when src is cleared.
+    // song starting at t=0 for an unexpected seek from the previous song's
+    // position. audio.currentTime may not reset synchronously when src is cleared.
     currentFilename = filename;
     // A fresh load arms autoplay; a pending auto-exit from the previous
     // song is no longer relevant. A *resume* load (options.resume) instead
-    // arms _pendingResume — consumed at song:ready to restore speed + seek to
+    // arms S.pendingResume — consumed at song:ready to restore speed + seek to
     // the saved position, then start — so autostart and resume don't both try
     // to begin playback from different positions.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@static/app.js` around lines 5579 - 5591, Update the comment above the
resume-handling logic to reference S.pendingResume instead of the obsolete
_pendingResume variable, keeping the explanation consistent with the assignment
and its consumption behavior.
static/js/player-controls.js (1)

85-96: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the redundant handleSliderInput call

#speed-slider already has an input path through setSpeed(), and setSpeed() calls host.handleSliderInput(speedSlider). The direct call here makes the same update run twice; the synthetic input event is enough.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@static/js/player-controls.js` around lines 85 - 96, Remove the direct
host.handleSliderInput(slider) call from applySpeedPreset and retain the
synthetic input event so the existing setSpeed() path performs the update
exactly once.
tests/js/song_restart.test.js (1)

55-70: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Local S in loadRestart is a snapshot, not a live reference to sandbox.S.

var S = { isPlaying: ${sandbox.S.isPlaying}, ... } captures the boolean at load time; __togglePlay() mutates sandbox.S.isPlaying, a separate object, so restartCurrentSong's internal S.isPlaying won't reflect that mutation. Current assertions don't exercise this (the function only reads S.isPlaying once before any toggle), but it's worth noting this indirection could mask a S-consistency bug in future test additions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/js/song_restart.test.js` around lines 55 - 70, Update loadRestart so
the generated restartCurrentSong function uses the live sandbox.S object rather
than a copied S snapshot. Preserve the existing lastAudioTime initialization
while ensuring __togglePlay mutations to sandbox.S.isPlaying are visible through
S during execution.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@static/js/loops.js`:
- Around line 182-261: Check response.ok immediately after each fetch in
loadSavedLoops, saveCurrentLoop, and deleteSelectedLoop, and handle non-OK
responses as failures before parsing JSON or updating UI state. Preserve the
existing UI updates only on successful requests: do not hide the Save button,
clear the loop, or refresh saved loops when the corresponding server operation
fails.

In `@static/js/resume-session.js`:
- Around line 78-96: Re-invoke _maybeShowResumePill() in the catch block of
resumeLastSession after preserving the failed snapshot, so the retry affordance
is immediately restored when host.playSong fails. Keep the existing failure
return and snapshot retention behavior unchanged.

In `@static/js/section-practice.js`:
- Around line 73-112: Reset _sectionPracticeActiveParent to its sentinel value
in both the off branch of _setSectionPracticeMode and the exported
resetSelection function, alongside the existing selection-state resets. Preserve
_sectionPracticeResetSelectionUi and _hideSectionPracticeBar behavior so
_updateSectionPracticeHighlight cannot retain a stale selected chip after
disabling practice or clearing a loop.

---

Nitpick comments:
In `@static/app.js`:
- Around line 5579-5591: Update the comment above the resume-handling logic to
reference S.pendingResume instead of the obsolete _pendingResume variable,
keeping the explanation consistent with the assignment and its consumption
behavior.

In `@static/js/count-in.js`:
- Around line 376-379: Update armCreditsHideOnPlay to remove any previously
registered song:play listener before replacing _creditsHideOnPlay and
registering the new { once: true } listener. Preserve the existing callback
behavior and hideSongCreditsOverlay cleanup.

In `@static/js/player-controls.js`:
- Around line 85-96: Remove the direct host.handleSliderInput(slider) call from
applySpeedPreset and retain the synthetic input event so the existing setSpeed()
path performs the update exactly once.

In `@static/js/resume-session.js`:
- Around line 62-71: Update _readResumeSession so malformed snapshots failing
the !snap, !snap.f, or invalid snap.t validation are cleared via
_clearResumeSession() before returning null, matching the stale-timestamp
branch; preserve the existing valid-snapshot return and error handling behavior.

In `@tests/js/song_restart.test.js`:
- Around line 55-70: Update loadRestart so the generated restartCurrentSong
function uses the live sandbox.S object rather than a copied S snapshot.
Preserve the existing lastAudioTime initialization while ensuring __togglePlay
mutations to sandbox.S.isPlaying are visible through S during execution.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a94b021c-3ed2-4704-a5a6-d26cb2fbbd9b

📥 Commits

Reviewing files that changed from the base of the PR and between 11f8c36 and 702eb7c.

📒 Files selected for processing (21)
  • static/app.js
  • static/js/count-in.js
  • static/js/host.js
  • static/js/loops.js
  • static/js/player-controls.js
  • static/js/player-state.js
  • static/js/resume-session.js
  • static/js/section-practice.js
  • tests/js/autoplay_exit.test.js
  • tests/js/host_contract.test.js
  • tests/js/juce_engine_reroute.test.js
  • tests/js/loop_api.test.js
  • tests/js/loop_restart.test.js
  • tests/js/play_button_reroute_guard.test.js
  • tests/js/playback_app_adapter.test.js
  • tests/js/section_practice_dismiss.test.js
  • tests/js/song_credits_overlay.test.js
  • tests/js/song_restart.test.js
  • tests/js/song_seek.test.js
  • tests/js/speed_reset.test.js
  • tests/test_plugin_runtime_idempotence.py

Comment thread static/js/loops.js
Comment on lines +182 to +261
export async function loadSavedLoops() {
const sel = document.getElementById('saved-loops');
const delBtn = document.getElementById('btn-loop-delete');
if (!host.currentFilename()) { sel.classList.add('hidden'); delBtn.classList.add('hidden'); return; }

const resp = await fetch(`/api/loops?filename=${encodeURIComponent(decodeURIComponent(host.currentFilename()))}`);
const loops = await resp.json();

sel.innerHTML = '<option value="">Saved Loops</option>';
for (const l of loops) {
sel.innerHTML += `<option value="${l.id}" data-start="${l.start}" data-end="${l.end}">${esc(l.name)} (${host.formatTime(l.start)}→${host.formatTime(l.end)})</option>`;
}
if (loops.length > 0) {
sel.classList.remove('hidden');
} else {
sel.classList.add('hidden');
}
delBtn.classList.add('hidden');
}

export async function loadSavedLoop(loopId) {
const sel = document.getElementById('saved-loops');
const opt = sel.selectedOptions[0];
const delBtn = document.getElementById('btn-loop-delete');
if (!loopId || !opt?.dataset.start) {
delBtn.classList.add('hidden');
return;
}
let ok = false;
try {
// Pass raw strings — setLoop's Number() coercion is stricter than
// parseFloat (rejects "12abc") so malformed dataset values throw
// and fall into the catch instead of silently truncating.
ok = await setLoop(opt.dataset.start, opt.dataset.end);
} catch (err) {
// Malformed dataset (server returned bad data): treat the same as
// a failed seek so the dropdown resyncs and we don't propagate an
// uncaught rejection out of the onchange handler.
console.warn('[loadSavedLoop] setLoop threw:', err);
ok = false;
}
if (!ok) {
// Seek aborted, landed off-target, or input was malformed.
// Resync the dropdown with the still-active loop so the UI
// doesn't lie about which loop is loaded.
_syncSavedLoopSelection();
return;
}
// Success path: setLoop already called _syncSavedLoopSelection,
// which surfaces the delete button when the new loop matches a
// saved option (which the dropdown selection guarantees here).
}

export async function saveCurrentLoop() {
if (loopA === null || loopB === null || !host.currentFilename()) return;
const name = await uiPrompt({ title: 'Save Loop', label: 'Loop name', value: 'Loop', okLabel: 'Save' });
if (name === null) return; // cancelled
const finalName = name.trim() || 'Loop'; // never persist an empty name
await fetch('/api/loops', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: decodeURIComponent(host.currentFilename()),
name: finalName,
start: loopA,
end: loopB,
}),
});
await loadSavedLoops();
document.getElementById('btn-loop-save').classList.add('hidden');
}

export async function deleteSelectedLoop() {
const sel = document.getElementById('saved-loops');
const loopId = sel.value;
if (!loopId) return;
await fetch(`/api/loops/${loopId}`, { method: 'DELETE' });
clearLoop();
await loadSavedLoops();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fetch calls never check response.ok.

loadSavedLoops, saveCurrentLoop, and deleteSelectedLoop all call fetch() without checking resp.ok. An HTTP error (500/404/etc.) doesn't throw — execution continues as if the write/delete succeeded: saveCurrentLoop still hides the Save button, deleteSelectedLoop still calls clearLoop(), and loadSavedLoops will call resp.json() on a non-JSON error body, which can throw and silently abort whatever awaited it (e.g. cutting saveCurrentLoop short before it hides the Save button, or deleteSelectedLoop before the dropdown refresh). The UI ends up out of sync with what actually persisted server-side.

🛡️ Suggested fix
 export async function loadSavedLoops() {
     const sel = document.getElementById('saved-loops');
     const delBtn = document.getElementById('btn-loop-delete');
     if (!host.currentFilename()) { sel.classList.add('hidden'); delBtn.classList.add('hidden'); return; }

     const resp = await fetch(`/api/loops?filename=${encodeURIComponent(decodeURIComponent(host.currentFilename()))}`);
+    if (!resp.ok) {
+        console.error('[loadSavedLoops] request failed:', resp.status);
+        sel.classList.add('hidden');
+        delBtn.classList.add('hidden');
+        return;
+    }
     const loops = await resp.json();
     ...
 export async function saveCurrentLoop() {
     ...
-    await fetch('/api/loops', {
+    const resp = await fetch('/api/loops', {
         method: 'POST',
         headers: { 'Content-Type': 'application/json' },
         body: JSON.stringify({ ... }),
     });
+    if (!resp.ok) {
+        console.error('[saveCurrentLoop] save failed:', resp.status);
+        return;
+    }
     await loadSavedLoops();
     ...
 export async function deleteSelectedLoop() {
     ...
-    await fetch(`/api/loops/${loopId}`, { method: 'DELETE' });
-    clearLoop();
+    const resp = await fetch(`/api/loops/${loopId}`, { method: 'DELETE' });
+    if (!resp.ok) {
+        console.error('[deleteSelectedLoop] delete failed:', resp.status);
+        return;
+    }
+    clearLoop();
     await loadSavedLoops();
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function loadSavedLoops() {
const sel = document.getElementById('saved-loops');
const delBtn = document.getElementById('btn-loop-delete');
if (!host.currentFilename()) { sel.classList.add('hidden'); delBtn.classList.add('hidden'); return; }
const resp = await fetch(`/api/loops?filename=${encodeURIComponent(decodeURIComponent(host.currentFilename()))}`);
const loops = await resp.json();
sel.innerHTML = '<option value="">Saved Loops</option>';
for (const l of loops) {
sel.innerHTML += `<option value="${l.id}" data-start="${l.start}" data-end="${l.end}">${esc(l.name)} (${host.formatTime(l.start)}${host.formatTime(l.end)})</option>`;
}
if (loops.length > 0) {
sel.classList.remove('hidden');
} else {
sel.classList.add('hidden');
}
delBtn.classList.add('hidden');
}
export async function loadSavedLoop(loopId) {
const sel = document.getElementById('saved-loops');
const opt = sel.selectedOptions[0];
const delBtn = document.getElementById('btn-loop-delete');
if (!loopId || !opt?.dataset.start) {
delBtn.classList.add('hidden');
return;
}
let ok = false;
try {
// Pass raw strings — setLoop's Number() coercion is stricter than
// parseFloat (rejects "12abc") so malformed dataset values throw
// and fall into the catch instead of silently truncating.
ok = await setLoop(opt.dataset.start, opt.dataset.end);
} catch (err) {
// Malformed dataset (server returned bad data): treat the same as
// a failed seek so the dropdown resyncs and we don't propagate an
// uncaught rejection out of the onchange handler.
console.warn('[loadSavedLoop] setLoop threw:', err);
ok = false;
}
if (!ok) {
// Seek aborted, landed off-target, or input was malformed.
// Resync the dropdown with the still-active loop so the UI
// doesn't lie about which loop is loaded.
_syncSavedLoopSelection();
return;
}
// Success path: setLoop already called _syncSavedLoopSelection,
// which surfaces the delete button when the new loop matches a
// saved option (which the dropdown selection guarantees here).
}
export async function saveCurrentLoop() {
if (loopA === null || loopB === null || !host.currentFilename()) return;
const name = await uiPrompt({ title: 'Save Loop', label: 'Loop name', value: 'Loop', okLabel: 'Save' });
if (name === null) return; // cancelled
const finalName = name.trim() || 'Loop'; // never persist an empty name
await fetch('/api/loops', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: decodeURIComponent(host.currentFilename()),
name: finalName,
start: loopA,
end: loopB,
}),
});
await loadSavedLoops();
document.getElementById('btn-loop-save').classList.add('hidden');
}
export async function deleteSelectedLoop() {
const sel = document.getElementById('saved-loops');
const loopId = sel.value;
if (!loopId) return;
await fetch(`/api/loops/${loopId}`, { method: 'DELETE' });
clearLoop();
await loadSavedLoops();
}
export async function loadSavedLoops() {
const sel = document.getElementById('saved-loops');
const delBtn = document.getElementById('btn-loop-delete');
if (!host.currentFilename()) { sel.classList.add('hidden'); delBtn.classList.add('hidden'); return; }
const resp = await fetch(`/api/loops?filename=${encodeURIComponent(decodeURIComponent(host.currentFilename()))}`);
if (!resp.ok) {
console.error('[loadSavedLoops] request failed:', resp.status);
sel.classList.add('hidden');
delBtn.classList.add('hidden');
return;
}
const loops = await resp.json();
sel.innerHTML = '<option value="">Saved Loops</option>';
for (const l of loops) {
sel.innerHTML += `<option value="${l.id}" data-start="${l.start}" data-end="${l.end}">${esc(l.name)} (${host.formatTime(l.start)}${host.formatTime(l.end)})</option>`;
}
if (loops.length > 0) {
sel.classList.remove('hidden');
} else {
sel.classList.add('hidden');
}
delBtn.classList.add('hidden');
}
export async function loadSavedLoop(loopId) {
const sel = document.getElementById('saved-loops');
const opt = sel.selectedOptions[0];
const delBtn = document.getElementById('btn-loop-delete');
if (!loopId || !opt?.dataset.start) {
delBtn.classList.add('hidden');
return;
}
let ok = false;
try {
// Pass raw strings — setLoop's Number() coercion is stricter than
// parseFloat (rejects "12abc") so malformed dataset values throw
// and fall into the catch instead of silently truncating.
ok = await setLoop(opt.dataset.start, opt.dataset.end);
} catch (err) {
// Malformed dataset (server returned bad data): treat the same as
// a failed seek so the dropdown resyncs and we don't propagate an
// uncaught rejection out of the onchange handler.
console.warn('[loadSavedLoop] setLoop threw:', err);
ok = false;
}
if (!ok) {
// Seek aborted, landed off-target, or input was malformed.
// Resync the dropdown with the still-active loop so the UI
// doesn't lie about which loop is loaded.
_syncSavedLoopSelection();
return;
}
// Success path: setLoop already called _syncSavedLoopSelection,
// which surfaces the delete button when the new loop matches a
// saved option (which the dropdown selection guarantees here).
}
export async function saveCurrentLoop() {
if (loopA === null || loopB === null || !host.currentFilename()) return;
const name = await uiPrompt({ title: 'Save Loop', label: 'Loop name', value: 'Loop', okLabel: 'Save' });
if (name === null) return; // cancelled
const finalName = name.trim() || 'Loop'; // never persist an empty name
const resp = await fetch('/api/loops', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: decodeURIComponent(host.currentFilename()),
name: finalName,
start: loopA,
end: loopB,
}),
});
if (!resp.ok) {
console.error('[saveCurrentLoop] save failed:', resp.status);
return;
}
await loadSavedLoops();
document.getElementById('btn-loop-save').classList.add('hidden');
}
export async function deleteSelectedLoop() {
const sel = document.getElementById('saved-loops');
const loopId = sel.value;
if (!loopId) return;
const resp = await fetch(`/api/loops/${loopId}`, { method: 'DELETE' });
if (!resp.ok) {
console.error('[deleteSelectedLoop] delete failed:', resp.status);
return;
}
clearLoop();
await loadSavedLoops();
}
🧰 Tools
🪛 ast-grep (0.44.1)

[error] 214-214: React's useState should not be directly called
Context: setLoop(opt.dataset.start, opt.dataset.end)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(usestate-direct-usage)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@static/js/loops.js` around lines 182 - 261, Check response.ok immediately
after each fetch in loadSavedLoops, saveCurrentLoop, and deleteSelectedLoop, and
handle non-OK responses as failures before parsing JSON or updating UI state.
Preserve the existing UI updates only on successful requests: do not hide the
Save button, clear the loop, or refresh saved loops when the corresponding
server operation fails.

Comment on lines +78 to +96
export async function resumeLastSession() {
const snap = _readResumeSession();
if (!snap) { _hideResumePill(); return false; }
_hideResumePill();
try {
await host.playSong(snap.f, snap.a, {
resume: { position: Number(snap.t) || 0, speed: Number(snap.sp) || 1 },
});
} catch (err) {
// A transient load/connect failure must not strand the user: keep the
// snapshot so the pill can re-offer it on the next non-player screen,
// rather than consuming the only copy before the song actually loaded.
console.warn('[app] resume failed to load; keeping snapshot:', err);
S.pendingResume = null;
return false;
}
_clearResumeSession(); // consumed only after a successful load
return true;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Pill isn't re-offered after a failed resume attempt.

_hideResumePill() runs before host.playSong (Line 81). On failure (Line 86-93), the snapshot is kept but the pill is never shown again — only a console.warn is emitted. The user is left with no visible affordance to retry, and no indication anything went wrong, until some unrelated navigation happens to call _maybeShowResumePill().

Consider re-invoking _maybeShowResumePill() in the catch block so the pill reappears with the still-valid snapshot.

💡 Proposed fix
     } catch (err) {
         // A transient load/connect failure must not strand the user: keep the
         // snapshot so the pill can re-offer it on the next non-player screen,
         // rather than consuming the only copy before the song actually loaded.
         console.warn('[app] resume failed to load; keeping snapshot:', err);
         S.pendingResume = null;
+        _maybeShowResumePill();
         return false;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function resumeLastSession() {
const snap = _readResumeSession();
if (!snap) { _hideResumePill(); return false; }
_hideResumePill();
try {
await host.playSong(snap.f, snap.a, {
resume: { position: Number(snap.t) || 0, speed: Number(snap.sp) || 1 },
});
} catch (err) {
// A transient load/connect failure must not strand the user: keep the
// snapshot so the pill can re-offer it on the next non-player screen,
// rather than consuming the only copy before the song actually loaded.
console.warn('[app] resume failed to load; keeping snapshot:', err);
S.pendingResume = null;
return false;
}
_clearResumeSession(); // consumed only after a successful load
return true;
}
export async function resumeLastSession() {
const snap = _readResumeSession();
if (!snap) { _hideResumePill(); return false; }
_hideResumePill();
try {
await host.playSong(snap.f, snap.a, {
resume: { position: Number(snap.t) || 0, speed: Number(snap.sp) || 1 },
});
} catch (err) {
// A transient load/connect failure must not strand the user: keep the
// snapshot so the pill can re-offer it on the next non-player screen,
// rather than consuming the only copy before the song actually loaded.
console.warn('[app] resume failed to load; keeping snapshot:', err);
S.pendingResume = null;
_maybeShowResumePill();
return false;
}
_clearResumeSession(); // consumed only after a successful load
return true;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@static/js/resume-session.js` around lines 78 - 96, Re-invoke
_maybeShowResumePill() in the catch block of resumeLastSession after preserving
the failed snapshot, so the retry affordance is immediately restored when
host.playSong fails. Keep the existing failure return and snapshot retention
behavior unchanged.

Comment on lines +73 to +112
export function _setSectionPracticeMode(on, opts = {}) {
const next = !!on;
if (next === _sectionPracticeMode && !opts.force) return;
_sectionPracticeMode = next;
const cb = document.getElementById('section-practice-mode');
if (cb) cb.checked = _sectionPracticeMode;
// Surface the "looping" state on the collapsed pill so the user can tell
// Section Practice is armed without opening the popover.
const pill = document.getElementById('section-practice-pill');
if (pill) pill.classList.toggle('section-practice-pill--active', _sectionPracticeMode);
_sectionPracticeFollowParent = -1;
if (_sectionPracticeMode) {
if (opts.defaultWholeOn) {
_sectionPracticeWholeSection = true;
}
_updateSectionPracticeHighlight(host._audioTime());
if (opts.defaultWholeOn) {
_syncSectionPracticePieceUi();
}
} else {
// Turning the feature off must cancel any in-flight practiceSection()
// retry: otherwise a stale setLoop() that lands after the user unchecks
// Section Practice would re-arm the loop, flip the mode back on via
// _syncSectionPracticeFromLoop(), and restart playback through
// startCountIn(). Bumping the request gen makes the pending retry bail.
_sectionPracticeRequestGen++;
// Cancel any pending count-in: every section-practice teardown routes
// through here (mode toggle off, clearLoop, and _hideSectionPracticeBar
// on song/arrangement change), so a countdown started by a prior section
// click must not resume playback after the user has turned practice off.
host._cancelCountIn();
_sectionPracticeSelected = -1;
_sectionPracticeWholeSection = false;
_sectionPracticeSavedPartIndex = 0;
_updateSectionPracticeHighlight(host._audioTime());
if (!opts.skipClearLoop && (host.loopA() !== null || host.loopB() !== null)) {
host.clearLoop();
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Turning off practice mode never clears _sectionPracticeActiveParent, leaving a stale chip highlighted.

The off-branch (Lines 104-106) resets _sectionPracticeSelected/_sectionPracticeWholeSection/_sectionPracticeSavedPartIndex but not _sectionPracticeActiveParent. The exported resetSelection() (Lines 1201-1206, called by loops.js's clearLoop() at Line 74) has the identical gap. Contrast with _sectionPracticeResetSelectionUi() (Line 344-350) and _hideSectionPracticeBar() (Line 805), which both correctly reset it.

_updateSectionPracticeHighlight() toggles is-selected purely off _sectionPracticeActiveParent (Line 1159), unconditionally of practice mode — so after clearing a loop or unchecking "Practice Section", the previously active section chip stays visually marked selected indefinitely (until the user clicks another chip or a song/arrangement change forces the full _hideSectionPracticeBar() reset), even while the time-following is-playing highlight is simultaneously showing a different chip.

🐛 Suggested fix (both locations)
         _sectionPracticeRequestGen++;
         host._cancelCountIn();
         _sectionPracticeSelected = -1;
         _sectionPracticeWholeSection = false;
         _sectionPracticeSavedPartIndex = 0;
+        _sectionPracticeActiveParent = -1;
         _updateSectionPracticeHighlight(host._audioTime());
 export function resetSelection() {
     _sectionPracticeSelected = -1;
     _sectionPracticeWholeSection = false;
     _sectionPracticeSavedPartIndex = 0;
+    _sectionPracticeActiveParent = -1;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@static/js/section-practice.js` around lines 73 - 112, Reset
_sectionPracticeActiveParent to its sentinel value in both the off branch of
_setSectionPracticeMode and the exported resetSelection function, alongside the
existing selection-state resets. Preserve _sectionPracticeResetSelectionUi and
_hideSectionPracticeBar behavior so _updateSectionPracticeHighlight cannot
retain a stale selected chip after disabling practice or clearing a loop.

static/js/resume-session.js (157) — the snapshot taken when you leave a song and the
pill that offers it back. Bodies VERBATIM. app.js 7,727 → 7,601.
Fifth slice out of the strongly-connected core. ONE hook (playSong) + a
currentFilename getter.

S.pendingResume JOINS THE CONTAINER — on demand, exactly as intended. app.js WRITES
it (playSong({ resume }) arms it; the song:ready listener consumes it) while this
module reads it, so it cannot be a plain export: an imported binding is read-only.
Same reason isPlaying is there. The container grows one field per carve that needs
it, never speculatively.

THE CONTRACT TEST CAUGHT THE MISSING HOOK, again on a path nothing executes:
"playSong is read by a module but never wired by app.js — it would throw at runtime".
Second time it has caught a real wiring gap the moment it appeared.

A REAL TRAP, worth remembering: I first did the S.pendingResume rewrite by feeding
acorn's identifier RANGES from node into python, and it corrupted the file
(`_pS.pendingResume null;`). **Acorn's offsets are UTF-16 code units; Python's string
indices are code points.** static/app.js contains emoji, so every offset past one
drifts. Do an AST-driven rewrite in the SAME language that produced the offsets.
`node --check` caught it; a silent version of that bug is very easy to imagine.

VERIFIED. A/B against origin/main in two browsers, real song: the window API
(resumeLastSession / _snapshotResumeSession / _readResumeSession /
_clearResumeSession), snapshot, read-back, and clear — IDENTICAL, zero page errors.
HONEST LIMIT: my probe never got the snapshot to actually PERSIST (there is a guard
beyond the 3s minimum position that a scripted playSong does not satisfy), so that
path is verified only as identical-to-main, not as observed-working. The real
coverage is tests/browser/resume-session.spec.ts, which drives the flow properly.

Zero harnesses broke. pytest 2396, node 1040/1040, ESLint 0 (no-cycle clean),
tailwind clean, Codex 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
static/app.js (1)

5580-5591: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Update stale comment referencing _pendingResume.

The arming logic is correct, but the comment above still describes arming _pendingResume, which has been moved to the shared container as S.pendingResume. Since this PR documents the shared-state lifecycle, aligning the comment avoids future confusion.

📝 Proposed comment update
     // A fresh load arms autoplay; a pending auto-exit from the previous
     // song is no longer relevant. A *resume* load (options.resume) instead
-    // arms _pendingResume — consumed at song:ready to restore speed + seek to
+    // arms S.pendingResume — consumed at song:ready to restore speed + seek to
     // the saved position, then start — so autostart and resume don't both try
     // to begin playback from different positions.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@static/app.js` around lines 5580 - 5591, Update the comment above the
autoplay/resume arming logic to reference the shared state property
S.pendingResume instead of _pendingResume, while preserving its existing
explanation of resume consumption and playback behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@static/app.js`:
- Around line 5580-5591: Update the comment above the autoplay/resume arming
logic to reference the shared state property S.pendingResume instead of
_pendingResume, while preserving its existing explanation of resume consumption
and playback behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 730b590f-2a0f-4fef-8ccb-b3a53a7a255e

📥 Commits

Reviewing files that changed from the base of the PR and between 702eb7c and 1c6d2d0.

📒 Files selected for processing (3)
  • static/app.js
  • static/js/player-state.js
  • static/js/resume-session.js
✅ Files skipped from review due to trivial changes (1)
  • static/js/player-state.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • static/js/resume-session.js

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant