fix(study): keep the exam selected when opening a recent guide (#476) - #499

Merged
AndresL230 merged 2 commits into
mainfrom
fix/476-recent-guide-exam-selection
Jul 31, 2026
Merged

fix(study): keep the exam selected when opening a recent guide (#476)#499
AndresL230 merged 2 commits into
mainfrom
fix/476-recent-guide-exam-selection

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Part of #476.

The issue body misattributes the cause

"the recent-guides open path clears the selected exam"

It doesn't. openRecent (Study.tsx) sets both ids:

recentTermRef.current=entry.semester??"";setCourseId(entry.course_id);setExamId(entry.exam_id);

The clearing comes from the courseId-keyed exams effect, whose first
statement was an unconditional setExamId(""). That reset cannot tell
"the user switched course" (selection now invalid) from "we just opened a
specific guide"
(selection deliberate and valid) — it only sees that
courseId differs from its last value, which openRecent also causes.

Both effects run in the same commit, so the loader still saw the intact
pair and the guide loaded; only the next render lost the exam. That is why
the symptom is "a guide on screen above a dead Regenerate button" rather than
"nothing opens" — and why disabled={… || !examId} pins the button off.

Discriminator (pinned as a control test): it needs a course change to
reproduce. A rail entry whose course is already selected never trips the
effect and worked fine on main. That test passed before the fix and still
passes after — it's what proves the mechanism is the reset, not the open path.

The fix

The reset now happens at the two events that actually mean it:

  1. Course picker onChange — the event that genuinely invalidates the exam.
  2. Term switch — a re-scope of the exam list, so the exam picked under the
    old term need not exist in the new one.

The term case adjusts state during render (the StudyModePanel pattern
already in this file) rather than in an effect. That matters: an effect-time
reset lands a render late, so the loader commits one read of the old exam
under the new term first. That was the same defect's second trigger — it
reproduced on main and now has a test.

The exams effect is now purely a fetch.

One consequence worth flagging

Making Regenerate reachable on the rail path exposed a term hazard.
regenerate sent the active selector's term, while a recent entry opens
under its own term (#475 F1). On main that was unreachable — the button
was dead on the one path where the two differ — so it was latent. Enabling
the button would have made it live: regenerating a Fall guide while the
selector says Spring rebuilds against an offering the displayed guide never
came from, which is exactly the mismatch #475 F1 fixed for reads.

Regenerate now replays the term the displayed guide was loaded with.

Tests

  • Study.recentGuides.test.tsx (new, 7 cases) — written red first:
    4 failed on the Regenerate/picker assertions, the term-switch case failed
    on the stale re-read, and the same-course control passed throughout.
  • Study.semester.test.tsx — updated a comment that documented the bug as
    permanent ("Regenerate isn't drivable from the recent-guides rail").
  • e2e/study-recent-guides.spec.ts (new) — promoted regression journey.
  • db/seed_local_rich.py — seeds a cached guide so the journey can open
    the rail without generating; the study_guide agent has no function-mode
    handler, and the guide GET returns the cache hit before reaching it. The
    journey asserts Regenerate is enabled and deliberately never clicks it.

Gates

  • tsc --noEmit clean; eslint src 0 errors; vitest run 422/422.
  • backend pytest 1529 passed / 32 skipped; ruff check clean.
  • Full local e2e cycle (up → Playwright → oracles → down) in one flock.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a cached CS101 Fall 2025 midterm study guide for local study experiences.
    • Recent guides now preserve the associated course, exam, and semester when opened.
    • Regeneration uses the selected guide’s original term and keeps the Regenerate option available.
  • Bug Fixes

    • Prevented outdated exam and guide selections from carrying over when switching courses or semesters.
    • Improved failed-guide retries by preserving the original study selections.

Opening a guide from the "Recent guides" rail left Regenerate permanently
disabled. The cause is not the open path — openRecent sets courseId AND
examId together. It's the courseId-keyed exams effect, which opened with an
unconditional setExamId(""): a scope reset that cannot tell "the user
switched course" (selection now invalid) from "we just opened a specific
guide" (selection deliberate and valid).
Both effects run in the same commit, so the loader still saw the intact pair
and the guide loaded; only the NEXT render lost the exam. Hence the symptom —
a guide on screen above a dead Regenerate button — rather than "nothing
opens". It needs a course CHANGE to reproduce, which is why a rail entry for
the already-selected course always worked (pinned as a control test).
The reset now happens at the two events that mean it: the course picker's
onChange, and a term switch. The term case adjusts state during render (the
StudyModePanel pattern already in this file) rather than in an effect,
because an effect-time reset lands a render late — the loader would commit
one read of the old exam under the new term first. That was the same defect's
second trigger, and it now has a test.
Making Regenerate reachable on the rail path exposed a term hazard: it sent
the ACTIVE selector's term, while a recent entry opens under its OWN term
(#475 F1). Regenerating a Fall guide as Spring would rebuild against an
offering the displayed guide never came from. Regenerate now replays the term
the displayed guide was loaded with.
Also seeds a CACHED study guide in the rich local dataset so the e2e journey
can open the rail without generating (the study_guide agent has no
function-mode handler).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9dbc4915-c217-4ea7-ac76-e931dedc30df

📥 Commits

Reviewing files that changed from the base of the PR and between 065b325 and 1778215.

📒 Files selected for processing (2)
  • frontend/src/components/screens/Study.recentGuides.test.tsx
  • frontend/src/components/screens/Study.tsx
📝 Walkthrough

Walkthrough

The PR seeds a cached CS101 midterm guide and updates Study to preserve the guide’s course, exam, and semester context. Course and semester changes clear stale state. Unit and Playwright tests cover recent-guide navigation and regeneration.

Changes

Study guide context

Layer / File(s)Summary
Seed cached study guides
backend/db/seed_local_rich.py
Adds idempotent CS101 midterm guide data and runs the study-guide seed step.
Preserve guide course, exam, and semester context
frontend/src/components/screens/Study.tsx
Stores the resolved semester, reuses the original context for retries and regeneration, and clears stale guide state when course or semester changes.
Validate recent-guide navigation
frontend/src/components/screens/Study.recentGuides.test.tsx, frontend/src/components/screens/Study.semester.test.tsx, frontend/e2e/study-recent-guides.spec.ts
Tests recent-guide opening, preserved selections, term-aware regeneration, stale-selection resets, and the enabled Regenerate action.

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

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the main fix: preserving exam selection when opening a recent study guide.
Description check✅ PassedThe description explains the cause, fix, related issue, testing, and review notes, although it does not follow the template headings exactly.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck 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 fix/476-recent-guide-exam-selection

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging1778215Commit Preview URL

Branch Preview URL
Jul 31 2026, 04:18 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/components/screens/Study.tsx (1)

288-329: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard loadGuide and regenerate against stale async responses.

Neither loadGuide nor regenerate checks whether the selection is still current when their request resolves. If a user switches term or course while a getStudyGuide/regenerateStudyGuide call is in flight, clearSelection() clears examId/guide during render, but the pending .then still runs setGuide(r.guide) and setLoadedTerm(...) unconditionally afterward. That resurrects a guide (and its term) the user just navigated away from, under a course/term combination that no longer matches the pickers. This directly undercuts the invariant this PR adds ("switching term drops the selection instead of reloading it").

Add a staleness check before applying the response, for example by comparing the captured cid/eid against refs updated whenever courseId/examId change, and bailing out if they no longer match.

🔧 Proposed staleness guard
+ const courseIdRef = React.useRef(courseId);+ const examIdRef = React.useRef(examId);+ React.useEffect(() => { courseIdRef.current = courseId; }, [courseId]);+ React.useEffect(() => { examIdRef.current = examId; }, [examId]);+
const loadGuide = React.useCallback(async (cid: string, eid: string, termOverride?: string) => {
if (!userId) return;
const term = termOverride !== undefined ? (termOverride || undefined) : (semester || undefined);
setLoadingGuide(true);
setGuideProblem(null);
try {
const r = await getStudyGuide(userId, cid, eid, term);
+ // Bail if the selection changed while the request was in flight — a+ // cleared or reselected exam should not be resurrected by a late response.+ if (cid !== courseIdRef.current || eid !== examIdRef.current) return;
setGuide(r.guide);

Apply the same cid/eid check in regenerate before setGuide(r.guide).

Also applies to: 351-362

🤖 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 `@frontend/src/components/screens/Study.tsx` around lines 288 - 329, Guard the
async response handling in loadGuide and regenerate with the current
courseId/examId selection, using refs updated whenever those values change.
Before applying guide data or related state such as loadedTerm, return without
updates when the captured cid/eid no longer match the current selection, while
preserving normal updates for the active request.
🧹 Nitpick comments (1)
frontend/e2e/study-recent-guides.spec.ts (1)

22-27: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a stable testid for CustomSelect triggers.

CustomSelect has no data-testid prop and its trigger button is selector-only, so this regression journey relies on aria-haspopup="listbox". Use a documented route, such as giving CustomSelect a testid prop and wiring the Study course/exam pickers into it, before relying on ARIA markup here.

🤖 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 `@frontend/e2e/study-recent-guides.spec.ts` around lines 22 - 27, Add a
documented testid prop to the CustomSelect trigger and pass distinct stable test
IDs to the Study course and exam pickers. Update the examPicker locator in the
regression test to use the exam picker test ID instead of relying on
aria-haspopup and DOM order.

Source: Path instructions

🤖 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 `@backend/db/seed_local_rich.py`:
- Around line 585-620: Update seed_study_guides to encrypt each guide’s content
before passing it to h.insert_if_absent, using the existing encrypt_json or
encrypt_if_present helper and matching the encryption behavior in the
study_guide route. Keep the stored study_guides row structure and
cross-references unchanged.
---
Outside diff comments:
In `@frontend/src/components/screens/Study.tsx`:
- Around line 288-329: Guard the async response handling in loadGuide and
regenerate with the current courseId/examId selection, using refs updated
whenever those values change. Before applying guide data or related state such
as loadedTerm, return without updates when the captured cid/eid no longer match
the current selection, while preserving normal updates for the active request.
---
Nitpick comments:
In `@frontend/e2e/study-recent-guides.spec.ts`:
- Around line 22-27: Add a documented testid prop to the CustomSelect trigger
and pass distinct stable test IDs to the Study course and exam pickers. Update
the examPicker locator in the regression test to use the exam picker test ID
instead of relying on aria-haspopup and DOM order.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 24187e41-dbab-4240-a235-8efe5889d8af

📥 Commits

Reviewing files that changed from the base of the PR and between 4236c32 and 065b325.

📒 Files selected for processing (5)
  • backend/db/seed_local_rich.py
  • frontend/e2e/study-recent-guides.spec.ts
  • frontend/src/components/screens/Study.recentGuides.test.tsx
  • frontend/src/components/screens/Study.semester.test.tsx
  • frontend/src/components/screens/Study.tsx

Comment on lines +585 to +620
_STUDY_GUIDES = [
("rich-guide-cs-f25-mid", USER_ACTIVE, OFF_CS_F25, "rich-asg-cs-f25-mid",
"2026-03-01T12:00:00Z",
{
"exam": "Midterm Exam",
"due_date": "2025-10-15",
"overview": "Covers variables, control flow, and functions.",
"topics": [
{
"name": "Variables",
"importance": "Every later topic builds on binding names to values.",
"concepts": ["Assignment", "Scope"],
},
{
"name": "Recursion",
"importance": "The midterm's hardest questions are recursive traces.",
"concepts": ["Base case", "Call stack"],
},
],
}),
]


def seed_study_guides() -> None:
for guide_id, user_id, off_id, exam_id, generated_at, content in _STUDY_GUIDES:
h.insert_if_absent(
"study_guides",
guide_id,
{
"user_id": user_id,
"offering_id": off_id,
"exam_id": exam_id,
"generated_at": generated_at,
"content": content,
},
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"--- assignment id/title match ---"
rg -n "rich-asg-cs-f25-mid" backend/db/seed_local_rich.py
rg -n "_ASSIGNMENTS = \[" -A 40 backend/db/seed_local_rich.py | rg -n "rich-asg-cs-f25-mid|Midterm"echo"--- encryption precedent ---"
rg -n "encrypt_if_present" backend/db/seed_local_rich.py
rg -n "study_guides" backend -g '*.py' -g '!backend/db/seed_local_rich.py'

Repository: SaplingLearn/Sapling

Length of output: 2909


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"--- seed_local_rich study_guide section ---"
sed -n '560,635p' backend/db/seed_local_rich.py
echoecho"--- assignments section around exam id ---"
sed -n '340,385p' backend/db/seed_local_rich.py
echoecho"--- generate/insert study guide path ---"
sed -n '160,220p' backend/routes/study_guide.py
sed -n '300,370p' backend/routes/study_guide.py
echoecho"--- frontend e2e references ---"if [ -f frontend/e2e/study-recent-guides.spec.ts ];then
rg -n "Midterm Exam|study guide|guides|rich-guide-cs-f25-mid|Midterm" frontend/e2e/study-recent-guides.spec.ts
elseecho"frontend/e2e/study-recent-guides.spec.ts not found"fiechoecho"--- schema/table model for study_guides if present ---"
rg -n "study_guides|content.*study" backend/db backend/services backend/tests backend/routes -g '*.py'| head -200

Repository: SaplingLearn/Sapling

Length of output: 13088


Encrypt the generated study_guides.content before inserting it.

"rich-asg-cs-f25-mid" exists and matches "Midterm Exam" in _ASSIGNMENTS, so the cross-reference is correct. For writes, backend/routes/study_guide.py inserts content = result.output.model_dump() directly into study_guides; route that value through encrypt_json/encrypt_if_present before table("study_guides").insert(row).

🤖 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 `@backend/db/seed_local_rich.py` around lines 585 - 620, Update
seed_study_guides to encrypt each guide’s content before passing it to
h.insert_if_absent, using the existing encrypt_json or encrypt_if_present helper
and matching the encryption behavior in the study_guide route. Keep the stored
study_guides row structure and cross-references unchanged.

Source: Coding guidelines

Code review caught a regression this PR introduced. Moving the reset out of
the courseId-keyed effect and onto the picker's onChange dropped a guard the
effect had for free: setCourseId(sameValue) bails out, so the effect never
re-ran. CustomSelect.commit() fires onChange for the already-selected option
too, so re-confirming the course you were already on wiped the guide you were
reading — the same class of bug as #476 itself.
Guard selectCourse on an actual change, and pin it with a test.
Also records the known cross-term edge the fix leaves standing: the exam
OPTIONS follow the active selector by #475's design, so a rail entry opened
under a different term is absent from that list and the picker shows its
placeholder while the guide and Regenerate are live and correctly aimed at
loadedTerm. Squaring it means tracking "the term I'm viewing" across list and
loads, which is a #475 change rather than part of this fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 blocking issue, now fixed in 1778215, plus two notes recorded rather than fixed.

  1. Re-picking the already-selected course wiped the loaded guide — a regression this PR introduced (bug due to CustomSelect.commit() calling onChange(opt.value) unconditionally, with no equality check against the current value).

Moving the reset out of the courseId-keyed effect onto the picker's onChange dropped a guard the effect had for free: setCourseId(sameValue) bails out, so the effect never re-ran. On a plain handler it has to be explicit. Reopening the course dropdown and clicking the course you were already on cleared examId/guide — the same class of defect as #476 itself.

constselectCourse=(next: string)=>{
setCourseId(next);
clearSelection();
};

Fixed by guarding on an actual change, with a regression test (re-picking the course already selected leaves the loaded guide alone) that was watched fail first:

// Guarded on an actual CHANGE. CustomSelect fires onChange for the
// already-selected option too, and the effect this reset came out of got that
// for free — setCourseId(same) bails out, so the effect never re-ran. On a
// plain handler it has to be explicit, or re-confirming the course you are
// already on wipes the guide you are reading.
constselectCourse=(next: string)=>{
if(next===courseId)return;
setCourseId(next);
clearSelection();
};

Notes, not blocking:

  1. Cross-term rail open leaves the exam picker on its placeholder. The exam OPTIONS follow the active selector, so an entry opened under a different term isn't in that list and CustomSelect falls back to the placeholder — while the guide and Regenerate are live and correctly aimed at loadedTerm. This is not a regression (pre-fix that case was strictly worse: Regenerate was dead too), and listing the entry's term instead would offer exams the next picker-driven load would 404 on, since feat(study): semester-scope the study-tool reads (#141 reframe) #475 pins only the recent-open's own load. Squaring it means tracking "the term I'm viewing" across list and loads — a feat(study): semester-scope the study-tool reads (#141 reframe) #475 change. Recorded in a comment at the effect.

// The OPTIONS follow the active selector, deliberately: #475 pins only the
// recent-open's own load to the entry's term, and later picker-driven loads
// go back to the selector — so listing the entry's term here would offer
// exams that the next load would then 404 on.
// Known edge that leaves: open a rail entry whose term ISN'T the active one
// and its exam is absent from this list, so CustomSelect falls back to the
// placeholder while the guide and Regenerate are live and correctly aimed at
// `loadedTerm`. Squaring that needs the screen to track "the term I'm
// viewing" as one thing across list + loads — a #475 change, not this one.
React.useEffect(()=>{
setExams([]);
if(!courseId||!userId||!semesterReady)return;
setLoadingExams(true);
getStudyGuideExams(userId,courseId,semester||undefined)
.then(r=>setExams(r.exams||[]))

  1. loadGuide has no stale-response guard, so two rail entries clicked in quick succession can resolve out of order. Pre-existing; this PR adds loadedTerm to the state that can go stale. Worth a follow-up, out of scope here.

Also checked and cleared: a review pass flagged a captured Playwright failure of this PR's own journey as evidence the fix doesn't work. That artifact is from a deliberate pre-fix run — e2e/results/last-run.json recorded argv of playwright test e2e/study-recent-guides.spec.ts, the single-spec invocation used to verify the journey goes red against pre-fix Study.tsx. Gitignored, so it was never part of this PR; deleted to stop it misleading future readers.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@AndresL230
AndresL230 merged commit bbe4eb5 into mainJul 31, 2026
7 checks passed
@AndresL230
AndresL230 deleted the fix/476-recent-guide-exam-selection branch August 2, 2026 18:30
Sign up for freeto 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

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(study): keep the exam selected when opening a recent guide (#476) - #499

Merged
AndresL230 merged 2 commits into
mainfrom
fix/476-recent-guide-exam-selection
Jul 31, 2026
Merged

fix(study): keep the exam selected when opening a recent guide (#476)#499
AndresL230 merged 2 commits into
mainfrom
fix/476-recent-guide-exam-selection

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Part of #476.

The issue body misattributes the cause

"the recent-guides open path clears the selected exam"

It doesn't. openRecent (Study.tsx) sets both ids:

recentTermRef.current=entry.semester??"";setCourseId(entry.course_id);setExamId(entry.exam_id);

The clearing comes from the courseId-keyed exams effect, whose first
statement was an unconditional setExamId(""). That reset cannot tell
"the user switched course" (selection now invalid) from "we just opened a
specific guide"
(selection deliberate and valid) — it only sees that
courseId differs from its last value, which openRecent also causes.

Both effects run in the same commit, so the loader still saw the intact
pair and the guide loaded; only the next render lost the exam. That is why
the symptom is "a guide on screen above a dead Regenerate button" rather than
"nothing opens" — and why disabled={… || !examId} pins the button off.

Discriminator (pinned as a control test): it needs a course change to
reproduce. A rail entry whose course is already selected never trips the
effect and worked fine on main. That test passed before the fix and still
passes after — it's what proves the mechanism is the reset, not the open path.

The fix

The reset now happens at the two events that actually mean it:

  1. Course picker onChange — the event that genuinely invalidates the exam.
  2. Term switch — a re-scope of the exam list, so the exam picked under the
    old term need not exist in the new one.

The term case adjusts state during render (the StudyModePanel pattern
already in this file) rather than in an effect. That matters: an effect-time
reset lands a render late, so the loader commits one read of the old exam
under the new term first. That was the same defect's second trigger — it
reproduced on main and now has a test.

The exams effect is now purely a fetch.

One consequence worth flagging

Making Regenerate reachable on the rail path exposed a term hazard.
regenerate sent the active selector's term, while a recent entry opens
under its own term (#475 F1). On main that was unreachable — the button
was dead on the one path where the two differ — so it was latent. Enabling
the button would have made it live: regenerating a Fall guide while the
selector says Spring rebuilds against an offering the displayed guide never
came from, which is exactly the mismatch #475 F1 fixed for reads.

Regenerate now replays the term the displayed guide was loaded with.

Tests

  • Study.recentGuides.test.tsx (new, 7 cases) — written red first:
    4 failed on the Regenerate/picker assertions, the term-switch case failed
    on the stale re-read, and the same-course control passed throughout.
  • Study.semester.test.tsx — updated a comment that documented the bug as
    permanent ("Regenerate isn't drivable from the recent-guides rail").
  • e2e/study-recent-guides.spec.ts (new) — promoted regression journey.
  • db/seed_local_rich.py — seeds a cached guide so the journey can open
    the rail without generating; the study_guide agent has no function-mode
    handler, and the guide GET returns the cache hit before reaching it. The
    journey asserts Regenerate is enabled and deliberately never clicks it.

Gates

  • tsc --noEmit clean; eslint src 0 errors; vitest run 422/422.
  • backend pytest 1529 passed / 32 skipped; ruff check clean.
  • Full local e2e cycle (up → Playwright → oracles → down) in one flock.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a cached CS101 Fall 2025 midterm study guide for local study experiences.
    • Recent guides now preserve the associated course, exam, and semester when opened.
    • Regeneration uses the selected guide’s original term and keeps the Regenerate option available.
  • Bug Fixes

    • Prevented outdated exam and guide selections from carrying over when switching courses or semesters.
    • Improved failed-guide retries by preserving the original study selections.

Opening a guide from the "Recent guides" rail left Regenerate permanently
disabled. The cause is not the open path — openRecent sets courseId AND
examId together. It's the courseId-keyed exams effect, which opened with an
unconditional setExamId(""): a scope reset that cannot tell "the user
switched course" (selection now invalid) from "we just opened a specific
guide" (selection deliberate and valid).
Both effects run in the same commit, so the loader still saw the intact pair
and the guide loaded; only the NEXT render lost the exam. Hence the symptom —
a guide on screen above a dead Regenerate button — rather than "nothing
opens". It needs a course CHANGE to reproduce, which is why a rail entry for
the already-selected course always worked (pinned as a control test).
The reset now happens at the two events that mean it: the course picker's
onChange, and a term switch. The term case adjusts state during render (the
StudyModePanel pattern already in this file) rather than in an effect,
because an effect-time reset lands a render late — the loader would commit
one read of the old exam under the new term first. That was the same defect's
second trigger, and it now has a test.
Making Regenerate reachable on the rail path exposed a term hazard: it sent
the ACTIVE selector's term, while a recent entry opens under its OWN term
(#475 F1). Regenerating a Fall guide as Spring would rebuild against an
offering the displayed guide never came from. Regenerate now replays the term
the displayed guide was loaded with.
Also seeds a CACHED study guide in the rich local dataset so the e2e journey
can open the rail without generating (the study_guide agent has no
function-mode handler).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9dbc4915-c217-4ea7-ac76-e931dedc30df

📥 Commits

Reviewing files that changed from the base of the PR and between 065b325 and 1778215.

📒 Files selected for processing (2)
  • frontend/src/components/screens/Study.recentGuides.test.tsx
  • frontend/src/components/screens/Study.tsx
📝 Walkthrough

Walkthrough

The PR seeds a cached CS101 midterm guide and updates Study to preserve the guide’s course, exam, and semester context. Course and semester changes clear stale state. Unit and Playwright tests cover recent-guide navigation and regeneration.

Changes

Study guide context

Layer / File(s)Summary
Seed cached study guides
backend/db/seed_local_rich.py
Adds idempotent CS101 midterm guide data and runs the study-guide seed step.
Preserve guide course, exam, and semester context
frontend/src/components/screens/Study.tsx
Stores the resolved semester, reuses the original context for retries and regeneration, and clears stale guide state when course or semester changes.
Validate recent-guide navigation
frontend/src/components/screens/Study.recentGuides.test.tsx, frontend/src/components/screens/Study.semester.test.tsx, frontend/e2e/study-recent-guides.spec.ts
Tests recent-guide opening, preserved selections, term-aware regeneration, stale-selection resets, and the enabled Regenerate action.

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

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the main fix: preserving exam selection when opening a recent study guide.
Description check✅ PassedThe description explains the cause, fix, related issue, testing, and review notes, although it does not follow the template headings exactly.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck 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 fix/476-recent-guide-exam-selection

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging1778215Commit Preview URL

Branch Preview URL
Jul 31 2026, 04:18 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/components/screens/Study.tsx (1)

288-329: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard loadGuide and regenerate against stale async responses.

Neither loadGuide nor regenerate checks whether the selection is still current when their request resolves. If a user switches term or course while a getStudyGuide/regenerateStudyGuide call is in flight, clearSelection() clears examId/guide during render, but the pending .then still runs setGuide(r.guide) and setLoadedTerm(...) unconditionally afterward. That resurrects a guide (and its term) the user just navigated away from, under a course/term combination that no longer matches the pickers. This directly undercuts the invariant this PR adds ("switching term drops the selection instead of reloading it").

Add a staleness check before applying the response, for example by comparing the captured cid/eid against refs updated whenever courseId/examId change, and bailing out if they no longer match.

🔧 Proposed staleness guard
+ const courseIdRef = React.useRef(courseId);+ const examIdRef = React.useRef(examId);+ React.useEffect(() => { courseIdRef.current = courseId; }, [courseId]);+ React.useEffect(() => { examIdRef.current = examId; }, [examId]);+
const loadGuide = React.useCallback(async (cid: string, eid: string, termOverride?: string) => {
if (!userId) return;
const term = termOverride !== undefined ? (termOverride || undefined) : (semester || undefined);
setLoadingGuide(true);
setGuideProblem(null);
try {
const r = await getStudyGuide(userId, cid, eid, term);
+ // Bail if the selection changed while the request was in flight — a+ // cleared or reselected exam should not be resurrected by a late response.+ if (cid !== courseIdRef.current || eid !== examIdRef.current) return;
setGuide(r.guide);

Apply the same cid/eid check in regenerate before setGuide(r.guide).

Also applies to: 351-362

🤖 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 `@frontend/src/components/screens/Study.tsx` around lines 288 - 329, Guard the
async response handling in loadGuide and regenerate with the current
courseId/examId selection, using refs updated whenever those values change.
Before applying guide data or related state such as loadedTerm, return without
updates when the captured cid/eid no longer match the current selection, while
preserving normal updates for the active request.
🧹 Nitpick comments (1)
frontend/e2e/study-recent-guides.spec.ts (1)

22-27: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a stable testid for CustomSelect triggers.

CustomSelect has no data-testid prop and its trigger button is selector-only, so this regression journey relies on aria-haspopup="listbox". Use a documented route, such as giving CustomSelect a testid prop and wiring the Study course/exam pickers into it, before relying on ARIA markup here.

🤖 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 `@frontend/e2e/study-recent-guides.spec.ts` around lines 22 - 27, Add a
documented testid prop to the CustomSelect trigger and pass distinct stable test
IDs to the Study course and exam pickers. Update the examPicker locator in the
regression test to use the exam picker test ID instead of relying on
aria-haspopup and DOM order.

Source: Path instructions

🤖 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 `@backend/db/seed_local_rich.py`:
- Around line 585-620: Update seed_study_guides to encrypt each guide’s content
before passing it to h.insert_if_absent, using the existing encrypt_json or
encrypt_if_present helper and matching the encryption behavior in the
study_guide route. Keep the stored study_guides row structure and
cross-references unchanged.
---
Outside diff comments:
In `@frontend/src/components/screens/Study.tsx`:
- Around line 288-329: Guard the async response handling in loadGuide and
regenerate with the current courseId/examId selection, using refs updated
whenever those values change. Before applying guide data or related state such
as loadedTerm, return without updates when the captured cid/eid no longer match
the current selection, while preserving normal updates for the active request.
---
Nitpick comments:
In `@frontend/e2e/study-recent-guides.spec.ts`:
- Around line 22-27: Add a documented testid prop to the CustomSelect trigger
and pass distinct stable test IDs to the Study course and exam pickers. Update
the examPicker locator in the regression test to use the exam picker test ID
instead of relying on aria-haspopup and DOM order.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 24187e41-dbab-4240-a235-8efe5889d8af

📥 Commits

Reviewing files that changed from the base of the PR and between 4236c32 and 065b325.

📒 Files selected for processing (5)
  • backend/db/seed_local_rich.py
  • frontend/e2e/study-recent-guides.spec.ts
  • frontend/src/components/screens/Study.recentGuides.test.tsx
  • frontend/src/components/screens/Study.semester.test.tsx
  • frontend/src/components/screens/Study.tsx

Comment on lines +585 to +620
_STUDY_GUIDES = [
("rich-guide-cs-f25-mid", USER_ACTIVE, OFF_CS_F25, "rich-asg-cs-f25-mid",
"2026-03-01T12:00:00Z",
{
"exam": "Midterm Exam",
"due_date": "2025-10-15",
"overview": "Covers variables, control flow, and functions.",
"topics": [
{
"name": "Variables",
"importance": "Every later topic builds on binding names to values.",
"concepts": ["Assignment", "Scope"],
},
{
"name": "Recursion",
"importance": "The midterm's hardest questions are recursive traces.",
"concepts": ["Base case", "Call stack"],
},
],
}),
]


def seed_study_guides() -> None:
for guide_id, user_id, off_id, exam_id, generated_at, content in _STUDY_GUIDES:
h.insert_if_absent(
"study_guides",
guide_id,
{
"user_id": user_id,
"offering_id": off_id,
"exam_id": exam_id,
"generated_at": generated_at,
"content": content,
},
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"--- assignment id/title match ---"
rg -n "rich-asg-cs-f25-mid" backend/db/seed_local_rich.py
rg -n "_ASSIGNMENTS = \[" -A 40 backend/db/seed_local_rich.py | rg -n "rich-asg-cs-f25-mid|Midterm"echo"--- encryption precedent ---"
rg -n "encrypt_if_present" backend/db/seed_local_rich.py
rg -n "study_guides" backend -g '*.py' -g '!backend/db/seed_local_rich.py'

Repository: SaplingLearn/Sapling

Length of output: 2909


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"--- seed_local_rich study_guide section ---"
sed -n '560,635p' backend/db/seed_local_rich.py
echoecho"--- assignments section around exam id ---"
sed -n '340,385p' backend/db/seed_local_rich.py
echoecho"--- generate/insert study guide path ---"
sed -n '160,220p' backend/routes/study_guide.py
sed -n '300,370p' backend/routes/study_guide.py
echoecho"--- frontend e2e references ---"if [ -f frontend/e2e/study-recent-guides.spec.ts ];then
rg -n "Midterm Exam|study guide|guides|rich-guide-cs-f25-mid|Midterm" frontend/e2e/study-recent-guides.spec.ts
elseecho"frontend/e2e/study-recent-guides.spec.ts not found"fiechoecho"--- schema/table model for study_guides if present ---"
rg -n "study_guides|content.*study" backend/db backend/services backend/tests backend/routes -g '*.py'| head -200

Repository: SaplingLearn/Sapling

Length of output: 13088


Encrypt the generated study_guides.content before inserting it.

"rich-asg-cs-f25-mid" exists and matches "Midterm Exam" in _ASSIGNMENTS, so the cross-reference is correct. For writes, backend/routes/study_guide.py inserts content = result.output.model_dump() directly into study_guides; route that value through encrypt_json/encrypt_if_present before table("study_guides").insert(row).

🤖 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 `@backend/db/seed_local_rich.py` around lines 585 - 620, Update
seed_study_guides to encrypt each guide’s content before passing it to
h.insert_if_absent, using the existing encrypt_json or encrypt_if_present helper
and matching the encryption behavior in the study_guide route. Keep the stored
study_guides row structure and cross-references unchanged.

Source: Coding guidelines

Code review caught a regression this PR introduced. Moving the reset out of
the courseId-keyed effect and onto the picker's onChange dropped a guard the
effect had for free: setCourseId(sameValue) bails out, so the effect never
re-ran. CustomSelect.commit() fires onChange for the already-selected option
too, so re-confirming the course you were already on wiped the guide you were
reading — the same class of bug as #476 itself.
Guard selectCourse on an actual change, and pin it with a test.
Also records the known cross-term edge the fix leaves standing: the exam
OPTIONS follow the active selector by #475's design, so a rail entry opened
under a different term is absent from that list and the picker shows its
placeholder while the guide and Regenerate are live and correctly aimed at
loadedTerm. Squaring it means tracking "the term I'm viewing" across list and
loads, which is a #475 change rather than part of this fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 blocking issue, now fixed in 1778215, plus two notes recorded rather than fixed.

  1. Re-picking the already-selected course wiped the loaded guide — a regression this PR introduced (bug due to CustomSelect.commit() calling onChange(opt.value) unconditionally, with no equality check against the current value).

Moving the reset out of the courseId-keyed effect onto the picker's onChange dropped a guard the effect had for free: setCourseId(sameValue) bails out, so the effect never re-ran. On a plain handler it has to be explicit. Reopening the course dropdown and clicking the course you were already on cleared examId/guide — the same class of defect as #476 itself.

constselectCourse=(next: string)=>{
setCourseId(next);
clearSelection();
};

Fixed by guarding on an actual change, with a regression test (re-picking the course already selected leaves the loaded guide alone) that was watched fail first:

// Guarded on an actual CHANGE. CustomSelect fires onChange for the
// already-selected option too, and the effect this reset came out of got that
// for free — setCourseId(same) bails out, so the effect never re-ran. On a
// plain handler it has to be explicit, or re-confirming the course you are
// already on wipes the guide you are reading.
constselectCourse=(next: string)=>{
if(next===courseId)return;
setCourseId(next);
clearSelection();
};

Notes, not blocking:

  1. Cross-term rail open leaves the exam picker on its placeholder. The exam OPTIONS follow the active selector, so an entry opened under a different term isn't in that list and CustomSelect falls back to the placeholder — while the guide and Regenerate are live and correctly aimed at loadedTerm. This is not a regression (pre-fix that case was strictly worse: Regenerate was dead too), and listing the entry's term instead would offer exams the next picker-driven load would 404 on, since feat(study): semester-scope the study-tool reads (#141 reframe) #475 pins only the recent-open's own load. Squaring it means tracking "the term I'm viewing" across list and loads — a feat(study): semester-scope the study-tool reads (#141 reframe) #475 change. Recorded in a comment at the effect.

// The OPTIONS follow the active selector, deliberately: #475 pins only the
// recent-open's own load to the entry's term, and later picker-driven loads
// go back to the selector — so listing the entry's term here would offer
// exams that the next load would then 404 on.
// Known edge that leaves: open a rail entry whose term ISN'T the active one
// and its exam is absent from this list, so CustomSelect falls back to the
// placeholder while the guide and Regenerate are live and correctly aimed at
// `loadedTerm`. Squaring that needs the screen to track "the term I'm
// viewing" as one thing across list + loads — a #475 change, not this one.
React.useEffect(()=>{
setExams([]);
if(!courseId||!userId||!semesterReady)return;
setLoadingExams(true);
getStudyGuideExams(userId,courseId,semester||undefined)
.then(r=>setExams(r.exams||[]))

  1. loadGuide has no stale-response guard, so two rail entries clicked in quick succession can resolve out of order. Pre-existing; this PR adds loadedTerm to the state that can go stale. Worth a follow-up, out of scope here.

Also checked and cleared: a review pass flagged a captured Playwright failure of this PR's own journey as evidence the fix doesn't work. That artifact is from a deliberate pre-fix run — e2e/results/last-run.json recorded argv of playwright test e2e/study-recent-guides.spec.ts, the single-spec invocation used to verify the journey goes red against pre-fix Study.tsx. Gitignored, so it was never part of this PR; deleted to stop it misleading future readers.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@AndresL230
AndresL230 merged commit bbe4eb5 into mainJul 31, 2026
7 checks passed
@AndresL230
AndresL230 deleted the fix/476-recent-guide-exam-selection branch August 2, 2026 18:30
Sign up for freeto 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

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(study): keep the exam selected when opening a recent guide (#476) - #499

Merged
AndresL230 merged 2 commits into
mainfrom
fix/476-recent-guide-exam-selection
Jul 31, 2026
Merged

fix(study): keep the exam selected when opening a recent guide (#476)#499
AndresL230 merged 2 commits into
mainfrom
fix/476-recent-guide-exam-selection

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Part of #476.

The issue body misattributes the cause

"the recent-guides open path clears the selected exam"

It doesn't. openRecent (Study.tsx) sets both ids:

recentTermRef.current=entry.semester??"";setCourseId(entry.course_id);setExamId(entry.exam_id);

The clearing comes from the courseId-keyed exams effect, whose first
statement was an unconditional setExamId(""). That reset cannot tell
"the user switched course" (selection now invalid) from "we just opened a
specific guide"
(selection deliberate and valid) — it only sees that
courseId differs from its last value, which openRecent also causes.

Both effects run in the same commit, so the loader still saw the intact
pair and the guide loaded; only the next render lost the exam. That is why
the symptom is "a guide on screen above a dead Regenerate button" rather than
"nothing opens" — and why disabled={… || !examId} pins the button off.

Discriminator (pinned as a control test): it needs a course change to
reproduce. A rail entry whose course is already selected never trips the
effect and worked fine on main. That test passed before the fix and still
passes after — it's what proves the mechanism is the reset, not the open path.

The fix

The reset now happens at the two events that actually mean it:

  1. Course picker onChange — the event that genuinely invalidates the exam.
  2. Term switch — a re-scope of the exam list, so the exam picked under the
    old term need not exist in the new one.

The term case adjusts state during render (the StudyModePanel pattern
already in this file) rather than in an effect. That matters: an effect-time
reset lands a render late, so the loader commits one read of the old exam
under the new term first. That was the same defect's second trigger — it
reproduced on main and now has a test.

The exams effect is now purely a fetch.

One consequence worth flagging

Making Regenerate reachable on the rail path exposed a term hazard.
regenerate sent the active selector's term, while a recent entry opens
under its own term (#475 F1). On main that was unreachable — the button
was dead on the one path where the two differ — so it was latent. Enabling
the button would have made it live: regenerating a Fall guide while the
selector says Spring rebuilds against an offering the displayed guide never
came from, which is exactly the mismatch #475 F1 fixed for reads.

Regenerate now replays the term the displayed guide was loaded with.

Tests

  • Study.recentGuides.test.tsx (new, 7 cases) — written red first:
    4 failed on the Regenerate/picker assertions, the term-switch case failed
    on the stale re-read, and the same-course control passed throughout.
  • Study.semester.test.tsx — updated a comment that documented the bug as
    permanent ("Regenerate isn't drivable from the recent-guides rail").
  • e2e/study-recent-guides.spec.ts (new) — promoted regression journey.
  • db/seed_local_rich.py — seeds a cached guide so the journey can open
    the rail without generating; the study_guide agent has no function-mode
    handler, and the guide GET returns the cache hit before reaching it. The
    journey asserts Regenerate is enabled and deliberately never clicks it.

Gates

  • tsc --noEmit clean; eslint src 0 errors; vitest run 422/422.
  • backend pytest 1529 passed / 32 skipped; ruff check clean.
  • Full local e2e cycle (up → Playwright → oracles → down) in one flock.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a cached CS101 Fall 2025 midterm study guide for local study experiences.
    • Recent guides now preserve the associated course, exam, and semester when opened.
    • Regeneration uses the selected guide’s original term and keeps the Regenerate option available.
  • Bug Fixes

    • Prevented outdated exam and guide selections from carrying over when switching courses or semesters.
    • Improved failed-guide retries by preserving the original study selections.

Opening a guide from the "Recent guides" rail left Regenerate permanently
disabled. The cause is not the open path — openRecent sets courseId AND
examId together. It's the courseId-keyed exams effect, which opened with an
unconditional setExamId(""): a scope reset that cannot tell "the user
switched course" (selection now invalid) from "we just opened a specific
guide" (selection deliberate and valid).
Both effects run in the same commit, so the loader still saw the intact pair
and the guide loaded; only the NEXT render lost the exam. Hence the symptom —
a guide on screen above a dead Regenerate button — rather than "nothing
opens". It needs a course CHANGE to reproduce, which is why a rail entry for
the already-selected course always worked (pinned as a control test).
The reset now happens at the two events that mean it: the course picker's
onChange, and a term switch. The term case adjusts state during render (the
StudyModePanel pattern already in this file) rather than in an effect,
because an effect-time reset lands a render late — the loader would commit
one read of the old exam under the new term first. That was the same defect's
second trigger, and it now has a test.
Making Regenerate reachable on the rail path exposed a term hazard: it sent
the ACTIVE selector's term, while a recent entry opens under its OWN term
(#475 F1). Regenerating a Fall guide as Spring would rebuild against an
offering the displayed guide never came from. Regenerate now replays the term
the displayed guide was loaded with.
Also seeds a CACHED study guide in the rich local dataset so the e2e journey
can open the rail without generating (the study_guide agent has no
function-mode handler).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9dbc4915-c217-4ea7-ac76-e931dedc30df

📥 Commits

Reviewing files that changed from the base of the PR and between 065b325 and 1778215.

📒 Files selected for processing (2)
  • frontend/src/components/screens/Study.recentGuides.test.tsx
  • frontend/src/components/screens/Study.tsx
📝 Walkthrough

Walkthrough

The PR seeds a cached CS101 midterm guide and updates Study to preserve the guide’s course, exam, and semester context. Course and semester changes clear stale state. Unit and Playwright tests cover recent-guide navigation and regeneration.

Changes

Study guide context

Layer / File(s)Summary
Seed cached study guides
backend/db/seed_local_rich.py
Adds idempotent CS101 midterm guide data and runs the study-guide seed step.
Preserve guide course, exam, and semester context
frontend/src/components/screens/Study.tsx
Stores the resolved semester, reuses the original context for retries and regeneration, and clears stale guide state when course or semester changes.
Validate recent-guide navigation
frontend/src/components/screens/Study.recentGuides.test.tsx, frontend/src/components/screens/Study.semester.test.tsx, frontend/e2e/study-recent-guides.spec.ts
Tests recent-guide opening, preserved selections, term-aware regeneration, stale-selection resets, and the enabled Regenerate action.

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

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the main fix: preserving exam selection when opening a recent study guide.
Description check✅ PassedThe description explains the cause, fix, related issue, testing, and review notes, although it does not follow the template headings exactly.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck 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 fix/476-recent-guide-exam-selection

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging1778215Commit Preview URL

Branch Preview URL
Jul 31 2026, 04:18 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/components/screens/Study.tsx (1)

288-329: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard loadGuide and regenerate against stale async responses.

Neither loadGuide nor regenerate checks whether the selection is still current when their request resolves. If a user switches term or course while a getStudyGuide/regenerateStudyGuide call is in flight, clearSelection() clears examId/guide during render, but the pending .then still runs setGuide(r.guide) and setLoadedTerm(...) unconditionally afterward. That resurrects a guide (and its term) the user just navigated away from, under a course/term combination that no longer matches the pickers. This directly undercuts the invariant this PR adds ("switching term drops the selection instead of reloading it").

Add a staleness check before applying the response, for example by comparing the captured cid/eid against refs updated whenever courseId/examId change, and bailing out if they no longer match.

🔧 Proposed staleness guard
+ const courseIdRef = React.useRef(courseId);+ const examIdRef = React.useRef(examId);+ React.useEffect(() => { courseIdRef.current = courseId; }, [courseId]);+ React.useEffect(() => { examIdRef.current = examId; }, [examId]);+
const loadGuide = React.useCallback(async (cid: string, eid: string, termOverride?: string) => {
if (!userId) return;
const term = termOverride !== undefined ? (termOverride || undefined) : (semester || undefined);
setLoadingGuide(true);
setGuideProblem(null);
try {
const r = await getStudyGuide(userId, cid, eid, term);
+ // Bail if the selection changed while the request was in flight — a+ // cleared or reselected exam should not be resurrected by a late response.+ if (cid !== courseIdRef.current || eid !== examIdRef.current) return;
setGuide(r.guide);

Apply the same cid/eid check in regenerate before setGuide(r.guide).

Also applies to: 351-362

🤖 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 `@frontend/src/components/screens/Study.tsx` around lines 288 - 329, Guard the
async response handling in loadGuide and regenerate with the current
courseId/examId selection, using refs updated whenever those values change.
Before applying guide data or related state such as loadedTerm, return without
updates when the captured cid/eid no longer match the current selection, while
preserving normal updates for the active request.
🧹 Nitpick comments (1)
frontend/e2e/study-recent-guides.spec.ts (1)

22-27: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a stable testid for CustomSelect triggers.

CustomSelect has no data-testid prop and its trigger button is selector-only, so this regression journey relies on aria-haspopup="listbox". Use a documented route, such as giving CustomSelect a testid prop and wiring the Study course/exam pickers into it, before relying on ARIA markup here.

🤖 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 `@frontend/e2e/study-recent-guides.spec.ts` around lines 22 - 27, Add a
documented testid prop to the CustomSelect trigger and pass distinct stable test
IDs to the Study course and exam pickers. Update the examPicker locator in the
regression test to use the exam picker test ID instead of relying on
aria-haspopup and DOM order.

Source: Path instructions

🤖 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 `@backend/db/seed_local_rich.py`:
- Around line 585-620: Update seed_study_guides to encrypt each guide’s content
before passing it to h.insert_if_absent, using the existing encrypt_json or
encrypt_if_present helper and matching the encryption behavior in the
study_guide route. Keep the stored study_guides row structure and
cross-references unchanged.
---
Outside diff comments:
In `@frontend/src/components/screens/Study.tsx`:
- Around line 288-329: Guard the async response handling in loadGuide and
regenerate with the current courseId/examId selection, using refs updated
whenever those values change. Before applying guide data or related state such
as loadedTerm, return without updates when the captured cid/eid no longer match
the current selection, while preserving normal updates for the active request.
---
Nitpick comments:
In `@frontend/e2e/study-recent-guides.spec.ts`:
- Around line 22-27: Add a documented testid prop to the CustomSelect trigger
and pass distinct stable test IDs to the Study course and exam pickers. Update
the examPicker locator in the regression test to use the exam picker test ID
instead of relying on aria-haspopup and DOM order.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 24187e41-dbab-4240-a235-8efe5889d8af

📥 Commits

Reviewing files that changed from the base of the PR and between 4236c32 and 065b325.

📒 Files selected for processing (5)
  • backend/db/seed_local_rich.py
  • frontend/e2e/study-recent-guides.spec.ts
  • frontend/src/components/screens/Study.recentGuides.test.tsx
  • frontend/src/components/screens/Study.semester.test.tsx
  • frontend/src/components/screens/Study.tsx

Comment on lines +585 to +620
_STUDY_GUIDES = [
("rich-guide-cs-f25-mid", USER_ACTIVE, OFF_CS_F25, "rich-asg-cs-f25-mid",
"2026-03-01T12:00:00Z",
{
"exam": "Midterm Exam",
"due_date": "2025-10-15",
"overview": "Covers variables, control flow, and functions.",
"topics": [
{
"name": "Variables",
"importance": "Every later topic builds on binding names to values.",
"concepts": ["Assignment", "Scope"],
},
{
"name": "Recursion",
"importance": "The midterm's hardest questions are recursive traces.",
"concepts": ["Base case", "Call stack"],
},
],
}),
]


def seed_study_guides() -> None:
for guide_id, user_id, off_id, exam_id, generated_at, content in _STUDY_GUIDES:
h.insert_if_absent(
"study_guides",
guide_id,
{
"user_id": user_id,
"offering_id": off_id,
"exam_id": exam_id,
"generated_at": generated_at,
"content": content,
},
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"--- assignment id/title match ---"
rg -n "rich-asg-cs-f25-mid" backend/db/seed_local_rich.py
rg -n "_ASSIGNMENTS = \[" -A 40 backend/db/seed_local_rich.py | rg -n "rich-asg-cs-f25-mid|Midterm"echo"--- encryption precedent ---"
rg -n "encrypt_if_present" backend/db/seed_local_rich.py
rg -n "study_guides" backend -g '*.py' -g '!backend/db/seed_local_rich.py'

Repository: SaplingLearn/Sapling

Length of output: 2909


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"--- seed_local_rich study_guide section ---"
sed -n '560,635p' backend/db/seed_local_rich.py
echoecho"--- assignments section around exam id ---"
sed -n '340,385p' backend/db/seed_local_rich.py
echoecho"--- generate/insert study guide path ---"
sed -n '160,220p' backend/routes/study_guide.py
sed -n '300,370p' backend/routes/study_guide.py
echoecho"--- frontend e2e references ---"if [ -f frontend/e2e/study-recent-guides.spec.ts ];then
rg -n "Midterm Exam|study guide|guides|rich-guide-cs-f25-mid|Midterm" frontend/e2e/study-recent-guides.spec.ts
elseecho"frontend/e2e/study-recent-guides.spec.ts not found"fiechoecho"--- schema/table model for study_guides if present ---"
rg -n "study_guides|content.*study" backend/db backend/services backend/tests backend/routes -g '*.py'| head -200

Repository: SaplingLearn/Sapling

Length of output: 13088


Encrypt the generated study_guides.content before inserting it.

"rich-asg-cs-f25-mid" exists and matches "Midterm Exam" in _ASSIGNMENTS, so the cross-reference is correct. For writes, backend/routes/study_guide.py inserts content = result.output.model_dump() directly into study_guides; route that value through encrypt_json/encrypt_if_present before table("study_guides").insert(row).

🤖 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 `@backend/db/seed_local_rich.py` around lines 585 - 620, Update
seed_study_guides to encrypt each guide’s content before passing it to
h.insert_if_absent, using the existing encrypt_json or encrypt_if_present helper
and matching the encryption behavior in the study_guide route. Keep the stored
study_guides row structure and cross-references unchanged.

Source: Coding guidelines

Code review caught a regression this PR introduced. Moving the reset out of
the courseId-keyed effect and onto the picker's onChange dropped a guard the
effect had for free: setCourseId(sameValue) bails out, so the effect never
re-ran. CustomSelect.commit() fires onChange for the already-selected option
too, so re-confirming the course you were already on wiped the guide you were
reading — the same class of bug as #476 itself.
Guard selectCourse on an actual change, and pin it with a test.
Also records the known cross-term edge the fix leaves standing: the exam
OPTIONS follow the active selector by #475's design, so a rail entry opened
under a different term is absent from that list and the picker shows its
placeholder while the guide and Regenerate are live and correctly aimed at
loadedTerm. Squaring it means tracking "the term I'm viewing" across list and
loads, which is a #475 change rather than part of this fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 blocking issue, now fixed in 1778215, plus two notes recorded rather than fixed.

  1. Re-picking the already-selected course wiped the loaded guide — a regression this PR introduced (bug due to CustomSelect.commit() calling onChange(opt.value) unconditionally, with no equality check against the current value).

Moving the reset out of the courseId-keyed effect onto the picker's onChange dropped a guard the effect had for free: setCourseId(sameValue) bails out, so the effect never re-ran. On a plain handler it has to be explicit. Reopening the course dropdown and clicking the course you were already on cleared examId/guide — the same class of defect as #476 itself.

constselectCourse=(next: string)=>{
setCourseId(next);
clearSelection();
};

Fixed by guarding on an actual change, with a regression test (re-picking the course already selected leaves the loaded guide alone) that was watched fail first:

// Guarded on an actual CHANGE. CustomSelect fires onChange for the
// already-selected option too, and the effect this reset came out of got that
// for free — setCourseId(same) bails out, so the effect never re-ran. On a
// plain handler it has to be explicit, or re-confirming the course you are
// already on wipes the guide you are reading.
constselectCourse=(next: string)=>{
if(next===courseId)return;
setCourseId(next);
clearSelection();
};

Notes, not blocking:

  1. Cross-term rail open leaves the exam picker on its placeholder. The exam OPTIONS follow the active selector, so an entry opened under a different term isn't in that list and CustomSelect falls back to the placeholder — while the guide and Regenerate are live and correctly aimed at loadedTerm. This is not a regression (pre-fix that case was strictly worse: Regenerate was dead too), and listing the entry's term instead would offer exams the next picker-driven load would 404 on, since feat(study): semester-scope the study-tool reads (#141 reframe) #475 pins only the recent-open's own load. Squaring it means tracking "the term I'm viewing" across list and loads — a feat(study): semester-scope the study-tool reads (#141 reframe) #475 change. Recorded in a comment at the effect.

// The OPTIONS follow the active selector, deliberately: #475 pins only the
// recent-open's own load to the entry's term, and later picker-driven loads
// go back to the selector — so listing the entry's term here would offer
// exams that the next load would then 404 on.
// Known edge that leaves: open a rail entry whose term ISN'T the active one
// and its exam is absent from this list, so CustomSelect falls back to the
// placeholder while the guide and Regenerate are live and correctly aimed at
// `loadedTerm`. Squaring that needs the screen to track "the term I'm
// viewing" as one thing across list + loads — a #475 change, not this one.
React.useEffect(()=>{
setExams([]);
if(!courseId||!userId||!semesterReady)return;
setLoadingExams(true);
getStudyGuideExams(userId,courseId,semester||undefined)
.then(r=>setExams(r.exams||[]))

  1. loadGuide has no stale-response guard, so two rail entries clicked in quick succession can resolve out of order. Pre-existing; this PR adds loadedTerm to the state that can go stale. Worth a follow-up, out of scope here.

Also checked and cleared: a review pass flagged a captured Playwright failure of this PR's own journey as evidence the fix doesn't work. That artifact is from a deliberate pre-fix run — e2e/results/last-run.json recorded argv of playwright test e2e/study-recent-guides.spec.ts, the single-spec invocation used to verify the journey goes red against pre-fix Study.tsx. Gitignored, so it was never part of this PR; deleted to stop it misleading future readers.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@AndresL230
AndresL230 merged commit bbe4eb5 into mainJul 31, 2026
7 checks passed
@AndresL230
AndresL230 deleted the fix/476-recent-guide-exam-selection branch August 2, 2026 18:30
Sign up for freeto 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

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(study): keep the exam selected when opening a recent guide (#476) - #499

Merged
AndresL230 merged 2 commits into
mainfrom
fix/476-recent-guide-exam-selection
Jul 31, 2026
Merged

fix(study): keep the exam selected when opening a recent guide (#476)#499
AndresL230 merged 2 commits into
mainfrom
fix/476-recent-guide-exam-selection

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Part of #476.

The issue body misattributes the cause

"the recent-guides open path clears the selected exam"

It doesn't. openRecent (Study.tsx) sets both ids:

recentTermRef.current=entry.semester??"";setCourseId(entry.course_id);setExamId(entry.exam_id);

The clearing comes from the courseId-keyed exams effect, whose first
statement was an unconditional setExamId(""). That reset cannot tell
"the user switched course" (selection now invalid) from "we just opened a
specific guide"
(selection deliberate and valid) — it only sees that
courseId differs from its last value, which openRecent also causes.

Both effects run in the same commit, so the loader still saw the intact
pair and the guide loaded; only the next render lost the exam. That is why
the symptom is "a guide on screen above a dead Regenerate button" rather than
"nothing opens" — and why disabled={… || !examId} pins the button off.

Discriminator (pinned as a control test): it needs a course change to
reproduce. A rail entry whose course is already selected never trips the
effect and worked fine on main. That test passed before the fix and still
passes after — it's what proves the mechanism is the reset, not the open path.

The fix

The reset now happens at the two events that actually mean it:

  1. Course picker onChange — the event that genuinely invalidates the exam.
  2. Term switch — a re-scope of the exam list, so the exam picked under the
    old term need not exist in the new one.

The term case adjusts state during render (the StudyModePanel pattern
already in this file) rather than in an effect. That matters: an effect-time
reset lands a render late, so the loader commits one read of the old exam
under the new term first. That was the same defect's second trigger — it
reproduced on main and now has a test.

The exams effect is now purely a fetch.

One consequence worth flagging

Making Regenerate reachable on the rail path exposed a term hazard.
regenerate sent the active selector's term, while a recent entry opens
under its own term (#475 F1). On main that was unreachable — the button
was dead on the one path where the two differ — so it was latent. Enabling
the button would have made it live: regenerating a Fall guide while the
selector says Spring rebuilds against an offering the displayed guide never
came from, which is exactly the mismatch #475 F1 fixed for reads.

Regenerate now replays the term the displayed guide was loaded with.

Tests

  • Study.recentGuides.test.tsx (new, 7 cases) — written red first:
    4 failed on the Regenerate/picker assertions, the term-switch case failed
    on the stale re-read, and the same-course control passed throughout.
  • Study.semester.test.tsx — updated a comment that documented the bug as
    permanent ("Regenerate isn't drivable from the recent-guides rail").
  • e2e/study-recent-guides.spec.ts (new) — promoted regression journey.
  • db/seed_local_rich.py — seeds a cached guide so the journey can open
    the rail without generating; the study_guide agent has no function-mode
    handler, and the guide GET returns the cache hit before reaching it. The
    journey asserts Regenerate is enabled and deliberately never clicks it.

Gates

  • tsc --noEmit clean; eslint src 0 errors; vitest run 422/422.
  • backend pytest 1529 passed / 32 skipped; ruff check clean.
  • Full local e2e cycle (up → Playwright → oracles → down) in one flock.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a cached CS101 Fall 2025 midterm study guide for local study experiences.
    • Recent guides now preserve the associated course, exam, and semester when opened.
    • Regeneration uses the selected guide’s original term and keeps the Regenerate option available.
  • Bug Fixes

    • Prevented outdated exam and guide selections from carrying over when switching courses or semesters.
    • Improved failed-guide retries by preserving the original study selections.

Opening a guide from the "Recent guides" rail left Regenerate permanently
disabled. The cause is not the open path — openRecent sets courseId AND
examId together. It's the courseId-keyed exams effect, which opened with an
unconditional setExamId(""): a scope reset that cannot tell "the user
switched course" (selection now invalid) from "we just opened a specific
guide" (selection deliberate and valid).
Both effects run in the same commit, so the loader still saw the intact pair
and the guide loaded; only the NEXT render lost the exam. Hence the symptom —
a guide on screen above a dead Regenerate button — rather than "nothing
opens". It needs a course CHANGE to reproduce, which is why a rail entry for
the already-selected course always worked (pinned as a control test).
The reset now happens at the two events that mean it: the course picker's
onChange, and a term switch. The term case adjusts state during render (the
StudyModePanel pattern already in this file) rather than in an effect,
because an effect-time reset lands a render late — the loader would commit
one read of the old exam under the new term first. That was the same defect's
second trigger, and it now has a test.
Making Regenerate reachable on the rail path exposed a term hazard: it sent
the ACTIVE selector's term, while a recent entry opens under its OWN term
(#475 F1). Regenerating a Fall guide as Spring would rebuild against an
offering the displayed guide never came from. Regenerate now replays the term
the displayed guide was loaded with.
Also seeds a CACHED study guide in the rich local dataset so the e2e journey
can open the rail without generating (the study_guide agent has no
function-mode handler).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9dbc4915-c217-4ea7-ac76-e931dedc30df

📥 Commits

Reviewing files that changed from the base of the PR and between 065b325 and 1778215.

📒 Files selected for processing (2)
  • frontend/src/components/screens/Study.recentGuides.test.tsx
  • frontend/src/components/screens/Study.tsx
📝 Walkthrough

Walkthrough

The PR seeds a cached CS101 midterm guide and updates Study to preserve the guide’s course, exam, and semester context. Course and semester changes clear stale state. Unit and Playwright tests cover recent-guide navigation and regeneration.

Changes

Study guide context

Layer / File(s)Summary
Seed cached study guides
backend/db/seed_local_rich.py
Adds idempotent CS101 midterm guide data and runs the study-guide seed step.
Preserve guide course, exam, and semester context
frontend/src/components/screens/Study.tsx
Stores the resolved semester, reuses the original context for retries and regeneration, and clears stale guide state when course or semester changes.
Validate recent-guide navigation
frontend/src/components/screens/Study.recentGuides.test.tsx, frontend/src/components/screens/Study.semester.test.tsx, frontend/e2e/study-recent-guides.spec.ts
Tests recent-guide opening, preserved selections, term-aware regeneration, stale-selection resets, and the enabled Regenerate action.

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

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the main fix: preserving exam selection when opening a recent study guide.
Description check✅ PassedThe description explains the cause, fix, related issue, testing, and review notes, although it does not follow the template headings exactly.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck 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 fix/476-recent-guide-exam-selection

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging1778215Commit Preview URL

Branch Preview URL
Jul 31 2026, 04:18 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/components/screens/Study.tsx (1)

288-329: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard loadGuide and regenerate against stale async responses.

Neither loadGuide nor regenerate checks whether the selection is still current when their request resolves. If a user switches term or course while a getStudyGuide/regenerateStudyGuide call is in flight, clearSelection() clears examId/guide during render, but the pending .then still runs setGuide(r.guide) and setLoadedTerm(...) unconditionally afterward. That resurrects a guide (and its term) the user just navigated away from, under a course/term combination that no longer matches the pickers. This directly undercuts the invariant this PR adds ("switching term drops the selection instead of reloading it").

Add a staleness check before applying the response, for example by comparing the captured cid/eid against refs updated whenever courseId/examId change, and bailing out if they no longer match.

🔧 Proposed staleness guard
+ const courseIdRef = React.useRef(courseId);+ const examIdRef = React.useRef(examId);+ React.useEffect(() => { courseIdRef.current = courseId; }, [courseId]);+ React.useEffect(() => { examIdRef.current = examId; }, [examId]);+
const loadGuide = React.useCallback(async (cid: string, eid: string, termOverride?: string) => {
if (!userId) return;
const term = termOverride !== undefined ? (termOverride || undefined) : (semester || undefined);
setLoadingGuide(true);
setGuideProblem(null);
try {
const r = await getStudyGuide(userId, cid, eid, term);
+ // Bail if the selection changed while the request was in flight — a+ // cleared or reselected exam should not be resurrected by a late response.+ if (cid !== courseIdRef.current || eid !== examIdRef.current) return;
setGuide(r.guide);

Apply the same cid/eid check in regenerate before setGuide(r.guide).

Also applies to: 351-362

🤖 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 `@frontend/src/components/screens/Study.tsx` around lines 288 - 329, Guard the
async response handling in loadGuide and regenerate with the current
courseId/examId selection, using refs updated whenever those values change.
Before applying guide data or related state such as loadedTerm, return without
updates when the captured cid/eid no longer match the current selection, while
preserving normal updates for the active request.
🧹 Nitpick comments (1)
frontend/e2e/study-recent-guides.spec.ts (1)

22-27: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a stable testid for CustomSelect triggers.

CustomSelect has no data-testid prop and its trigger button is selector-only, so this regression journey relies on aria-haspopup="listbox". Use a documented route, such as giving CustomSelect a testid prop and wiring the Study course/exam pickers into it, before relying on ARIA markup here.

🤖 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 `@frontend/e2e/study-recent-guides.spec.ts` around lines 22 - 27, Add a
documented testid prop to the CustomSelect trigger and pass distinct stable test
IDs to the Study course and exam pickers. Update the examPicker locator in the
regression test to use the exam picker test ID instead of relying on
aria-haspopup and DOM order.

Source: Path instructions

🤖 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 `@backend/db/seed_local_rich.py`:
- Around line 585-620: Update seed_study_guides to encrypt each guide’s content
before passing it to h.insert_if_absent, using the existing encrypt_json or
encrypt_if_present helper and matching the encryption behavior in the
study_guide route. Keep the stored study_guides row structure and
cross-references unchanged.
---
Outside diff comments:
In `@frontend/src/components/screens/Study.tsx`:
- Around line 288-329: Guard the async response handling in loadGuide and
regenerate with the current courseId/examId selection, using refs updated
whenever those values change. Before applying guide data or related state such
as loadedTerm, return without updates when the captured cid/eid no longer match
the current selection, while preserving normal updates for the active request.
---
Nitpick comments:
In `@frontend/e2e/study-recent-guides.spec.ts`:
- Around line 22-27: Add a documented testid prop to the CustomSelect trigger
and pass distinct stable test IDs to the Study course and exam pickers. Update
the examPicker locator in the regression test to use the exam picker test ID
instead of relying on aria-haspopup and DOM order.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 24187e41-dbab-4240-a235-8efe5889d8af

📥 Commits

Reviewing files that changed from the base of the PR and between 4236c32 and 065b325.

📒 Files selected for processing (5)
  • backend/db/seed_local_rich.py
  • frontend/e2e/study-recent-guides.spec.ts
  • frontend/src/components/screens/Study.recentGuides.test.tsx
  • frontend/src/components/screens/Study.semester.test.tsx
  • frontend/src/components/screens/Study.tsx

Comment on lines +585 to +620
_STUDY_GUIDES = [
("rich-guide-cs-f25-mid", USER_ACTIVE, OFF_CS_F25, "rich-asg-cs-f25-mid",
"2026-03-01T12:00:00Z",
{
"exam": "Midterm Exam",
"due_date": "2025-10-15",
"overview": "Covers variables, control flow, and functions.",
"topics": [
{
"name": "Variables",
"importance": "Every later topic builds on binding names to values.",
"concepts": ["Assignment", "Scope"],
},
{
"name": "Recursion",
"importance": "The midterm's hardest questions are recursive traces.",
"concepts": ["Base case", "Call stack"],
},
],
}),
]


def seed_study_guides() -> None:
for guide_id, user_id, off_id, exam_id, generated_at, content in _STUDY_GUIDES:
h.insert_if_absent(
"study_guides",
guide_id,
{
"user_id": user_id,
"offering_id": off_id,
"exam_id": exam_id,
"generated_at": generated_at,
"content": content,
},
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"--- assignment id/title match ---"
rg -n "rich-asg-cs-f25-mid" backend/db/seed_local_rich.py
rg -n "_ASSIGNMENTS = \[" -A 40 backend/db/seed_local_rich.py | rg -n "rich-asg-cs-f25-mid|Midterm"echo"--- encryption precedent ---"
rg -n "encrypt_if_present" backend/db/seed_local_rich.py
rg -n "study_guides" backend -g '*.py' -g '!backend/db/seed_local_rich.py'

Repository: SaplingLearn/Sapling

Length of output: 2909


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"--- seed_local_rich study_guide section ---"
sed -n '560,635p' backend/db/seed_local_rich.py
echoecho"--- assignments section around exam id ---"
sed -n '340,385p' backend/db/seed_local_rich.py
echoecho"--- generate/insert study guide path ---"
sed -n '160,220p' backend/routes/study_guide.py
sed -n '300,370p' backend/routes/study_guide.py
echoecho"--- frontend e2e references ---"if [ -f frontend/e2e/study-recent-guides.spec.ts ];then
rg -n "Midterm Exam|study guide|guides|rich-guide-cs-f25-mid|Midterm" frontend/e2e/study-recent-guides.spec.ts
elseecho"frontend/e2e/study-recent-guides.spec.ts not found"fiechoecho"--- schema/table model for study_guides if present ---"
rg -n "study_guides|content.*study" backend/db backend/services backend/tests backend/routes -g '*.py'| head -200

Repository: SaplingLearn/Sapling

Length of output: 13088


Encrypt the generated study_guides.content before inserting it.

"rich-asg-cs-f25-mid" exists and matches "Midterm Exam" in _ASSIGNMENTS, so the cross-reference is correct. For writes, backend/routes/study_guide.py inserts content = result.output.model_dump() directly into study_guides; route that value through encrypt_json/encrypt_if_present before table("study_guides").insert(row).

🤖 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 `@backend/db/seed_local_rich.py` around lines 585 - 620, Update
seed_study_guides to encrypt each guide’s content before passing it to
h.insert_if_absent, using the existing encrypt_json or encrypt_if_present helper
and matching the encryption behavior in the study_guide route. Keep the stored
study_guides row structure and cross-references unchanged.

Source: Coding guidelines

Code review caught a regression this PR introduced. Moving the reset out of
the courseId-keyed effect and onto the picker's onChange dropped a guard the
effect had for free: setCourseId(sameValue) bails out, so the effect never
re-ran. CustomSelect.commit() fires onChange for the already-selected option
too, so re-confirming the course you were already on wiped the guide you were
reading — the same class of bug as #476 itself.
Guard selectCourse on an actual change, and pin it with a test.
Also records the known cross-term edge the fix leaves standing: the exam
OPTIONS follow the active selector by #475's design, so a rail entry opened
under a different term is absent from that list and the picker shows its
placeholder while the guide and Regenerate are live and correctly aimed at
loadedTerm. Squaring it means tracking "the term I'm viewing" across list and
loads, which is a #475 change rather than part of this fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 blocking issue, now fixed in 1778215, plus two notes recorded rather than fixed.

  1. Re-picking the already-selected course wiped the loaded guide — a regression this PR introduced (bug due to CustomSelect.commit() calling onChange(opt.value) unconditionally, with no equality check against the current value).

Moving the reset out of the courseId-keyed effect onto the picker's onChange dropped a guard the effect had for free: setCourseId(sameValue) bails out, so the effect never re-ran. On a plain handler it has to be explicit. Reopening the course dropdown and clicking the course you were already on cleared examId/guide — the same class of defect as #476 itself.

constselectCourse=(next: string)=>{
setCourseId(next);
clearSelection();
};

Fixed by guarding on an actual change, with a regression test (re-picking the course already selected leaves the loaded guide alone) that was watched fail first:

// Guarded on an actual CHANGE. CustomSelect fires onChange for the
// already-selected option too, and the effect this reset came out of got that
// for free — setCourseId(same) bails out, so the effect never re-ran. On a
// plain handler it has to be explicit, or re-confirming the course you are
// already on wipes the guide you are reading.
constselectCourse=(next: string)=>{
if(next===courseId)return;
setCourseId(next);
clearSelection();
};

Notes, not blocking:

  1. Cross-term rail open leaves the exam picker on its placeholder. The exam OPTIONS follow the active selector, so an entry opened under a different term isn't in that list and CustomSelect falls back to the placeholder — while the guide and Regenerate are live and correctly aimed at loadedTerm. This is not a regression (pre-fix that case was strictly worse: Regenerate was dead too), and listing the entry's term instead would offer exams the next picker-driven load would 404 on, since feat(study): semester-scope the study-tool reads (#141 reframe) #475 pins only the recent-open's own load. Squaring it means tracking "the term I'm viewing" across list and loads — a feat(study): semester-scope the study-tool reads (#141 reframe) #475 change. Recorded in a comment at the effect.

// The OPTIONS follow the active selector, deliberately: #475 pins only the
// recent-open's own load to the entry's term, and later picker-driven loads
// go back to the selector — so listing the entry's term here would offer
// exams that the next load would then 404 on.
// Known edge that leaves: open a rail entry whose term ISN'T the active one
// and its exam is absent from this list, so CustomSelect falls back to the
// placeholder while the guide and Regenerate are live and correctly aimed at
// `loadedTerm`. Squaring that needs the screen to track "the term I'm
// viewing" as one thing across list + loads — a #475 change, not this one.
React.useEffect(()=>{
setExams([]);
if(!courseId||!userId||!semesterReady)return;
setLoadingExams(true);
getStudyGuideExams(userId,courseId,semester||undefined)
.then(r=>setExams(r.exams||[]))

  1. loadGuide has no stale-response guard, so two rail entries clicked in quick succession can resolve out of order. Pre-existing; this PR adds loadedTerm to the state that can go stale. Worth a follow-up, out of scope here.

Also checked and cleared: a review pass flagged a captured Playwright failure of this PR's own journey as evidence the fix doesn't work. That artifact is from a deliberate pre-fix run — e2e/results/last-run.json recorded argv of playwright test e2e/study-recent-guides.spec.ts, the single-spec invocation used to verify the journey goes red against pre-fix Study.tsx. Gitignored, so it was never part of this PR; deleted to stop it misleading future readers.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@AndresL230
AndresL230 merged commit bbe4eb5 into mainJul 31, 2026
7 checks passed
@AndresL230
AndresL230 deleted the fix/476-recent-guide-exam-selection branch August 2, 2026 18:30
Sign up for freeto 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

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(study): keep the exam selected when opening a recent guide (#476) - #499

Merged
AndresL230 merged 2 commits into
mainfrom
fix/476-recent-guide-exam-selection
Jul 31, 2026
Merged

fix(study): keep the exam selected when opening a recent guide (#476)#499
AndresL230 merged 2 commits into
mainfrom
fix/476-recent-guide-exam-selection

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Part of #476.

The issue body misattributes the cause

"the recent-guides open path clears the selected exam"

It doesn't. openRecent (Study.tsx) sets both ids:

recentTermRef.current=entry.semester??"";setCourseId(entry.course_id);setExamId(entry.exam_id);

The clearing comes from the courseId-keyed exams effect, whose first
statement was an unconditional setExamId(""). That reset cannot tell
"the user switched course" (selection now invalid) from "we just opened a
specific guide"
(selection deliberate and valid) — it only sees that
courseId differs from its last value, which openRecent also causes.

Both effects run in the same commit, so the loader still saw the intact
pair and the guide loaded; only the next render lost the exam. That is why
the symptom is "a guide on screen above a dead Regenerate button" rather than
"nothing opens" — and why disabled={… || !examId} pins the button off.

Discriminator (pinned as a control test): it needs a course change to
reproduce. A rail entry whose course is already selected never trips the
effect and worked fine on main. That test passed before the fix and still
passes after — it's what proves the mechanism is the reset, not the open path.

The fix

The reset now happens at the two events that actually mean it:

  1. Course picker onChange — the event that genuinely invalidates the exam.
  2. Term switch — a re-scope of the exam list, so the exam picked under the
    old term need not exist in the new one.

The term case adjusts state during render (the StudyModePanel pattern
already in this file) rather than in an effect. That matters: an effect-time
reset lands a render late, so the loader commits one read of the old exam
under the new term first. That was the same defect's second trigger — it
reproduced on main and now has a test.

The exams effect is now purely a fetch.

One consequence worth flagging

Making Regenerate reachable on the rail path exposed a term hazard.
regenerate sent the active selector's term, while a recent entry opens
under its own term (#475 F1). On main that was unreachable — the button
was dead on the one path where the two differ — so it was latent. Enabling
the button would have made it live: regenerating a Fall guide while the
selector says Spring rebuilds against an offering the displayed guide never
came from, which is exactly the mismatch #475 F1 fixed for reads.

Regenerate now replays the term the displayed guide was loaded with.

Tests

  • Study.recentGuides.test.tsx (new, 7 cases) — written red first:
    4 failed on the Regenerate/picker assertions, the term-switch case failed
    on the stale re-read, and the same-course control passed throughout.
  • Study.semester.test.tsx — updated a comment that documented the bug as
    permanent ("Regenerate isn't drivable from the recent-guides rail").
  • e2e/study-recent-guides.spec.ts (new) — promoted regression journey.
  • db/seed_local_rich.py — seeds a cached guide so the journey can open
    the rail without generating; the study_guide agent has no function-mode
    handler, and the guide GET returns the cache hit before reaching it. The
    journey asserts Regenerate is enabled and deliberately never clicks it.

Gates

  • tsc --noEmit clean; eslint src 0 errors; vitest run 422/422.
  • backend pytest 1529 passed / 32 skipped; ruff check clean.
  • Full local e2e cycle (up → Playwright → oracles → down) in one flock.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a cached CS101 Fall 2025 midterm study guide for local study experiences.
    • Recent guides now preserve the associated course, exam, and semester when opened.
    • Regeneration uses the selected guide’s original term and keeps the Regenerate option available.
  • Bug Fixes

    • Prevented outdated exam and guide selections from carrying over when switching courses or semesters.
    • Improved failed-guide retries by preserving the original study selections.

Opening a guide from the "Recent guides" rail left Regenerate permanently
disabled. The cause is not the open path — openRecent sets courseId AND
examId together. It's the courseId-keyed exams effect, which opened with an
unconditional setExamId(""): a scope reset that cannot tell "the user
switched course" (selection now invalid) from "we just opened a specific
guide" (selection deliberate and valid).
Both effects run in the same commit, so the loader still saw the intact pair
and the guide loaded; only the NEXT render lost the exam. Hence the symptom —
a guide on screen above a dead Regenerate button — rather than "nothing
opens". It needs a course CHANGE to reproduce, which is why a rail entry for
the already-selected course always worked (pinned as a control test).
The reset now happens at the two events that mean it: the course picker's
onChange, and a term switch. The term case adjusts state during render (the
StudyModePanel pattern already in this file) rather than in an effect,
because an effect-time reset lands a render late — the loader would commit
one read of the old exam under the new term first. That was the same defect's
second trigger, and it now has a test.
Making Regenerate reachable on the rail path exposed a term hazard: it sent
the ACTIVE selector's term, while a recent entry opens under its OWN term
(#475 F1). Regenerating a Fall guide as Spring would rebuild against an
offering the displayed guide never came from. Regenerate now replays the term
the displayed guide was loaded with.
Also seeds a CACHED study guide in the rich local dataset so the e2e journey
can open the rail without generating (the study_guide agent has no
function-mode handler).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9dbc4915-c217-4ea7-ac76-e931dedc30df

📥 Commits

Reviewing files that changed from the base of the PR and between 065b325 and 1778215.

📒 Files selected for processing (2)
  • frontend/src/components/screens/Study.recentGuides.test.tsx
  • frontend/src/components/screens/Study.tsx
📝 Walkthrough

Walkthrough

The PR seeds a cached CS101 midterm guide and updates Study to preserve the guide’s course, exam, and semester context. Course and semester changes clear stale state. Unit and Playwright tests cover recent-guide navigation and regeneration.

Changes

Study guide context

Layer / File(s)Summary
Seed cached study guides
backend/db/seed_local_rich.py
Adds idempotent CS101 midterm guide data and runs the study-guide seed step.
Preserve guide course, exam, and semester context
frontend/src/components/screens/Study.tsx
Stores the resolved semester, reuses the original context for retries and regeneration, and clears stale guide state when course or semester changes.
Validate recent-guide navigation
frontend/src/components/screens/Study.recentGuides.test.tsx, frontend/src/components/screens/Study.semester.test.tsx, frontend/e2e/study-recent-guides.spec.ts
Tests recent-guide opening, preserved selections, term-aware regeneration, stale-selection resets, and the enabled Regenerate action.

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

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the main fix: preserving exam selection when opening a recent study guide.
Description check✅ PassedThe description explains the cause, fix, related issue, testing, and review notes, although it does not follow the template headings exactly.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck 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 fix/476-recent-guide-exam-selection

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging1778215Commit Preview URL

Branch Preview URL
Jul 31 2026, 04:18 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/components/screens/Study.tsx (1)

288-329: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard loadGuide and regenerate against stale async responses.

Neither loadGuide nor regenerate checks whether the selection is still current when their request resolves. If a user switches term or course while a getStudyGuide/regenerateStudyGuide call is in flight, clearSelection() clears examId/guide during render, but the pending .then still runs setGuide(r.guide) and setLoadedTerm(...) unconditionally afterward. That resurrects a guide (and its term) the user just navigated away from, under a course/term combination that no longer matches the pickers. This directly undercuts the invariant this PR adds ("switching term drops the selection instead of reloading it").

Add a staleness check before applying the response, for example by comparing the captured cid/eid against refs updated whenever courseId/examId change, and bailing out if they no longer match.

🔧 Proposed staleness guard
+ const courseIdRef = React.useRef(courseId);+ const examIdRef = React.useRef(examId);+ React.useEffect(() => { courseIdRef.current = courseId; }, [courseId]);+ React.useEffect(() => { examIdRef.current = examId; }, [examId]);+
const loadGuide = React.useCallback(async (cid: string, eid: string, termOverride?: string) => {
if (!userId) return;
const term = termOverride !== undefined ? (termOverride || undefined) : (semester || undefined);
setLoadingGuide(true);
setGuideProblem(null);
try {
const r = await getStudyGuide(userId, cid, eid, term);
+ // Bail if the selection changed while the request was in flight — a+ // cleared or reselected exam should not be resurrected by a late response.+ if (cid !== courseIdRef.current || eid !== examIdRef.current) return;
setGuide(r.guide);

Apply the same cid/eid check in regenerate before setGuide(r.guide).

Also applies to: 351-362

🤖 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 `@frontend/src/components/screens/Study.tsx` around lines 288 - 329, Guard the
async response handling in loadGuide and regenerate with the current
courseId/examId selection, using refs updated whenever those values change.
Before applying guide data or related state such as loadedTerm, return without
updates when the captured cid/eid no longer match the current selection, while
preserving normal updates for the active request.
🧹 Nitpick comments (1)
frontend/e2e/study-recent-guides.spec.ts (1)

22-27: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a stable testid for CustomSelect triggers.

CustomSelect has no data-testid prop and its trigger button is selector-only, so this regression journey relies on aria-haspopup="listbox". Use a documented route, such as giving CustomSelect a testid prop and wiring the Study course/exam pickers into it, before relying on ARIA markup here.

🤖 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 `@frontend/e2e/study-recent-guides.spec.ts` around lines 22 - 27, Add a
documented testid prop to the CustomSelect trigger and pass distinct stable test
IDs to the Study course and exam pickers. Update the examPicker locator in the
regression test to use the exam picker test ID instead of relying on
aria-haspopup and DOM order.

Source: Path instructions

🤖 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 `@backend/db/seed_local_rich.py`:
- Around line 585-620: Update seed_study_guides to encrypt each guide’s content
before passing it to h.insert_if_absent, using the existing encrypt_json or
encrypt_if_present helper and matching the encryption behavior in the
study_guide route. Keep the stored study_guides row structure and
cross-references unchanged.
---
Outside diff comments:
In `@frontend/src/components/screens/Study.tsx`:
- Around line 288-329: Guard the async response handling in loadGuide and
regenerate with the current courseId/examId selection, using refs updated
whenever those values change. Before applying guide data or related state such
as loadedTerm, return without updates when the captured cid/eid no longer match
the current selection, while preserving normal updates for the active request.
---
Nitpick comments:
In `@frontend/e2e/study-recent-guides.spec.ts`:
- Around line 22-27: Add a documented testid prop to the CustomSelect trigger
and pass distinct stable test IDs to the Study course and exam pickers. Update
the examPicker locator in the regression test to use the exam picker test ID
instead of relying on aria-haspopup and DOM order.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 24187e41-dbab-4240-a235-8efe5889d8af

📥 Commits

Reviewing files that changed from the base of the PR and between 4236c32 and 065b325.

📒 Files selected for processing (5)
  • backend/db/seed_local_rich.py
  • frontend/e2e/study-recent-guides.spec.ts
  • frontend/src/components/screens/Study.recentGuides.test.tsx
  • frontend/src/components/screens/Study.semester.test.tsx
  • frontend/src/components/screens/Study.tsx

Comment on lines +585 to +620
_STUDY_GUIDES = [
("rich-guide-cs-f25-mid", USER_ACTIVE, OFF_CS_F25, "rich-asg-cs-f25-mid",
"2026-03-01T12:00:00Z",
{
"exam": "Midterm Exam",
"due_date": "2025-10-15",
"overview": "Covers variables, control flow, and functions.",
"topics": [
{
"name": "Variables",
"importance": "Every later topic builds on binding names to values.",
"concepts": ["Assignment", "Scope"],
},
{
"name": "Recursion",
"importance": "The midterm's hardest questions are recursive traces.",
"concepts": ["Base case", "Call stack"],
},
],
}),
]


def seed_study_guides() -> None:
for guide_id, user_id, off_id, exam_id, generated_at, content in _STUDY_GUIDES:
h.insert_if_absent(
"study_guides",
guide_id,
{
"user_id": user_id,
"offering_id": off_id,
"exam_id": exam_id,
"generated_at": generated_at,
"content": content,
},
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"--- assignment id/title match ---"
rg -n "rich-asg-cs-f25-mid" backend/db/seed_local_rich.py
rg -n "_ASSIGNMENTS = \[" -A 40 backend/db/seed_local_rich.py | rg -n "rich-asg-cs-f25-mid|Midterm"echo"--- encryption precedent ---"
rg -n "encrypt_if_present" backend/db/seed_local_rich.py
rg -n "study_guides" backend -g '*.py' -g '!backend/db/seed_local_rich.py'

Repository: SaplingLearn/Sapling

Length of output: 2909


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"--- seed_local_rich study_guide section ---"
sed -n '560,635p' backend/db/seed_local_rich.py
echoecho"--- assignments section around exam id ---"
sed -n '340,385p' backend/db/seed_local_rich.py
echoecho"--- generate/insert study guide path ---"
sed -n '160,220p' backend/routes/study_guide.py
sed -n '300,370p' backend/routes/study_guide.py
echoecho"--- frontend e2e references ---"if [ -f frontend/e2e/study-recent-guides.spec.ts ];then
rg -n "Midterm Exam|study guide|guides|rich-guide-cs-f25-mid|Midterm" frontend/e2e/study-recent-guides.spec.ts
elseecho"frontend/e2e/study-recent-guides.spec.ts not found"fiechoecho"--- schema/table model for study_guides if present ---"
rg -n "study_guides|content.*study" backend/db backend/services backend/tests backend/routes -g '*.py'| head -200

Repository: SaplingLearn/Sapling

Length of output: 13088


Encrypt the generated study_guides.content before inserting it.

"rich-asg-cs-f25-mid" exists and matches "Midterm Exam" in _ASSIGNMENTS, so the cross-reference is correct. For writes, backend/routes/study_guide.py inserts content = result.output.model_dump() directly into study_guides; route that value through encrypt_json/encrypt_if_present before table("study_guides").insert(row).

🤖 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 `@backend/db/seed_local_rich.py` around lines 585 - 620, Update
seed_study_guides to encrypt each guide’s content before passing it to
h.insert_if_absent, using the existing encrypt_json or encrypt_if_present helper
and matching the encryption behavior in the study_guide route. Keep the stored
study_guides row structure and cross-references unchanged.

Source: Coding guidelines

Code review caught a regression this PR introduced. Moving the reset out of
the courseId-keyed effect and onto the picker's onChange dropped a guard the
effect had for free: setCourseId(sameValue) bails out, so the effect never
re-ran. CustomSelect.commit() fires onChange for the already-selected option
too, so re-confirming the course you were already on wiped the guide you were
reading — the same class of bug as #476 itself.
Guard selectCourse on an actual change, and pin it with a test.
Also records the known cross-term edge the fix leaves standing: the exam
OPTIONS follow the active selector by #475's design, so a rail entry opened
under a different term is absent from that list and the picker shows its
placeholder while the guide and Regenerate are live and correctly aimed at
loadedTerm. Squaring it means tracking "the term I'm viewing" across list and
loads, which is a #475 change rather than part of this fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 blocking issue, now fixed in 1778215, plus two notes recorded rather than fixed.

  1. Re-picking the already-selected course wiped the loaded guide — a regression this PR introduced (bug due to CustomSelect.commit() calling onChange(opt.value) unconditionally, with no equality check against the current value).

Moving the reset out of the courseId-keyed effect onto the picker's onChange dropped a guard the effect had for free: setCourseId(sameValue) bails out, so the effect never re-ran. On a plain handler it has to be explicit. Reopening the course dropdown and clicking the course you were already on cleared examId/guide — the same class of defect as #476 itself.

constselectCourse=(next: string)=>{
setCourseId(next);
clearSelection();
};

Fixed by guarding on an actual change, with a regression test (re-picking the course already selected leaves the loaded guide alone) that was watched fail first:

// Guarded on an actual CHANGE. CustomSelect fires onChange for the
// already-selected option too, and the effect this reset came out of got that
// for free — setCourseId(same) bails out, so the effect never re-ran. On a
// plain handler it has to be explicit, or re-confirming the course you are
// already on wipes the guide you are reading.
constselectCourse=(next: string)=>{
if(next===courseId)return;
setCourseId(next);
clearSelection();
};

Notes, not blocking:

  1. Cross-term rail open leaves the exam picker on its placeholder. The exam OPTIONS follow the active selector, so an entry opened under a different term isn't in that list and CustomSelect falls back to the placeholder — while the guide and Regenerate are live and correctly aimed at loadedTerm. This is not a regression (pre-fix that case was strictly worse: Regenerate was dead too), and listing the entry's term instead would offer exams the next picker-driven load would 404 on, since feat(study): semester-scope the study-tool reads (#141 reframe) #475 pins only the recent-open's own load. Squaring it means tracking "the term I'm viewing" across list and loads — a feat(study): semester-scope the study-tool reads (#141 reframe) #475 change. Recorded in a comment at the effect.

// The OPTIONS follow the active selector, deliberately: #475 pins only the
// recent-open's own load to the entry's term, and later picker-driven loads
// go back to the selector — so listing the entry's term here would offer
// exams that the next load would then 404 on.
// Known edge that leaves: open a rail entry whose term ISN'T the active one
// and its exam is absent from this list, so CustomSelect falls back to the
// placeholder while the guide and Regenerate are live and correctly aimed at
// `loadedTerm`. Squaring that needs the screen to track "the term I'm
// viewing" as one thing across list + loads — a #475 change, not this one.
React.useEffect(()=>{
setExams([]);
if(!courseId||!userId||!semesterReady)return;
setLoadingExams(true);
getStudyGuideExams(userId,courseId,semester||undefined)
.then(r=>setExams(r.exams||[]))

  1. loadGuide has no stale-response guard, so two rail entries clicked in quick succession can resolve out of order. Pre-existing; this PR adds loadedTerm to the state that can go stale. Worth a follow-up, out of scope here.

Also checked and cleared: a review pass flagged a captured Playwright failure of this PR's own journey as evidence the fix doesn't work. That artifact is from a deliberate pre-fix run — e2e/results/last-run.json recorded argv of playwright test e2e/study-recent-guides.spec.ts, the single-spec invocation used to verify the journey goes red against pre-fix Study.tsx. Gitignored, so it was never part of this PR; deleted to stop it misleading future readers.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@AndresL230
AndresL230 merged commit bbe4eb5 into mainJul 31, 2026
7 checks passed
@AndresL230
AndresL230 deleted the fix/476-recent-guide-exam-selection branch August 2, 2026 18:30
Sign up for freeto 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

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(study): keep the exam selected when opening a recent guide (#476) - #499

Merged
AndresL230 merged 2 commits into
mainfrom
fix/476-recent-guide-exam-selection
Jul 31, 2026
Merged

fix(study): keep the exam selected when opening a recent guide (#476)#499
AndresL230 merged 2 commits into
mainfrom
fix/476-recent-guide-exam-selection

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Part of #476.

The issue body misattributes the cause

"the recent-guides open path clears the selected exam"

It doesn't. openRecent (Study.tsx) sets both ids:

recentTermRef.current=entry.semester??"";setCourseId(entry.course_id);setExamId(entry.exam_id);

The clearing comes from the courseId-keyed exams effect, whose first
statement was an unconditional setExamId(""). That reset cannot tell
"the user switched course" (selection now invalid) from "we just opened a
specific guide"
(selection deliberate and valid) — it only sees that
courseId differs from its last value, which openRecent also causes.

Both effects run in the same commit, so the loader still saw the intact
pair and the guide loaded; only the next render lost the exam. That is why
the symptom is "a guide on screen above a dead Regenerate button" rather than
"nothing opens" — and why disabled={… || !examId} pins the button off.

Discriminator (pinned as a control test): it needs a course change to
reproduce. A rail entry whose course is already selected never trips the
effect and worked fine on main. That test passed before the fix and still
passes after — it's what proves the mechanism is the reset, not the open path.

The fix

The reset now happens at the two events that actually mean it:

  1. Course picker onChange — the event that genuinely invalidates the exam.
  2. Term switch — a re-scope of the exam list, so the exam picked under the
    old term need not exist in the new one.

The term case adjusts state during render (the StudyModePanel pattern
already in this file) rather than in an effect. That matters: an effect-time
reset lands a render late, so the loader commits one read of the old exam
under the new term first. That was the same defect's second trigger — it
reproduced on main and now has a test.

The exams effect is now purely a fetch.

One consequence worth flagging

Making Regenerate reachable on the rail path exposed a term hazard.
regenerate sent the active selector's term, while a recent entry opens
under its own term (#475 F1). On main that was unreachable — the button
was dead on the one path where the two differ — so it was latent. Enabling
the button would have made it live: regenerating a Fall guide while the
selector says Spring rebuilds against an offering the displayed guide never
came from, which is exactly the mismatch #475 F1 fixed for reads.

Regenerate now replays the term the displayed guide was loaded with.

Tests

  • Study.recentGuides.test.tsx (new, 7 cases) — written red first:
    4 failed on the Regenerate/picker assertions, the term-switch case failed
    on the stale re-read, and the same-course control passed throughout.
  • Study.semester.test.tsx — updated a comment that documented the bug as
    permanent ("Regenerate isn't drivable from the recent-guides rail").
  • e2e/study-recent-guides.spec.ts (new) — promoted regression journey.
  • db/seed_local_rich.py — seeds a cached guide so the journey can open
    the rail without generating; the study_guide agent has no function-mode
    handler, and the guide GET returns the cache hit before reaching it. The
    journey asserts Regenerate is enabled and deliberately never clicks it.

Gates

  • tsc --noEmit clean; eslint src 0 errors; vitest run 422/422.
  • backend pytest 1529 passed / 32 skipped; ruff check clean.
  • Full local e2e cycle (up → Playwright → oracles → down) in one flock.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a cached CS101 Fall 2025 midterm study guide for local study experiences.
    • Recent guides now preserve the associated course, exam, and semester when opened.
    • Regeneration uses the selected guide’s original term and keeps the Regenerate option available.
  • Bug Fixes

    • Prevented outdated exam and guide selections from carrying over when switching courses or semesters.
    • Improved failed-guide retries by preserving the original study selections.

Opening a guide from the "Recent guides" rail left Regenerate permanently
disabled. The cause is not the open path — openRecent sets courseId AND
examId together. It's the courseId-keyed exams effect, which opened with an
unconditional setExamId(""): a scope reset that cannot tell "the user
switched course" (selection now invalid) from "we just opened a specific
guide" (selection deliberate and valid).
Both effects run in the same commit, so the loader still saw the intact pair
and the guide loaded; only the NEXT render lost the exam. Hence the symptom —
a guide on screen above a dead Regenerate button — rather than "nothing
opens". It needs a course CHANGE to reproduce, which is why a rail entry for
the already-selected course always worked (pinned as a control test).
The reset now happens at the two events that mean it: the course picker's
onChange, and a term switch. The term case adjusts state during render (the
StudyModePanel pattern already in this file) rather than in an effect,
because an effect-time reset lands a render late — the loader would commit
one read of the old exam under the new term first. That was the same defect's
second trigger, and it now has a test.
Making Regenerate reachable on the rail path exposed a term hazard: it sent
the ACTIVE selector's term, while a recent entry opens under its OWN term
(#475 F1). Regenerating a Fall guide as Spring would rebuild against an
offering the displayed guide never came from. Regenerate now replays the term
the displayed guide was loaded with.
Also seeds a CACHED study guide in the rich local dataset so the e2e journey
can open the rail without generating (the study_guide agent has no
function-mode handler).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9dbc4915-c217-4ea7-ac76-e931dedc30df

📥 Commits

Reviewing files that changed from the base of the PR and between 065b325 and 1778215.

📒 Files selected for processing (2)
  • frontend/src/components/screens/Study.recentGuides.test.tsx
  • frontend/src/components/screens/Study.tsx
📝 Walkthrough

Walkthrough

The PR seeds a cached CS101 midterm guide and updates Study to preserve the guide’s course, exam, and semester context. Course and semester changes clear stale state. Unit and Playwright tests cover recent-guide navigation and regeneration.

Changes

Study guide context

Layer / File(s)Summary
Seed cached study guides
backend/db/seed_local_rich.py
Adds idempotent CS101 midterm guide data and runs the study-guide seed step.
Preserve guide course, exam, and semester context
frontend/src/components/screens/Study.tsx
Stores the resolved semester, reuses the original context for retries and regeneration, and clears stale guide state when course or semester changes.
Validate recent-guide navigation
frontend/src/components/screens/Study.recentGuides.test.tsx, frontend/src/components/screens/Study.semester.test.tsx, frontend/e2e/study-recent-guides.spec.ts
Tests recent-guide opening, preserved selections, term-aware regeneration, stale-selection resets, and the enabled Regenerate action.

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

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the main fix: preserving exam selection when opening a recent study guide.
Description check✅ PassedThe description explains the cause, fix, related issue, testing, and review notes, although it does not follow the template headings exactly.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck 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 fix/476-recent-guide-exam-selection

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging1778215Commit Preview URL

Branch Preview URL
Jul 31 2026, 04:18 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/components/screens/Study.tsx (1)

288-329: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard loadGuide and regenerate against stale async responses.

Neither loadGuide nor regenerate checks whether the selection is still current when their request resolves. If a user switches term or course while a getStudyGuide/regenerateStudyGuide call is in flight, clearSelection() clears examId/guide during render, but the pending .then still runs setGuide(r.guide) and setLoadedTerm(...) unconditionally afterward. That resurrects a guide (and its term) the user just navigated away from, under a course/term combination that no longer matches the pickers. This directly undercuts the invariant this PR adds ("switching term drops the selection instead of reloading it").

Add a staleness check before applying the response, for example by comparing the captured cid/eid against refs updated whenever courseId/examId change, and bailing out if they no longer match.

🔧 Proposed staleness guard
+ const courseIdRef = React.useRef(courseId);+ const examIdRef = React.useRef(examId);+ React.useEffect(() => { courseIdRef.current = courseId; }, [courseId]);+ React.useEffect(() => { examIdRef.current = examId; }, [examId]);+
const loadGuide = React.useCallback(async (cid: string, eid: string, termOverride?: string) => {
if (!userId) return;
const term = termOverride !== undefined ? (termOverride || undefined) : (semester || undefined);
setLoadingGuide(true);
setGuideProblem(null);
try {
const r = await getStudyGuide(userId, cid, eid, term);
+ // Bail if the selection changed while the request was in flight — a+ // cleared or reselected exam should not be resurrected by a late response.+ if (cid !== courseIdRef.current || eid !== examIdRef.current) return;
setGuide(r.guide);

Apply the same cid/eid check in regenerate before setGuide(r.guide).

Also applies to: 351-362

🤖 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 `@frontend/src/components/screens/Study.tsx` around lines 288 - 329, Guard the
async response handling in loadGuide and regenerate with the current
courseId/examId selection, using refs updated whenever those values change.
Before applying guide data or related state such as loadedTerm, return without
updates when the captured cid/eid no longer match the current selection, while
preserving normal updates for the active request.
🧹 Nitpick comments (1)
frontend/e2e/study-recent-guides.spec.ts (1)

22-27: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a stable testid for CustomSelect triggers.

CustomSelect has no data-testid prop and its trigger button is selector-only, so this regression journey relies on aria-haspopup="listbox". Use a documented route, such as giving CustomSelect a testid prop and wiring the Study course/exam pickers into it, before relying on ARIA markup here.

🤖 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 `@frontend/e2e/study-recent-guides.spec.ts` around lines 22 - 27, Add a
documented testid prop to the CustomSelect trigger and pass distinct stable test
IDs to the Study course and exam pickers. Update the examPicker locator in the
regression test to use the exam picker test ID instead of relying on
aria-haspopup and DOM order.

Source: Path instructions

🤖 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 `@backend/db/seed_local_rich.py`:
- Around line 585-620: Update seed_study_guides to encrypt each guide’s content
before passing it to h.insert_if_absent, using the existing encrypt_json or
encrypt_if_present helper and matching the encryption behavior in the
study_guide route. Keep the stored study_guides row structure and
cross-references unchanged.
---
Outside diff comments:
In `@frontend/src/components/screens/Study.tsx`:
- Around line 288-329: Guard the async response handling in loadGuide and
regenerate with the current courseId/examId selection, using refs updated
whenever those values change. Before applying guide data or related state such
as loadedTerm, return without updates when the captured cid/eid no longer match
the current selection, while preserving normal updates for the active request.
---
Nitpick comments:
In `@frontend/e2e/study-recent-guides.spec.ts`:
- Around line 22-27: Add a documented testid prop to the CustomSelect trigger
and pass distinct stable test IDs to the Study course and exam pickers. Update
the examPicker locator in the regression test to use the exam picker test ID
instead of relying on aria-haspopup and DOM order.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 24187e41-dbab-4240-a235-8efe5889d8af

📥 Commits

Reviewing files that changed from the base of the PR and between 4236c32 and 065b325.

📒 Files selected for processing (5)
  • backend/db/seed_local_rich.py
  • frontend/e2e/study-recent-guides.spec.ts
  • frontend/src/components/screens/Study.recentGuides.test.tsx
  • frontend/src/components/screens/Study.semester.test.tsx
  • frontend/src/components/screens/Study.tsx

Comment on lines +585 to +620
_STUDY_GUIDES = [
("rich-guide-cs-f25-mid", USER_ACTIVE, OFF_CS_F25, "rich-asg-cs-f25-mid",
"2026-03-01T12:00:00Z",
{
"exam": "Midterm Exam",
"due_date": "2025-10-15",
"overview": "Covers variables, control flow, and functions.",
"topics": [
{
"name": "Variables",
"importance": "Every later topic builds on binding names to values.",
"concepts": ["Assignment", "Scope"],
},
{
"name": "Recursion",
"importance": "The midterm's hardest questions are recursive traces.",
"concepts": ["Base case", "Call stack"],
},
],
}),
]


def seed_study_guides() -> None:
for guide_id, user_id, off_id, exam_id, generated_at, content in _STUDY_GUIDES:
h.insert_if_absent(
"study_guides",
guide_id,
{
"user_id": user_id,
"offering_id": off_id,
"exam_id": exam_id,
"generated_at": generated_at,
"content": content,
},
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"--- assignment id/title match ---"
rg -n "rich-asg-cs-f25-mid" backend/db/seed_local_rich.py
rg -n "_ASSIGNMENTS = \[" -A 40 backend/db/seed_local_rich.py | rg -n "rich-asg-cs-f25-mid|Midterm"echo"--- encryption precedent ---"
rg -n "encrypt_if_present" backend/db/seed_local_rich.py
rg -n "study_guides" backend -g '*.py' -g '!backend/db/seed_local_rich.py'

Repository: SaplingLearn/Sapling

Length of output: 2909


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"--- seed_local_rich study_guide section ---"
sed -n '560,635p' backend/db/seed_local_rich.py
echoecho"--- assignments section around exam id ---"
sed -n '340,385p' backend/db/seed_local_rich.py
echoecho"--- generate/insert study guide path ---"
sed -n '160,220p' backend/routes/study_guide.py
sed -n '300,370p' backend/routes/study_guide.py
echoecho"--- frontend e2e references ---"if [ -f frontend/e2e/study-recent-guides.spec.ts ];then
rg -n "Midterm Exam|study guide|guides|rich-guide-cs-f25-mid|Midterm" frontend/e2e/study-recent-guides.spec.ts
elseecho"frontend/e2e/study-recent-guides.spec.ts not found"fiechoecho"--- schema/table model for study_guides if present ---"
rg -n "study_guides|content.*study" backend/db backend/services backend/tests backend/routes -g '*.py'| head -200

Repository: SaplingLearn/Sapling

Length of output: 13088


Encrypt the generated study_guides.content before inserting it.

"rich-asg-cs-f25-mid" exists and matches "Midterm Exam" in _ASSIGNMENTS, so the cross-reference is correct. For writes, backend/routes/study_guide.py inserts content = result.output.model_dump() directly into study_guides; route that value through encrypt_json/encrypt_if_present before table("study_guides").insert(row).

🤖 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 `@backend/db/seed_local_rich.py` around lines 585 - 620, Update
seed_study_guides to encrypt each guide’s content before passing it to
h.insert_if_absent, using the existing encrypt_json or encrypt_if_present helper
and matching the encryption behavior in the study_guide route. Keep the stored
study_guides row structure and cross-references unchanged.

Source: Coding guidelines

Code review caught a regression this PR introduced. Moving the reset out of
the courseId-keyed effect and onto the picker's onChange dropped a guard the
effect had for free: setCourseId(sameValue) bails out, so the effect never
re-ran. CustomSelect.commit() fires onChange for the already-selected option
too, so re-confirming the course you were already on wiped the guide you were
reading — the same class of bug as #476 itself.
Guard selectCourse on an actual change, and pin it with a test.
Also records the known cross-term edge the fix leaves standing: the exam
OPTIONS follow the active selector by #475's design, so a rail entry opened
under a different term is absent from that list and the picker shows its
placeholder while the guide and Regenerate are live and correctly aimed at
loadedTerm. Squaring it means tracking "the term I'm viewing" across list and
loads, which is a #475 change rather than part of this fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 blocking issue, now fixed in 1778215, plus two notes recorded rather than fixed.

  1. Re-picking the already-selected course wiped the loaded guide — a regression this PR introduced (bug due to CustomSelect.commit() calling onChange(opt.value) unconditionally, with no equality check against the current value).

Moving the reset out of the courseId-keyed effect onto the picker's onChange dropped a guard the effect had for free: setCourseId(sameValue) bails out, so the effect never re-ran. On a plain handler it has to be explicit. Reopening the course dropdown and clicking the course you were already on cleared examId/guide — the same class of defect as #476 itself.

constselectCourse=(next: string)=>{
setCourseId(next);
clearSelection();
};

Fixed by guarding on an actual change, with a regression test (re-picking the course already selected leaves the loaded guide alone) that was watched fail first:

// Guarded on an actual CHANGE. CustomSelect fires onChange for the
// already-selected option too, and the effect this reset came out of got that
// for free — setCourseId(same) bails out, so the effect never re-ran. On a
// plain handler it has to be explicit, or re-confirming the course you are
// already on wipes the guide you are reading.
constselectCourse=(next: string)=>{
if(next===courseId)return;
setCourseId(next);
clearSelection();
};

Notes, not blocking:

  1. Cross-term rail open leaves the exam picker on its placeholder. The exam OPTIONS follow the active selector, so an entry opened under a different term isn't in that list and CustomSelect falls back to the placeholder — while the guide and Regenerate are live and correctly aimed at loadedTerm. This is not a regression (pre-fix that case was strictly worse: Regenerate was dead too), and listing the entry's term instead would offer exams the next picker-driven load would 404 on, since feat(study): semester-scope the study-tool reads (#141 reframe) #475 pins only the recent-open's own load. Squaring it means tracking "the term I'm viewing" across list and loads — a feat(study): semester-scope the study-tool reads (#141 reframe) #475 change. Recorded in a comment at the effect.

// The OPTIONS follow the active selector, deliberately: #475 pins only the
// recent-open's own load to the entry's term, and later picker-driven loads
// go back to the selector — so listing the entry's term here would offer
// exams that the next load would then 404 on.
// Known edge that leaves: open a rail entry whose term ISN'T the active one
// and its exam is absent from this list, so CustomSelect falls back to the
// placeholder while the guide and Regenerate are live and correctly aimed at
// `loadedTerm`. Squaring that needs the screen to track "the term I'm
// viewing" as one thing across list + loads — a #475 change, not this one.
React.useEffect(()=>{
setExams([]);
if(!courseId||!userId||!semesterReady)return;
setLoadingExams(true);
getStudyGuideExams(userId,courseId,semester||undefined)
.then(r=>setExams(r.exams||[]))

  1. loadGuide has no stale-response guard, so two rail entries clicked in quick succession can resolve out of order. Pre-existing; this PR adds loadedTerm to the state that can go stale. Worth a follow-up, out of scope here.

Also checked and cleared: a review pass flagged a captured Playwright failure of this PR's own journey as evidence the fix doesn't work. That artifact is from a deliberate pre-fix run — e2e/results/last-run.json recorded argv of playwright test e2e/study-recent-guides.spec.ts, the single-spec invocation used to verify the journey goes red against pre-fix Study.tsx. Gitignored, so it was never part of this PR; deleted to stop it misleading future readers.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@AndresL230
AndresL230 merged commit bbe4eb5 into mainJul 31, 2026
7 checks passed
@AndresL230
AndresL230 deleted the fix/476-recent-guide-exam-selection branch August 2, 2026 18:30
Sign up for freeto 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

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(study): keep the exam selected when opening a recent guide (#476) - #499

Merged
AndresL230 merged 2 commits into
mainfrom
fix/476-recent-guide-exam-selection
Jul 31, 2026
Merged

fix(study): keep the exam selected when opening a recent guide (#476)#499
AndresL230 merged 2 commits into
mainfrom
fix/476-recent-guide-exam-selection

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Part of #476.

The issue body misattributes the cause

"the recent-guides open path clears the selected exam"

It doesn't. openRecent (Study.tsx) sets both ids:

recentTermRef.current=entry.semester??"";setCourseId(entry.course_id);setExamId(entry.exam_id);

The clearing comes from the courseId-keyed exams effect, whose first
statement was an unconditional setExamId(""). That reset cannot tell
"the user switched course" (selection now invalid) from "we just opened a
specific guide"
(selection deliberate and valid) — it only sees that
courseId differs from its last value, which openRecent also causes.

Both effects run in the same commit, so the loader still saw the intact
pair and the guide loaded; only the next render lost the exam. That is why
the symptom is "a guide on screen above a dead Regenerate button" rather than
"nothing opens" — and why disabled={… || !examId} pins the button off.

Discriminator (pinned as a control test): it needs a course change to
reproduce. A rail entry whose course is already selected never trips the
effect and worked fine on main. That test passed before the fix and still
passes after — it's what proves the mechanism is the reset, not the open path.

The fix

The reset now happens at the two events that actually mean it:

  1. Course picker onChange — the event that genuinely invalidates the exam.
  2. Term switch — a re-scope of the exam list, so the exam picked under the
    old term need not exist in the new one.

The term case adjusts state during render (the StudyModePanel pattern
already in this file) rather than in an effect. That matters: an effect-time
reset lands a render late, so the loader commits one read of the old exam
under the new term first. That was the same defect's second trigger — it
reproduced on main and now has a test.

The exams effect is now purely a fetch.

One consequence worth flagging

Making Regenerate reachable on the rail path exposed a term hazard.
regenerate sent the active selector's term, while a recent entry opens
under its own term (#475 F1). On main that was unreachable — the button
was dead on the one path where the two differ — so it was latent. Enabling
the button would have made it live: regenerating a Fall guide while the
selector says Spring rebuilds against an offering the displayed guide never
came from, which is exactly the mismatch #475 F1 fixed for reads.

Regenerate now replays the term the displayed guide was loaded with.

Tests

  • Study.recentGuides.test.tsx (new, 7 cases) — written red first:
    4 failed on the Regenerate/picker assertions, the term-switch case failed
    on the stale re-read, and the same-course control passed throughout.
  • Study.semester.test.tsx — updated a comment that documented the bug as
    permanent ("Regenerate isn't drivable from the recent-guides rail").
  • e2e/study-recent-guides.spec.ts (new) — promoted regression journey.
  • db/seed_local_rich.py — seeds a cached guide so the journey can open
    the rail without generating; the study_guide agent has no function-mode
    handler, and the guide GET returns the cache hit before reaching it. The
    journey asserts Regenerate is enabled and deliberately never clicks it.

Gates

  • tsc --noEmit clean; eslint src 0 errors; vitest run 422/422.
  • backend pytest 1529 passed / 32 skipped; ruff check clean.
  • Full local e2e cycle (up → Playwright → oracles → down) in one flock.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a cached CS101 Fall 2025 midterm study guide for local study experiences.
    • Recent guides now preserve the associated course, exam, and semester when opened.
    • Regeneration uses the selected guide’s original term and keeps the Regenerate option available.
  • Bug Fixes

    • Prevented outdated exam and guide selections from carrying over when switching courses or semesters.
    • Improved failed-guide retries by preserving the original study selections.

Opening a guide from the "Recent guides" rail left Regenerate permanently
disabled. The cause is not the open path — openRecent sets courseId AND
examId together. It's the courseId-keyed exams effect, which opened with an
unconditional setExamId(""): a scope reset that cannot tell "the user
switched course" (selection now invalid) from "we just opened a specific
guide" (selection deliberate and valid).
Both effects run in the same commit, so the loader still saw the intact pair
and the guide loaded; only the NEXT render lost the exam. Hence the symptom —
a guide on screen above a dead Regenerate button — rather than "nothing
opens". It needs a course CHANGE to reproduce, which is why a rail entry for
the already-selected course always worked (pinned as a control test).
The reset now happens at the two events that mean it: the course picker's
onChange, and a term switch. The term case adjusts state during render (the
StudyModePanel pattern already in this file) rather than in an effect,
because an effect-time reset lands a render late — the loader would commit
one read of the old exam under the new term first. That was the same defect's
second trigger, and it now has a test.
Making Regenerate reachable on the rail path exposed a term hazard: it sent
the ACTIVE selector's term, while a recent entry opens under its OWN term
(#475 F1). Regenerating a Fall guide as Spring would rebuild against an
offering the displayed guide never came from. Regenerate now replays the term
the displayed guide was loaded with.
Also seeds a CACHED study guide in the rich local dataset so the e2e journey
can open the rail without generating (the study_guide agent has no
function-mode handler).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9dbc4915-c217-4ea7-ac76-e931dedc30df

📥 Commits

Reviewing files that changed from the base of the PR and between 065b325 and 1778215.

📒 Files selected for processing (2)
  • frontend/src/components/screens/Study.recentGuides.test.tsx
  • frontend/src/components/screens/Study.tsx
📝 Walkthrough

Walkthrough

The PR seeds a cached CS101 midterm guide and updates Study to preserve the guide’s course, exam, and semester context. Course and semester changes clear stale state. Unit and Playwright tests cover recent-guide navigation and regeneration.

Changes

Study guide context

Layer / File(s)Summary
Seed cached study guides
backend/db/seed_local_rich.py
Adds idempotent CS101 midterm guide data and runs the study-guide seed step.
Preserve guide course, exam, and semester context
frontend/src/components/screens/Study.tsx
Stores the resolved semester, reuses the original context for retries and regeneration, and clears stale guide state when course or semester changes.
Validate recent-guide navigation
frontend/src/components/screens/Study.recentGuides.test.tsx, frontend/src/components/screens/Study.semester.test.tsx, frontend/e2e/study-recent-guides.spec.ts
Tests recent-guide opening, preserved selections, term-aware regeneration, stale-selection resets, and the enabled Regenerate action.

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

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the main fix: preserving exam selection when opening a recent study guide.
Description check✅ PassedThe description explains the cause, fix, related issue, testing, and review notes, although it does not follow the template headings exactly.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck 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 fix/476-recent-guide-exam-selection

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging1778215Commit Preview URL

Branch Preview URL
Jul 31 2026, 04:18 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/components/screens/Study.tsx (1)

288-329: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard loadGuide and regenerate against stale async responses.

Neither loadGuide nor regenerate checks whether the selection is still current when their request resolves. If a user switches term or course while a getStudyGuide/regenerateStudyGuide call is in flight, clearSelection() clears examId/guide during render, but the pending .then still runs setGuide(r.guide) and setLoadedTerm(...) unconditionally afterward. That resurrects a guide (and its term) the user just navigated away from, under a course/term combination that no longer matches the pickers. This directly undercuts the invariant this PR adds ("switching term drops the selection instead of reloading it").

Add a staleness check before applying the response, for example by comparing the captured cid/eid against refs updated whenever courseId/examId change, and bailing out if they no longer match.

🔧 Proposed staleness guard
+ const courseIdRef = React.useRef(courseId);+ const examIdRef = React.useRef(examId);+ React.useEffect(() => { courseIdRef.current = courseId; }, [courseId]);+ React.useEffect(() => { examIdRef.current = examId; }, [examId]);+
const loadGuide = React.useCallback(async (cid: string, eid: string, termOverride?: string) => {
if (!userId) return;
const term = termOverride !== undefined ? (termOverride || undefined) : (semester || undefined);
setLoadingGuide(true);
setGuideProblem(null);
try {
const r = await getStudyGuide(userId, cid, eid, term);
+ // Bail if the selection changed while the request was in flight — a+ // cleared or reselected exam should not be resurrected by a late response.+ if (cid !== courseIdRef.current || eid !== examIdRef.current) return;
setGuide(r.guide);

Apply the same cid/eid check in regenerate before setGuide(r.guide).

Also applies to: 351-362

🤖 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 `@frontend/src/components/screens/Study.tsx` around lines 288 - 329, Guard the
async response handling in loadGuide and regenerate with the current
courseId/examId selection, using refs updated whenever those values change.
Before applying guide data or related state such as loadedTerm, return without
updates when the captured cid/eid no longer match the current selection, while
preserving normal updates for the active request.
🧹 Nitpick comments (1)
frontend/e2e/study-recent-guides.spec.ts (1)

22-27: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a stable testid for CustomSelect triggers.

CustomSelect has no data-testid prop and its trigger button is selector-only, so this regression journey relies on aria-haspopup="listbox". Use a documented route, such as giving CustomSelect a testid prop and wiring the Study course/exam pickers into it, before relying on ARIA markup here.

🤖 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 `@frontend/e2e/study-recent-guides.spec.ts` around lines 22 - 27, Add a
documented testid prop to the CustomSelect trigger and pass distinct stable test
IDs to the Study course and exam pickers. Update the examPicker locator in the
regression test to use the exam picker test ID instead of relying on
aria-haspopup and DOM order.

Source: Path instructions

🤖 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 `@backend/db/seed_local_rich.py`:
- Around line 585-620: Update seed_study_guides to encrypt each guide’s content
before passing it to h.insert_if_absent, using the existing encrypt_json or
encrypt_if_present helper and matching the encryption behavior in the
study_guide route. Keep the stored study_guides row structure and
cross-references unchanged.
---
Outside diff comments:
In `@frontend/src/components/screens/Study.tsx`:
- Around line 288-329: Guard the async response handling in loadGuide and
regenerate with the current courseId/examId selection, using refs updated
whenever those values change. Before applying guide data or related state such
as loadedTerm, return without updates when the captured cid/eid no longer match
the current selection, while preserving normal updates for the active request.
---
Nitpick comments:
In `@frontend/e2e/study-recent-guides.spec.ts`:
- Around line 22-27: Add a documented testid prop to the CustomSelect trigger
and pass distinct stable test IDs to the Study course and exam pickers. Update
the examPicker locator in the regression test to use the exam picker test ID
instead of relying on aria-haspopup and DOM order.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 24187e41-dbab-4240-a235-8efe5889d8af

📥 Commits

Reviewing files that changed from the base of the PR and between 4236c32 and 065b325.

📒 Files selected for processing (5)
  • backend/db/seed_local_rich.py
  • frontend/e2e/study-recent-guides.spec.ts
  • frontend/src/components/screens/Study.recentGuides.test.tsx
  • frontend/src/components/screens/Study.semester.test.tsx
  • frontend/src/components/screens/Study.tsx

Comment on lines +585 to +620
_STUDY_GUIDES = [
("rich-guide-cs-f25-mid", USER_ACTIVE, OFF_CS_F25, "rich-asg-cs-f25-mid",
"2026-03-01T12:00:00Z",
{
"exam": "Midterm Exam",
"due_date": "2025-10-15",
"overview": "Covers variables, control flow, and functions.",
"topics": [
{
"name": "Variables",
"importance": "Every later topic builds on binding names to values.",
"concepts": ["Assignment", "Scope"],
},
{
"name": "Recursion",
"importance": "The midterm's hardest questions are recursive traces.",
"concepts": ["Base case", "Call stack"],
},
],
}),
]


def seed_study_guides() -> None:
for guide_id, user_id, off_id, exam_id, generated_at, content in _STUDY_GUIDES:
h.insert_if_absent(
"study_guides",
guide_id,
{
"user_id": user_id,
"offering_id": off_id,
"exam_id": exam_id,
"generated_at": generated_at,
"content": content,
},
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"--- assignment id/title match ---"
rg -n "rich-asg-cs-f25-mid" backend/db/seed_local_rich.py
rg -n "_ASSIGNMENTS = \[" -A 40 backend/db/seed_local_rich.py | rg -n "rich-asg-cs-f25-mid|Midterm"echo"--- encryption precedent ---"
rg -n "encrypt_if_present" backend/db/seed_local_rich.py
rg -n "study_guides" backend -g '*.py' -g '!backend/db/seed_local_rich.py'

Repository: SaplingLearn/Sapling

Length of output: 2909


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"--- seed_local_rich study_guide section ---"
sed -n '560,635p' backend/db/seed_local_rich.py
echoecho"--- assignments section around exam id ---"
sed -n '340,385p' backend/db/seed_local_rich.py
echoecho"--- generate/insert study guide path ---"
sed -n '160,220p' backend/routes/study_guide.py
sed -n '300,370p' backend/routes/study_guide.py
echoecho"--- frontend e2e references ---"if [ -f frontend/e2e/study-recent-guides.spec.ts ];then
rg -n "Midterm Exam|study guide|guides|rich-guide-cs-f25-mid|Midterm" frontend/e2e/study-recent-guides.spec.ts
elseecho"frontend/e2e/study-recent-guides.spec.ts not found"fiechoecho"--- schema/table model for study_guides if present ---"
rg -n "study_guides|content.*study" backend/db backend/services backend/tests backend/routes -g '*.py'| head -200

Repository: SaplingLearn/Sapling

Length of output: 13088


Encrypt the generated study_guides.content before inserting it.

"rich-asg-cs-f25-mid" exists and matches "Midterm Exam" in _ASSIGNMENTS, so the cross-reference is correct. For writes, backend/routes/study_guide.py inserts content = result.output.model_dump() directly into study_guides; route that value through encrypt_json/encrypt_if_present before table("study_guides").insert(row).

🤖 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 `@backend/db/seed_local_rich.py` around lines 585 - 620, Update
seed_study_guides to encrypt each guide’s content before passing it to
h.insert_if_absent, using the existing encrypt_json or encrypt_if_present helper
and matching the encryption behavior in the study_guide route. Keep the stored
study_guides row structure and cross-references unchanged.

Source: Coding guidelines

Code review caught a regression this PR introduced. Moving the reset out of
the courseId-keyed effect and onto the picker's onChange dropped a guard the
effect had for free: setCourseId(sameValue) bails out, so the effect never
re-ran. CustomSelect.commit() fires onChange for the already-selected option
too, so re-confirming the course you were already on wiped the guide you were
reading — the same class of bug as #476 itself.
Guard selectCourse on an actual change, and pin it with a test.
Also records the known cross-term edge the fix leaves standing: the exam
OPTIONS follow the active selector by #475's design, so a rail entry opened
under a different term is absent from that list and the picker shows its
placeholder while the guide and Regenerate are live and correctly aimed at
loadedTerm. Squaring it means tracking "the term I'm viewing" across list and
loads, which is a #475 change rather than part of this fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 blocking issue, now fixed in 1778215, plus two notes recorded rather than fixed.

  1. Re-picking the already-selected course wiped the loaded guide — a regression this PR introduced (bug due to CustomSelect.commit() calling onChange(opt.value) unconditionally, with no equality check against the current value).

Moving the reset out of the courseId-keyed effect onto the picker's onChange dropped a guard the effect had for free: setCourseId(sameValue) bails out, so the effect never re-ran. On a plain handler it has to be explicit. Reopening the course dropdown and clicking the course you were already on cleared examId/guide — the same class of defect as #476 itself.

constselectCourse=(next: string)=>{
setCourseId(next);
clearSelection();
};

Fixed by guarding on an actual change, with a regression test (re-picking the course already selected leaves the loaded guide alone) that was watched fail first:

// Guarded on an actual CHANGE. CustomSelect fires onChange for the
// already-selected option too, and the effect this reset came out of got that
// for free — setCourseId(same) bails out, so the effect never re-ran. On a
// plain handler it has to be explicit, or re-confirming the course you are
// already on wipes the guide you are reading.
constselectCourse=(next: string)=>{
if(next===courseId)return;
setCourseId(next);
clearSelection();
};

Notes, not blocking:

  1. Cross-term rail open leaves the exam picker on its placeholder. The exam OPTIONS follow the active selector, so an entry opened under a different term isn't in that list and CustomSelect falls back to the placeholder — while the guide and Regenerate are live and correctly aimed at loadedTerm. This is not a regression (pre-fix that case was strictly worse: Regenerate was dead too), and listing the entry's term instead would offer exams the next picker-driven load would 404 on, since feat(study): semester-scope the study-tool reads (#141 reframe) #475 pins only the recent-open's own load. Squaring it means tracking "the term I'm viewing" across list and loads — a feat(study): semester-scope the study-tool reads (#141 reframe) #475 change. Recorded in a comment at the effect.

// The OPTIONS follow the active selector, deliberately: #475 pins only the
// recent-open's own load to the entry's term, and later picker-driven loads
// go back to the selector — so listing the entry's term here would offer
// exams that the next load would then 404 on.
// Known edge that leaves: open a rail entry whose term ISN'T the active one
// and its exam is absent from this list, so CustomSelect falls back to the
// placeholder while the guide and Regenerate are live and correctly aimed at
// `loadedTerm`. Squaring that needs the screen to track "the term I'm
// viewing" as one thing across list + loads — a #475 change, not this one.
React.useEffect(()=>{
setExams([]);
if(!courseId||!userId||!semesterReady)return;
setLoadingExams(true);
getStudyGuideExams(userId,courseId,semester||undefined)
.then(r=>setExams(r.exams||[]))

  1. loadGuide has no stale-response guard, so two rail entries clicked in quick succession can resolve out of order. Pre-existing; this PR adds loadedTerm to the state that can go stale. Worth a follow-up, out of scope here.

Also checked and cleared: a review pass flagged a captured Playwright failure of this PR's own journey as evidence the fix doesn't work. That artifact is from a deliberate pre-fix run — e2e/results/last-run.json recorded argv of playwright test e2e/study-recent-guides.spec.ts, the single-spec invocation used to verify the journey goes red against pre-fix Study.tsx. Gitignored, so it was never part of this PR; deleted to stop it misleading future readers.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@AndresL230
AndresL230 merged commit bbe4eb5 into mainJul 31, 2026
7 checks passed
@AndresL230
AndresL230 deleted the fix/476-recent-guide-exam-selection branch August 2, 2026 18:30
Sign up for freeto 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

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(study): keep the exam selected when opening a recent guide (#476) - #499

Merged
AndresL230 merged 2 commits into
mainfrom
fix/476-recent-guide-exam-selection
Jul 31, 2026
Merged

fix(study): keep the exam selected when opening a recent guide (#476)#499
AndresL230 merged 2 commits into
mainfrom
fix/476-recent-guide-exam-selection

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Part of #476.

The issue body misattributes the cause

"the recent-guides open path clears the selected exam"

It doesn't. openRecent (Study.tsx) sets both ids:

recentTermRef.current=entry.semester??"";setCourseId(entry.course_id);setExamId(entry.exam_id);

The clearing comes from the courseId-keyed exams effect, whose first
statement was an unconditional setExamId(""). That reset cannot tell
"the user switched course" (selection now invalid) from "we just opened a
specific guide"
(selection deliberate and valid) — it only sees that
courseId differs from its last value, which openRecent also causes.

Both effects run in the same commit, so the loader still saw the intact
pair and the guide loaded; only the next render lost the exam. That is why
the symptom is "a guide on screen above a dead Regenerate button" rather than
"nothing opens" — and why disabled={… || !examId} pins the button off.

Discriminator (pinned as a control test): it needs a course change to
reproduce. A rail entry whose course is already selected never trips the
effect and worked fine on main. That test passed before the fix and still
passes after — it's what proves the mechanism is the reset, not the open path.

The fix

The reset now happens at the two events that actually mean it:

  1. Course picker onChange — the event that genuinely invalidates the exam.
  2. Term switch — a re-scope of the exam list, so the exam picked under the
    old term need not exist in the new one.

The term case adjusts state during render (the StudyModePanel pattern
already in this file) rather than in an effect. That matters: an effect-time
reset lands a render late, so the loader commits one read of the old exam
under the new term first. That was the same defect's second trigger — it
reproduced on main and now has a test.

The exams effect is now purely a fetch.

One consequence worth flagging

Making Regenerate reachable on the rail path exposed a term hazard.
regenerate sent the active selector's term, while a recent entry opens
under its own term (#475 F1). On main that was unreachable — the button
was dead on the one path where the two differ — so it was latent. Enabling
the button would have made it live: regenerating a Fall guide while the
selector says Spring rebuilds against an offering the displayed guide never
came from, which is exactly the mismatch #475 F1 fixed for reads.

Regenerate now replays the term the displayed guide was loaded with.

Tests

  • Study.recentGuides.test.tsx (new, 7 cases) — written red first:
    4 failed on the Regenerate/picker assertions, the term-switch case failed
    on the stale re-read, and the same-course control passed throughout.
  • Study.semester.test.tsx — updated a comment that documented the bug as
    permanent ("Regenerate isn't drivable from the recent-guides rail").
  • e2e/study-recent-guides.spec.ts (new) — promoted regression journey.
  • db/seed_local_rich.py — seeds a cached guide so the journey can open
    the rail without generating; the study_guide agent has no function-mode
    handler, and the guide GET returns the cache hit before reaching it. The
    journey asserts Regenerate is enabled and deliberately never clicks it.

Gates

  • tsc --noEmit clean; eslint src 0 errors; vitest run 422/422.
  • backend pytest 1529 passed / 32 skipped; ruff check clean.
  • Full local e2e cycle (up → Playwright → oracles → down) in one flock.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a cached CS101 Fall 2025 midterm study guide for local study experiences.
    • Recent guides now preserve the associated course, exam, and semester when opened.
    • Regeneration uses the selected guide’s original term and keeps the Regenerate option available.
  • Bug Fixes

    • Prevented outdated exam and guide selections from carrying over when switching courses or semesters.
    • Improved failed-guide retries by preserving the original study selections.

Opening a guide from the "Recent guides" rail left Regenerate permanently
disabled. The cause is not the open path — openRecent sets courseId AND
examId together. It's the courseId-keyed exams effect, which opened with an
unconditional setExamId(""): a scope reset that cannot tell "the user
switched course" (selection now invalid) from "we just opened a specific
guide" (selection deliberate and valid).
Both effects run in the same commit, so the loader still saw the intact pair
and the guide loaded; only the NEXT render lost the exam. Hence the symptom —
a guide on screen above a dead Regenerate button — rather than "nothing
opens". It needs a course CHANGE to reproduce, which is why a rail entry for
the already-selected course always worked (pinned as a control test).
The reset now happens at the two events that mean it: the course picker's
onChange, and a term switch. The term case adjusts state during render (the
StudyModePanel pattern already in this file) rather than in an effect,
because an effect-time reset lands a render late — the loader would commit
one read of the old exam under the new term first. That was the same defect's
second trigger, and it now has a test.
Making Regenerate reachable on the rail path exposed a term hazard: it sent
the ACTIVE selector's term, while a recent entry opens under its OWN term
(#475 F1). Regenerating a Fall guide as Spring would rebuild against an
offering the displayed guide never came from. Regenerate now replays the term
the displayed guide was loaded with.
Also seeds a CACHED study guide in the rich local dataset so the e2e journey
can open the rail without generating (the study_guide agent has no
function-mode handler).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9dbc4915-c217-4ea7-ac76-e931dedc30df

📥 Commits

Reviewing files that changed from the base of the PR and between 065b325 and 1778215.

📒 Files selected for processing (2)
  • frontend/src/components/screens/Study.recentGuides.test.tsx
  • frontend/src/components/screens/Study.tsx
📝 Walkthrough

Walkthrough

The PR seeds a cached CS101 midterm guide and updates Study to preserve the guide’s course, exam, and semester context. Course and semester changes clear stale state. Unit and Playwright tests cover recent-guide navigation and regeneration.

Changes

Study guide context

Layer / File(s)Summary
Seed cached study guides
backend/db/seed_local_rich.py
Adds idempotent CS101 midterm guide data and runs the study-guide seed step.
Preserve guide course, exam, and semester context
frontend/src/components/screens/Study.tsx
Stores the resolved semester, reuses the original context for retries and regeneration, and clears stale guide state when course or semester changes.
Validate recent-guide navigation
frontend/src/components/screens/Study.recentGuides.test.tsx, frontend/src/components/screens/Study.semester.test.tsx, frontend/e2e/study-recent-guides.spec.ts
Tests recent-guide opening, preserved selections, term-aware regeneration, stale-selection resets, and the enabled Regenerate action.

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

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the main fix: preserving exam selection when opening a recent study guide.
Description check✅ PassedThe description explains the cause, fix, related issue, testing, and review notes, although it does not follow the template headings exactly.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck 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 fix/476-recent-guide-exam-selection

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging1778215Commit Preview URL

Branch Preview URL
Jul 31 2026, 04:18 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/components/screens/Study.tsx (1)

288-329: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard loadGuide and regenerate against stale async responses.

Neither loadGuide nor regenerate checks whether the selection is still current when their request resolves. If a user switches term or course while a getStudyGuide/regenerateStudyGuide call is in flight, clearSelection() clears examId/guide during render, but the pending .then still runs setGuide(r.guide) and setLoadedTerm(...) unconditionally afterward. That resurrects a guide (and its term) the user just navigated away from, under a course/term combination that no longer matches the pickers. This directly undercuts the invariant this PR adds ("switching term drops the selection instead of reloading it").

Add a staleness check before applying the response, for example by comparing the captured cid/eid against refs updated whenever courseId/examId change, and bailing out if they no longer match.

🔧 Proposed staleness guard
+ const courseIdRef = React.useRef(courseId);+ const examIdRef = React.useRef(examId);+ React.useEffect(() => { courseIdRef.current = courseId; }, [courseId]);+ React.useEffect(() => { examIdRef.current = examId; }, [examId]);+
const loadGuide = React.useCallback(async (cid: string, eid: string, termOverride?: string) => {
if (!userId) return;
const term = termOverride !== undefined ? (termOverride || undefined) : (semester || undefined);
setLoadingGuide(true);
setGuideProblem(null);
try {
const r = await getStudyGuide(userId, cid, eid, term);
+ // Bail if the selection changed while the request was in flight — a+ // cleared or reselected exam should not be resurrected by a late response.+ if (cid !== courseIdRef.current || eid !== examIdRef.current) return;
setGuide(r.guide);

Apply the same cid/eid check in regenerate before setGuide(r.guide).

Also applies to: 351-362

🤖 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 `@frontend/src/components/screens/Study.tsx` around lines 288 - 329, Guard the
async response handling in loadGuide and regenerate with the current
courseId/examId selection, using refs updated whenever those values change.
Before applying guide data or related state such as loadedTerm, return without
updates when the captured cid/eid no longer match the current selection, while
preserving normal updates for the active request.
🧹 Nitpick comments (1)
frontend/e2e/study-recent-guides.spec.ts (1)

22-27: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a stable testid for CustomSelect triggers.

CustomSelect has no data-testid prop and its trigger button is selector-only, so this regression journey relies on aria-haspopup="listbox". Use a documented route, such as giving CustomSelect a testid prop and wiring the Study course/exam pickers into it, before relying on ARIA markup here.

🤖 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 `@frontend/e2e/study-recent-guides.spec.ts` around lines 22 - 27, Add a
documented testid prop to the CustomSelect trigger and pass distinct stable test
IDs to the Study course and exam pickers. Update the examPicker locator in the
regression test to use the exam picker test ID instead of relying on
aria-haspopup and DOM order.

Source: Path instructions

🤖 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 `@backend/db/seed_local_rich.py`:
- Around line 585-620: Update seed_study_guides to encrypt each guide’s content
before passing it to h.insert_if_absent, using the existing encrypt_json or
encrypt_if_present helper and matching the encryption behavior in the
study_guide route. Keep the stored study_guides row structure and
cross-references unchanged.
---
Outside diff comments:
In `@frontend/src/components/screens/Study.tsx`:
- Around line 288-329: Guard the async response handling in loadGuide and
regenerate with the current courseId/examId selection, using refs updated
whenever those values change. Before applying guide data or related state such
as loadedTerm, return without updates when the captured cid/eid no longer match
the current selection, while preserving normal updates for the active request.
---
Nitpick comments:
In `@frontend/e2e/study-recent-guides.spec.ts`:
- Around line 22-27: Add a documented testid prop to the CustomSelect trigger
and pass distinct stable test IDs to the Study course and exam pickers. Update
the examPicker locator in the regression test to use the exam picker test ID
instead of relying on aria-haspopup and DOM order.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 24187e41-dbab-4240-a235-8efe5889d8af

📥 Commits

Reviewing files that changed from the base of the PR and between 4236c32 and 065b325.

📒 Files selected for processing (5)
  • backend/db/seed_local_rich.py
  • frontend/e2e/study-recent-guides.spec.ts
  • frontend/src/components/screens/Study.recentGuides.test.tsx
  • frontend/src/components/screens/Study.semester.test.tsx
  • frontend/src/components/screens/Study.tsx

Comment on lines +585 to +620
_STUDY_GUIDES = [
("rich-guide-cs-f25-mid", USER_ACTIVE, OFF_CS_F25, "rich-asg-cs-f25-mid",
"2026-03-01T12:00:00Z",
{
"exam": "Midterm Exam",
"due_date": "2025-10-15",
"overview": "Covers variables, control flow, and functions.",
"topics": [
{
"name": "Variables",
"importance": "Every later topic builds on binding names to values.",
"concepts": ["Assignment", "Scope"],
},
{
"name": "Recursion",
"importance": "The midterm's hardest questions are recursive traces.",
"concepts": ["Base case", "Call stack"],
},
],
}),
]


def seed_study_guides() -> None:
for guide_id, user_id, off_id, exam_id, generated_at, content in _STUDY_GUIDES:
h.insert_if_absent(
"study_guides",
guide_id,
{
"user_id": user_id,
"offering_id": off_id,
"exam_id": exam_id,
"generated_at": generated_at,
"content": content,
},
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"--- assignment id/title match ---"
rg -n "rich-asg-cs-f25-mid" backend/db/seed_local_rich.py
rg -n "_ASSIGNMENTS = \[" -A 40 backend/db/seed_local_rich.py | rg -n "rich-asg-cs-f25-mid|Midterm"echo"--- encryption precedent ---"
rg -n "encrypt_if_present" backend/db/seed_local_rich.py
rg -n "study_guides" backend -g '*.py' -g '!backend/db/seed_local_rich.py'

Repository: SaplingLearn/Sapling

Length of output: 2909


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"--- seed_local_rich study_guide section ---"
sed -n '560,635p' backend/db/seed_local_rich.py
echoecho"--- assignments section around exam id ---"
sed -n '340,385p' backend/db/seed_local_rich.py
echoecho"--- generate/insert study guide path ---"
sed -n '160,220p' backend/routes/study_guide.py
sed -n '300,370p' backend/routes/study_guide.py
echoecho"--- frontend e2e references ---"if [ -f frontend/e2e/study-recent-guides.spec.ts ];then
rg -n "Midterm Exam|study guide|guides|rich-guide-cs-f25-mid|Midterm" frontend/e2e/study-recent-guides.spec.ts
elseecho"frontend/e2e/study-recent-guides.spec.ts not found"fiechoecho"--- schema/table model for study_guides if present ---"
rg -n "study_guides|content.*study" backend/db backend/services backend/tests backend/routes -g '*.py'| head -200

Repository: SaplingLearn/Sapling

Length of output: 13088


Encrypt the generated study_guides.content before inserting it.

"rich-asg-cs-f25-mid" exists and matches "Midterm Exam" in _ASSIGNMENTS, so the cross-reference is correct. For writes, backend/routes/study_guide.py inserts content = result.output.model_dump() directly into study_guides; route that value through encrypt_json/encrypt_if_present before table("study_guides").insert(row).

🤖 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 `@backend/db/seed_local_rich.py` around lines 585 - 620, Update
seed_study_guides to encrypt each guide’s content before passing it to
h.insert_if_absent, using the existing encrypt_json or encrypt_if_present helper
and matching the encryption behavior in the study_guide route. Keep the stored
study_guides row structure and cross-references unchanged.

Source: Coding guidelines

Code review caught a regression this PR introduced. Moving the reset out of
the courseId-keyed effect and onto the picker's onChange dropped a guard the
effect had for free: setCourseId(sameValue) bails out, so the effect never
re-ran. CustomSelect.commit() fires onChange for the already-selected option
too, so re-confirming the course you were already on wiped the guide you were
reading — the same class of bug as #476 itself.
Guard selectCourse on an actual change, and pin it with a test.
Also records the known cross-term edge the fix leaves standing: the exam
OPTIONS follow the active selector by #475's design, so a rail entry opened
under a different term is absent from that list and the picker shows its
placeholder while the guide and Regenerate are live and correctly aimed at
loadedTerm. Squaring it means tracking "the term I'm viewing" across list and
loads, which is a #475 change rather than part of this fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 blocking issue, now fixed in 1778215, plus two notes recorded rather than fixed.

  1. Re-picking the already-selected course wiped the loaded guide — a regression this PR introduced (bug due to CustomSelect.commit() calling onChange(opt.value) unconditionally, with no equality check against the current value).

Moving the reset out of the courseId-keyed effect onto the picker's onChange dropped a guard the effect had for free: setCourseId(sameValue) bails out, so the effect never re-ran. On a plain handler it has to be explicit. Reopening the course dropdown and clicking the course you were already on cleared examId/guide — the same class of defect as #476 itself.

constselectCourse=(next: string)=>{
setCourseId(next);
clearSelection();
};

Fixed by guarding on an actual change, with a regression test (re-picking the course already selected leaves the loaded guide alone) that was watched fail first:

// Guarded on an actual CHANGE. CustomSelect fires onChange for the
// already-selected option too, and the effect this reset came out of got that
// for free — setCourseId(same) bails out, so the effect never re-ran. On a
// plain handler it has to be explicit, or re-confirming the course you are
// already on wipes the guide you are reading.
constselectCourse=(next: string)=>{
if(next===courseId)return;
setCourseId(next);
clearSelection();
};

Notes, not blocking:

  1. Cross-term rail open leaves the exam picker on its placeholder. The exam OPTIONS follow the active selector, so an entry opened under a different term isn't in that list and CustomSelect falls back to the placeholder — while the guide and Regenerate are live and correctly aimed at loadedTerm. This is not a regression (pre-fix that case was strictly worse: Regenerate was dead too), and listing the entry's term instead would offer exams the next picker-driven load would 404 on, since feat(study): semester-scope the study-tool reads (#141 reframe) #475 pins only the recent-open's own load. Squaring it means tracking "the term I'm viewing" across list and loads — a feat(study): semester-scope the study-tool reads (#141 reframe) #475 change. Recorded in a comment at the effect.

// The OPTIONS follow the active selector, deliberately: #475 pins only the
// recent-open's own load to the entry's term, and later picker-driven loads
// go back to the selector — so listing the entry's term here would offer
// exams that the next load would then 404 on.
// Known edge that leaves: open a rail entry whose term ISN'T the active one
// and its exam is absent from this list, so CustomSelect falls back to the
// placeholder while the guide and Regenerate are live and correctly aimed at
// `loadedTerm`. Squaring that needs the screen to track "the term I'm
// viewing" as one thing across list + loads — a #475 change, not this one.
React.useEffect(()=>{
setExams([]);
if(!courseId||!userId||!semesterReady)return;
setLoadingExams(true);
getStudyGuideExams(userId,courseId,semester||undefined)
.then(r=>setExams(r.exams||[]))

  1. loadGuide has no stale-response guard, so two rail entries clicked in quick succession can resolve out of order. Pre-existing; this PR adds loadedTerm to the state that can go stale. Worth a follow-up, out of scope here.

Also checked and cleared: a review pass flagged a captured Playwright failure of this PR's own journey as evidence the fix doesn't work. That artifact is from a deliberate pre-fix run — e2e/results/last-run.json recorded argv of playwright test e2e/study-recent-guides.spec.ts, the single-spec invocation used to verify the journey goes red against pre-fix Study.tsx. Gitignored, so it was never part of this PR; deleted to stop it misleading future readers.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@AndresL230
AndresL230 merged commit bbe4eb5 into mainJul 31, 2026
7 checks passed
@AndresL230
AndresL230 deleted the fix/476-recent-guide-exam-selection branch August 2, 2026 18:30
Sign up for freeto 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

@AndresL230