Skip to content

fix(learn): add a concept back after deleting it, and stop a stale backend answering - #536

Open
Darkest-Teddy wants to merge 7 commits into
mainfrom
fix/learn-add-concept-and-port-guard
Open

fix(learn): add a concept back after deleting it, and stop a stale backend answering#536
Darkest-Teddy wants to merge 7 commits into
mainfrom
fix/learn-add-concept-and-port-guard

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Split out of #534, which was carrying these three alongside unrelated quiz
work. Based on main; no dependency on #533/#534.

Reported as: "when deleting a concept node, i should be able to add it back
of the same name by add concept."

The reported bug was a stale server, not the graph

POST /api/graph/{user_id}/nodes returned 404 {"detail":"Not Found"} in
the browser while returning 200 through TestClient in the worktree. A backend
left running from a different working tree — older code, no create_node
was still answering :5000.

Windows lets a second process bind a port another process is already
listening on, and the first binder keeps the traffic. Restarts bound a
second socket, logged "Application startup complete", passed /api/health,
and served nobody. Get-NetTCPConnection listed two owning PIDs; the stale
parent looked dead in Win32_Process while its uvicorn reload child held
the socket, so killing the parent alone did nothing.

python main.py now probes the port first and exits with the kill command.
Dev entrypoint only — containers and Railway import main:app and never
reach it.

The tell, for next time: if a route you can read in routes/ is missing from
curl -s localhost:5000/openapi.json, you are talking to a different build.

Two real Learn bugs found on the way

Add-concept silently no-oped after a delete. The composer resolved its
course as topicNode?.course_id || selectedCourseId || null. The picker is
"Course (optional)" and starts at "", so for anyone who hasn't chosen one
the course id came entirely from the focused node — and deleting a concept
clears the focus. if (!label || !cardCourseId) return then bailed before
doing anything: no request, no toast, no rollback. resolveAddConceptCourseId
falls back to the last course that did resolve, and says so when there has
genuinely never been one. Exported, like resolveCardCourseId, so the test
exercises the resolution addConcept actually calls rather than a mirror.

Failures were unreadable..catch(() => toast.error("Couldn't save the concept — it was removed")) discarded the ApiError, giving a 401, a 404, a
500 and an unreachable backend one identical sentence. Surfacing the status
and body is what produced the 404 + request_id that ended the hunt. The
delete path had the same problem and worse: .catch(() => {}) let the node
vanish from the map while the row survived, so re-adding the same name
quietly merged into the undeleted row.

Verification

  • backend suite green on this branch; ruff check clean
  • port guard tested both ways (occupied → SystemExit, free → returns)
  • tsc --noEmit clean on the identical Learn.tsx
  • vitest cannot start on the local Node 20 (rolldown styleText crash), so
    the new specs are CI-verified only

The backend was never at fault: add → delete → re-add of the same name
creates a fresh row every time — verified against the real project.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved concept creation by retaining the previously selected course when focus is cleared.
    • Added clearer error messages when saving concepts or resolving a course fails.
    • Failed concept deletions now restore the affected content and connections when appropriate.
    • Added safeguards to prevent starting the development server when its configured port is already in use.
  • Tests
    • Added coverage for course resolution after concepts are deleted and re-added.

Darkest-Teddyand others added 3 commits August 12, 2026 10:43
The backend was never the obstacle: add -> delete -> add of the same name
creates a fresh row every time. graph_nodes has no soft delete, delete_node
hard-deletes the row and its edges, and the 0023 UNIQUE has nothing left to
collide with. Verified against the real project.
The break is in the rail's add-concept composer:
cardCourseId = topicNode?.course_id || selectedCourseId || null
...
if (!label || !cardCourseId) return;
The course picker is "Course (optional)" and starts at "" ("No course"), so
unless the user arrived with ?course= or picked one, cardCourseId comes
ENTIRELY from the focused node. Deleting a concept clears the focus — so the
delete removes the one thing supplying the course id, and the add then
returns before doing anything: no request, no toast, no rollback, the
composer just sitting there with the name typed in.
addConcept now resolves through resolveAddConceptCourseId, which falls back
to the last course that did resolve, and says so when there has genuinely
never been one instead of no-op'ing. Exported, like resolveCardCourseId, so
the test exercises the resolution addConcept actually calls rather than a
mirror of it.
Also stops removeConcept swallowing its own failures. `.catch(() => {})` is
not best-effort, it's a lie: the node vanishes from the map while the row
survives, so re-adding the same name quietly MERGES into the undeleted row
and toasts "Merged into your existing X" for a concept the user watched
themselves delete. It now restores the node and says the delete failed.
Verified with tsc --noEmit. vitest cannot start on the local Node 20
(rolldown's styleText crash), so the new specs are CI-verified only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The toast said "Couldn't save the concept — it was removed" for a 401, a
500, a 422 and an unreachable backend alike. Four different problems, four
different fixes, none of them guessable from the message — the catch
discarded the ApiError it was handed.
Chasing a real report of this cost several rounds of inference that the
status code would have ended immediately: the route itself returns 200 and
creates the row when called with the ids the client actually holds, so
whatever the browser hit is above add_node, and the toast was the only
witness. It now carries the status and the start of the body (the 500 path
includes a request_id that ties it to a log), and both handlers log the
full error to the console.
Not instrumentation to be removed later: an error message that names a
cause is what these two toasts should always have said.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The add-concept 404 was never a graph bug. A backend left running from the
MAIN working tree — older code, no create_node — was still answering :5000,
so POST /api/graph/{user_id}/nodes 404'd in the browser while returning 200
through TestClient in this worktree.
Windows lets a second process bind a port another process is already
listening on, and the FIRST binder keeps the traffic. Both restarts during
this session bound a second socket, logged "Application startup complete",
passed /api/health, and served nobody. Get-NetTCPConnection listed two
owning PIDs; the stale parent looked dead in Win32_Process while its uvicorn
reload child held the socket, so killing the parent alone did nothing.
`python main.py` now probes the port first and exits with the kill command
instead of starting a server that cannot receive a request. Dev entrypoint
only — containers and Railway import main:app and never reach it.
The tell, for next time: if a route you can read in routes/ is missing from
curl -s localhost:5000/openapi.json
you are talking to a different build, not looking at a routing bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 12, 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 Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:8 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: dcf757ba-3e52-4f90-b96f-af7d357a7767

📥 Commits

Reviewing files that changed from the base of the PR and between 76c7538 and b5c7bc9.

📒 Files selected for processing (5)
  • backend/main.py
  • backend/tests/test_port_guard.py
  • frontend/src/components/screens/Learn.addConcept.test.tsx
  • frontend/src/components/screens/Learn.graph.test.ts
  • frontend/src/components/screens/Learn.tsx
📝 Walkthrough

Walkthrough

The backend now checks development port occupancy before starting Uvicorn. The Learn screen retains course resolution after focus loss and improves concept save and deletion error handling.

Changes

Backend startup

Layer / File(s)Summary
Development port check
backend/main.py
The development entrypoint probes the configured localhost port and exits with platform-specific termination instructions when another server responds. Imported application startup remains unchanged.

Learn graph operations

Layer / File(s)Summary
Course resolution for concept creation
frontend/src/components/screens/Learn.tsx, frontend/src/components/screens/Learn.graph.test.ts
Concept creation uses the current course or the last resolved course. Tests cover fallback, current-course precedence, and the null case.
Graph error reporting and recovery
frontend/src/components/screens/Learn.tsx
Save failures include structured status or message details. Failed persisted deletions restore the concept and its edges, while temporary nodes remain removed.

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

Suggested reviewers:andresl230, jose-gael-cruz-lopez

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies both primary changes: Learn concept re-addition and stale backend process detection.
Description check✅ PassedThe description explains the bug, implementation, testing, and review context in detail, 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/learn-add-concept-and-port-guard

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 Aug 12, 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-stagingb5c7bc9Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:07 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: 2

🤖 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 `@frontend/src/components/screens/Learn.tsx`:
- Around line 1294-1295: Update the deletion error handling in Learn.tsx around
the ApiError check to include a truncated err.message alongside err.status in
the toast. Preserve the existing non-ApiError behavior and match the add failure
path’s response-detail formatting.
- Around line 1203-1206: Update Learn around resolveAddConceptCourseId and the
Add concept control so a single resolved add-course value is computed and reused
by both addConcept and the render condition, rather than gating the control
directly on cardCourseId. Preserve the fallback behavior when the picker has no
selection, and add a component test covering deletion of the focused concept
with no picker selection while confirming Add concept remains available.
🪄 Autofix

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: c58cf75a-76c6-43a2-9876-c9c6074f4f35

📥 Commits

Reviewing files that changed from the base of the PR and between 7681c48 and 76c7538.

📒 Files selected for processing (3)
  • backend/main.py
  • frontend/src/components/screens/Learn.graph.test.ts
  • frontend/src/components/screens/Learn.tsx

Comment threadfrontend/src/components/screens/Learn.tsx Outdated
Comment threadfrontend/src/components/screens/Learn.tsx Outdated
The tutor page had no quiz entry point at all — Learn.tsx did not mention
quiz anywhere. The only routes in were the nav's Quiz item, the dashboard,
the knowledge map and the notetaker, and every one of them drops the
session's context: the tutor knows exactly which concept the student is
working on, then hands them a page that asks them to pick it again.
Two buttons, one resolver:
focus card ("Quiz me") -> the rail's anchored concept
session toolbar ("Quiz me") -> the focused concept, else the session topic
`quizHref` prefers a node id, which screens/Quiz.tsx uses directly as
initialConceptId, and falls back to the topic NAME, which it resolves
against the concept list.
The placeholder guard is the part worth reading: an optimistically-added
concept carries a client-side `node-new-<ts>` id and a streamed one carries
`stream-<name>`, neither of which exists server-side. Passing either as
?concept= would preselect an id the quiz page cannot resolve — silently, as
an empty picker. Those fall through to the name, which resolves as soon as
the real row lands. Exported and tested like the other resolvers, so the
test covers what the buttons actually call.
Verified with tsc --noEmit. vitest cannot start on the local Node 20
(rolldown styleText crash), so the new specs are CI-verified only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — learn concept re-add + port guard

The diagnosis here is excellent — the "stale process on :5000" write-up is the most useful thing in the PR, and _refuse_if_port_taken is correctly scoped (dev __main__ only; backend/Dockerfile:37 runs uvicorn main:app, so containers never reach it). Replacing the delete path's .catch(() => {}) with a real restore is the right instinct. But the headline fix does not reach the reported flow: addConcept's new fallback is unreachable, because the composer that calls it is gated on the very value the fallback exists to work around. There is also a new failure mode in the delete restore (a 404 resurrects the node), a hand-rolled error formatter that duplicates lib/errorMessage.ts and prints raw JSON bodies into toasts, and ~70 lines of undocumented quiz deep-link feature in a PR whose description says it was split out to exclude quiz work.

Findings

P0

[P0] addConcept's new fallback is unreachable, and the reported bug survivesfrontend/src/components/screens/Learn.tsx:1754

{/* Add concept */}{cardCourseId&&(<divstyle={{padding: "4px 22px 24px"}}>

Both addConcept call sites live inside that block — :1763 (onKeyDown Enter) and :1780 (the Add button) — and there is no other caller in the file. So cardCourseId is always truthy when addConcept can run, which means resolveAddConceptCourseId(cardCourseId, lastCourseIdRef.current) at :1226 always returns cardCourseId. The lastCourseIdRef + effect (:474-477) and the new toast.error("Pick a course first…") branch (:1227-1230) are dead code.

Worse, the reported repro still fails. removeConcept is only reachable from the focus card — :1611 gates the button row on focusConcept, Remove is :1663-1683 — so the deleted node is always the focused one. After removal, activeFocusId = focusedNodeId ?? highlightId (:452) and highlightId is graphNodes.find(n => n.name.toLowerCase() === topic.trim().toLowerCase())?.id (:446) — the node just deleted. With selectedCourseId at its default "" (:392/:397, searchParams.get("course") ?? ""), resolveCardCourseId (:269-274) returns null and the whole "+ Add concept" affordance unmounts (block :1754-1815). The student cannot type the name back in at all, which is exactly "when deleting a concept node, i should be able to add it back of the same name by add concept".

The new specs pass because they call the exported resolver directly and never render the gate. The gate is where the fix belongs: resolve the add-course once and gate the composer on that, showing which course the concept will land in. (CodeRabbit raised the gate; this adds the proof that the fallback branch is unreachable and that the original repro is unchanged.)

P1

[P1] A 404 on delete resurrects the node and toasts a false statementfrontend/src/components/screens/Learn.tsx:1304-1318

deleteGraphNode(userId,nodeId).catch((err: unknown)=>{console.error("[removeConcept] failed",{ nodeId, err });if(!removedNode||/^(node-new-|stream-)/.test(nodeId))return;setGraphNodes(prev=>(prev.some(n=>n.id===nodeId) ? prev : [...prev,removedNode]));
...
constreason=errinstanceofApiError ? ` (${err.status})` : "";toast.error(`Couldn't delete “${removedNode.name}${reason} — it's still on your map.`);

The restore fires on any rejection, and 404 is precisely the status the endpoint returns for "the row is already gone": backend/routes/graph.py:119-125 raises HTTPException(status_code=404, …) whenever delete_node returns an error, and backend/services/graph_service.py:486-492 returns {"error": "Node not found", "deleted": False} when the owner-scoped select finds no row. So a second tab, a client-side node that is already stale, or a delete that already succeeded server-side now puts a phantom concept back on the map and tells the user "it's still on your map" when it is not. The old .catch(() => {}) produced the correct end state in exactly this case.

It compounds: delete X → 404 → user re-adds X (fresh row, new id) → the catch appends the old node object under its dead id and the map shows two "X" nodes, one of which doesn't exist. A 404 should be treated as success — nothing to restore. (The non-404 case is already handled correctly: the prev.some(n => n.id === nodeId) guard stops a double-insert when a re-add merged back into the surviving row.)

P2

[P2] Hand-rolled error copy duplicates lib/errorMessage.ts and dumps the raw response body into a toastfrontend/src/components/screens/Learn.tsx:1279-1285

constreason=errinstanceofApiError
? `${err.status}${err.message ? ` — ${err.message.slice(0,160)}` : ""}`
: errinstanceofError&&err.message
? err.message.slice(0,160)
: "the request never completed";toast.error(`Couldn't save “${label}” (${reason}).`);

ApiError.message is documented as the raw body — frontend/src/lib/api.ts:25-26: "message stays the raw body for backward compatibility" — so the toast renders Couldn't save “Markov Chains” (404 — {"detail":"Not Found"})., or 160 characters of a proxy's HTML error page. frontend/src/lib/errorMessage.ts exists for exactly this and says so in its header: "Rendering that with String(err) dumps JSON into the UI, which is what these helpers exist to prevent." Its humanizeError(err, fallback) (:156-160 — "A short sentence safe to put in a toast. Never returns … a raw JSON body") is already used across Gradebook, Calendar, Social, Study, ProfileView, the notetaker, SignInModal and QuizPanel; Learn.tsx imports nothing from it. The Engineering Style Guide's shared-primitive rule applies here the same way it does to components/ui/. It also removes the inconsistency CodeRabbit flagged — the delete toast at :1317 surfaces only the status and never the detail; both paths would read the same through one helper.

[P2] An undocumented quiz deep-link feature ships in a PR that says it excludes quiz workfrontend/src/components/screens/Learn.tsx:288-297, :1449-1457, :1640-1662

exportfunctionquizHref(conceptId: string|null|undefined,topic: string|null|undefined,): string{

quizHref plus two new "Quiz me" buttons (session toolbar :1449, focus card :1640) plus a five-case spec block (Learn.graph.test.ts:171-206) are roughly a quarter of the diff, and neither the title, the description, nor the release-note summary mentions them. The description says the opposite: "Split out of #534, which was carrying these three alongside unrelated quiz work." The code itself checks out — ?concept=/?topic= match screens/Quiz.tsx:86-94, resolveInitialSelection (lib/quizSelection.ts:71-75) degrades to {null, null} on an id it can't resolve, Icon has flask (Icon.tsx:78), .btn--sm exists (globals.css:233), and #534 carries no duplicate — but a new user-facing entry point riding along in a bugfix PR makes this hard to review and impossible to revert cleanly. Either name it in the description or move it out.

P3

[P3] A failed delete restores the node but not the focusfrontend/src/components/screens/Learn.tsx:1302

setFocusedNodeId(cur=>(cur===nodeId ? null : cur));

The catch at :1304-1318 undoes the node and edge removals but never the focus clear, so after a failed delete the concept reappears on the map while the rail's focus card is gone — and, per the P0 above, so is the Add-concept composer.

[P3] The port probe only covers IPv4 loopbackbackend/main.py:338-341

withsocket.socket(socket.AF_INET, socket.SOCK_STREAM) asprobe:
probe.settimeout(0.4)
ifprobe.connect_ex(("127.0.0.1", port)) !=0:
return

uvicorn.run(..., host="0.0.0.0") at :359 binds the IPv4 wildcard so the common case is covered, but a stale server bound to ::1 or to one specific interface passes the probe silently. Given the whole point is "tell the next person", resolving both families would close the gap.

What's good

  • The root-cause write-up in the description (two owning PIDs, the uvicorn reload child holding the socket, "if a route you can read in routes/ is missing from /openapi.json, you are talking to a different build") is worth more than the code — it belongs in CLAUDE.md or docs/local-supabase.md so it outlives the PR.
  • The guard is correctly classified and scoped: it is a dev-entrypoint TCP probe under if __name__ == "__main__", not a production origin/port check. backend/Dockerfile:37 is CMD ["uvicorn", "main:app", …] and scripts/e2e-up.sh invokes uvicorn directly, so neither containers nor the e2e lane can trip it.
  • The backend claim in the description holds: delete_node (graph_service.py:481-509) hard-deletes edges in both directions and then the row, with no soft-delete column and no unique-constraint collision on re-add — so add → delete → re-add really does create a fresh row.

Verdict: request changes — the P0 leaves the reported bug reproducible on the default path, and the P1 introduces a new map/server divergence in the opposite direction.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…p a 404 resurrecting a node
The previous pass added `resolveAddConceptCourseId` so add-concept could
survive the delete that clears the focus — and then gated the composer that
calls it on `cardCourseId`, the exact value the fallback exists to work
around. Both `addConcept` call sites lived inside that gate, so the fallback
could only ever run while `cardCourseId` was already truthy: dead code, and
the reported bug ("delete a concept, add it back under the same name")
unchanged. Deleting the focused concept with the optional course picker at
its "" default unmounted the whole "+ Add concept" affordance, so the name
could not be typed back in at all.
The gate and `addConcept` now read ONE resolved value, `addCourseId`, so
they cannot disagree again. `lastCourseId` became state rather than a ref
because the gate reads it during render, and a ref mutated in an effect
produces no re-render. The composer names the course the concept will land
in, since `addCourseId` can be the remembered fallback rather than what the
focus card shows — otherwise the destination is a silent guess.
Also in removeConcept:
- A 404 is the endpoint's answer for "that row is already gone"
(routes/graph.py::remove_node, from graph_service.delete_node's
{"error": "Node not found"}), so restoring on it put a phantom concept
back on the map under a toast asserting it was "still on your map". After
the student re-added the same name it showed two, one of which existed
nowhere. `shouldRestoreFailedDelete` draws the line; the double-insert
guard stays for genuine failures.
- A restored node now gets its focus back. Restoring the node but not the
focus left the concept on the map with the rail's focus card — and so the
Remove button and the composer — belonging to a different concept.
- Both the add-failure and delete-failure toasts go through
lib/errorMessage's `humanizeError` instead of interpolating
`ApiError.message`, which is the RAW response body: the toast used to read
`Couldn't save "X" (404 - {"detail":"Not Found"})`, or 160 characters of a
proxy's HTML error page.
Learn.addConcept.test.tsx is a component test on purpose: the gap here was
in which value the render gate reads, which no resolver-level test could
see. Each of the four fixes was verified to fail the suite when reverted.
The guard probed AF_INET/127.0.0.1 only. `localhost` resolves to both
127.0.0.1 and ::1 on any dual-stack box, and uvicorn binds whatever its
--host resolved to, so a stale server on ::1 (or on one specific interface)
walked straight past the check and the guard reported "port free" for exactly
the situation it was written to name — while `localhost:8000` in the browser
still reached the old server.
Both literals are probed now, with getaddrinfo("localhost") folded in on top
so a host whose localhost maps somewhere unusual is still covered, and an
unsupported family is skipped rather than raising. The SystemExit message
names the address that answered; without it the next person is told "already
serving" and has to guess which family to go hunting on.
Still dev-entrypoint only. tests/test_port_guard.py covers occupied IPv4,
occupied IPv6 (skipped where there is no IPv6 loopback), free, and the
message contents; the IPv6 case fails against the AF_INET-only probe.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Blocker

  • The add-concept fallback was unreachable and the reported bug still reproduced. The composer was gated on cardCourseId — the very value the fallback exists to work around — and both addConcept call sites live inside that gate, so resolveAddConceptCourseId could never return anything else and the whole "+ Add concept" affordance unmounted after deleting the focused concept. One resolved addCourseId now drives both the gate and addConcept, and the destination course is surfaced in the UI instead of being applied silently.

Major

  • A 404 on delete resurrected the node. 404 is exactly what the endpoint returns for "already gone" (routes/graph.pygraph_service.delete_node), so the restore produced a phantom concept and a toast claiming "it's still on your map" when it was not — compounding to two visible "X" nodes after a re-add. New shouldRestoreFailedDelete treats 404 as success; the genuine-failure path keeps its double-insert guard.

Minor / nits

  • Both toasts now go through lib/errorMessage.ts::humanizeError instead of printing ApiError.message — the documented raw body — into the UI. Also resolves the status-only vs body asymmetry between the add and delete paths.
  • A failed delete restores focus, not just the node and edges.
  • The dev port guard probes ::1 and getaddrinfo results as well as 127.0.0.1, and names the answering address.

Note

The quizHref / "Quiz me" deep-link code was left in place — it is correct, but it is not mentioned in the PR description, which says quiz work was split out. Worth naming in the body or moving out.

Verificationruff check . clean · 1869 passed, 49 skipped · tsc clean · 638 frontend tests pass · eslint 0 errors

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

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.

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
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;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(learn): add a concept back after deleting it, and stop a stale backend answering by Darkest-Teddy · Pull Request #536 · SaplingLearn/Sapling · GitHub
Skip to content

fix(learn): add a concept back after deleting it, and stop a stale backend answering - #536

Open
Darkest-Teddy wants to merge 7 commits into
mainfrom
fix/learn-add-concept-and-port-guard
Open

fix(learn): add a concept back after deleting it, and stop a stale backend answering#536
Darkest-Teddy wants to merge 7 commits into
mainfrom
fix/learn-add-concept-and-port-guard

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Split out of #534, which was carrying these three alongside unrelated quiz
work. Based on main; no dependency on #533/#534.

Reported as: "when deleting a concept node, i should be able to add it back
of the same name by add concept."

The reported bug was a stale server, not the graph

POST /api/graph/{user_id}/nodes returned 404 {"detail":"Not Found"} in
the browser while returning 200 through TestClient in the worktree. A backend
left running from a different working tree — older code, no create_node
was still answering :5000.

Windows lets a second process bind a port another process is already
listening on, and the first binder keeps the traffic. Restarts bound a
second socket, logged "Application startup complete", passed /api/health,
and served nobody. Get-NetTCPConnection listed two owning PIDs; the stale
parent looked dead in Win32_Process while its uvicorn reload child held
the socket, so killing the parent alone did nothing.

python main.py now probes the port first and exits with the kill command.
Dev entrypoint only — containers and Railway import main:app and never
reach it.

The tell, for next time: if a route you can read in routes/ is missing from
curl -s localhost:5000/openapi.json, you are talking to a different build.

Two real Learn bugs found on the way

Add-concept silently no-oped after a delete. The composer resolved its
course as topicNode?.course_id || selectedCourseId || null. The picker is
"Course (optional)" and starts at "", so for anyone who hasn't chosen one
the course id came entirely from the focused node — and deleting a concept
clears the focus. if (!label || !cardCourseId) return then bailed before
doing anything: no request, no toast, no rollback. resolveAddConceptCourseId
falls back to the last course that did resolve, and says so when there has
genuinely never been one. Exported, like resolveCardCourseId, so the test
exercises the resolution addConcept actually calls rather than a mirror.

Failures were unreadable..catch(() => toast.error("Couldn't save the concept — it was removed")) discarded the ApiError, giving a 401, a 404, a
500 and an unreachable backend one identical sentence. Surfacing the status
and body is what produced the 404 + request_id that ended the hunt. The
delete path had the same problem and worse: .catch(() => {}) let the node
vanish from the map while the row survived, so re-adding the same name
quietly merged into the undeleted row.

Verification

  • backend suite green on this branch; ruff check clean
  • port guard tested both ways (occupied → SystemExit, free → returns)
  • tsc --noEmit clean on the identical Learn.tsx
  • vitest cannot start on the local Node 20 (rolldown styleText crash), so
    the new specs are CI-verified only

The backend was never at fault: add → delete → re-add of the same name
creates a fresh row every time — verified against the real project.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved concept creation by retaining the previously selected course when focus is cleared.
    • Added clearer error messages when saving concepts or resolving a course fails.
    • Failed concept deletions now restore the affected content and connections when appropriate.
    • Added safeguards to prevent starting the development server when its configured port is already in use.
  • Tests
    • Added coverage for course resolution after concepts are deleted and re-added.

Darkest-Teddyand others added 3 commits August 12, 2026 10:43
The backend was never the obstacle: add -> delete -> add of the same name
creates a fresh row every time. graph_nodes has no soft delete, delete_node
hard-deletes the row and its edges, and the 0023 UNIQUE has nothing left to
collide with. Verified against the real project.
The break is in the rail's add-concept composer:
cardCourseId = topicNode?.course_id || selectedCourseId || null
...
if (!label || !cardCourseId) return;
The course picker is "Course (optional)" and starts at "" ("No course"), so
unless the user arrived with ?course= or picked one, cardCourseId comes
ENTIRELY from the focused node. Deleting a concept clears the focus — so the
delete removes the one thing supplying the course id, and the add then
returns before doing anything: no request, no toast, no rollback, the
composer just sitting there with the name typed in.
addConcept now resolves through resolveAddConceptCourseId, which falls back
to the last course that did resolve, and says so when there has genuinely
never been one instead of no-op'ing. Exported, like resolveCardCourseId, so
the test exercises the resolution addConcept actually calls rather than a
mirror of it.
Also stops removeConcept swallowing its own failures. `.catch(() => {})` is
not best-effort, it's a lie: the node vanishes from the map while the row
survives, so re-adding the same name quietly MERGES into the undeleted row
and toasts "Merged into your existing X" for a concept the user watched
themselves delete. It now restores the node and says the delete failed.
Verified with tsc --noEmit. vitest cannot start on the local Node 20
(rolldown's styleText crash), so the new specs are CI-verified only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The toast said "Couldn't save the concept — it was removed" for a 401, a
500, a 422 and an unreachable backend alike. Four different problems, four
different fixes, none of them guessable from the message — the catch
discarded the ApiError it was handed.
Chasing a real report of this cost several rounds of inference that the
status code would have ended immediately: the route itself returns 200 and
creates the row when called with the ids the client actually holds, so
whatever the browser hit is above add_node, and the toast was the only
witness. It now carries the status and the start of the body (the 500 path
includes a request_id that ties it to a log), and both handlers log the
full error to the console.
Not instrumentation to be removed later: an error message that names a
cause is what these two toasts should always have said.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The add-concept 404 was never a graph bug. A backend left running from the
MAIN working tree — older code, no create_node — was still answering :5000,
so POST /api/graph/{user_id}/nodes 404'd in the browser while returning 200
through TestClient in this worktree.
Windows lets a second process bind a port another process is already
listening on, and the FIRST binder keeps the traffic. Both restarts during
this session bound a second socket, logged "Application startup complete",
passed /api/health, and served nobody. Get-NetTCPConnection listed two
owning PIDs; the stale parent looked dead in Win32_Process while its uvicorn
reload child held the socket, so killing the parent alone did nothing.
`python main.py` now probes the port first and exits with the kill command
instead of starting a server that cannot receive a request. Dev entrypoint
only — containers and Railway import main:app and never reach it.
The tell, for next time: if a route you can read in routes/ is missing from
curl -s localhost:5000/openapi.json
you are talking to a different build, not looking at a routing bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 12, 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 Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:8 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: dcf757ba-3e52-4f90-b96f-af7d357a7767

📥 Commits

Reviewing files that changed from the base of the PR and between 76c7538 and b5c7bc9.

📒 Files selected for processing (5)
  • backend/main.py
  • backend/tests/test_port_guard.py
  • frontend/src/components/screens/Learn.addConcept.test.tsx
  • frontend/src/components/screens/Learn.graph.test.ts
  • frontend/src/components/screens/Learn.tsx
📝 Walkthrough

Walkthrough

The backend now checks development port occupancy before starting Uvicorn. The Learn screen retains course resolution after focus loss and improves concept save and deletion error handling.

Changes

Backend startup

Layer / File(s)Summary
Development port check
backend/main.py
The development entrypoint probes the configured localhost port and exits with platform-specific termination instructions when another server responds. Imported application startup remains unchanged.

Learn graph operations

Layer / File(s)Summary
Course resolution for concept creation
frontend/src/components/screens/Learn.tsx, frontend/src/components/screens/Learn.graph.test.ts
Concept creation uses the current course or the last resolved course. Tests cover fallback, current-course precedence, and the null case.
Graph error reporting and recovery
frontend/src/components/screens/Learn.tsx
Save failures include structured status or message details. Failed persisted deletions restore the concept and its edges, while temporary nodes remain removed.

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

Suggested reviewers:andresl230, jose-gael-cruz-lopez

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies both primary changes: Learn concept re-addition and stale backend process detection.
Description check✅ PassedThe description explains the bug, implementation, testing, and review context in detail, 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/learn-add-concept-and-port-guard

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 Aug 12, 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-stagingb5c7bc9Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:07 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: 2

🤖 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 `@frontend/src/components/screens/Learn.tsx`:
- Around line 1294-1295: Update the deletion error handling in Learn.tsx around
the ApiError check to include a truncated err.message alongside err.status in
the toast. Preserve the existing non-ApiError behavior and match the add failure
path’s response-detail formatting.
- Around line 1203-1206: Update Learn around resolveAddConceptCourseId and the
Add concept control so a single resolved add-course value is computed and reused
by both addConcept and the render condition, rather than gating the control
directly on cardCourseId. Preserve the fallback behavior when the picker has no
selection, and add a component test covering deletion of the focused concept
with no picker selection while confirming Add concept remains available.
🪄 Autofix

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: c58cf75a-76c6-43a2-9876-c9c6074f4f35

📥 Commits

Reviewing files that changed from the base of the PR and between 7681c48 and 76c7538.

📒 Files selected for processing (3)
  • backend/main.py
  • frontend/src/components/screens/Learn.graph.test.ts
  • frontend/src/components/screens/Learn.tsx

Comment threadfrontend/src/components/screens/Learn.tsx Outdated
Comment threadfrontend/src/components/screens/Learn.tsx Outdated
The tutor page had no quiz entry point at all — Learn.tsx did not mention
quiz anywhere. The only routes in were the nav's Quiz item, the dashboard,
the knowledge map and the notetaker, and every one of them drops the
session's context: the tutor knows exactly which concept the student is
working on, then hands them a page that asks them to pick it again.
Two buttons, one resolver:
focus card ("Quiz me") -> the rail's anchored concept
session toolbar ("Quiz me") -> the focused concept, else the session topic
`quizHref` prefers a node id, which screens/Quiz.tsx uses directly as
initialConceptId, and falls back to the topic NAME, which it resolves
against the concept list.
The placeholder guard is the part worth reading: an optimistically-added
concept carries a client-side `node-new-<ts>` id and a streamed one carries
`stream-<name>`, neither of which exists server-side. Passing either as
?concept= would preselect an id the quiz page cannot resolve — silently, as
an empty picker. Those fall through to the name, which resolves as soon as
the real row lands. Exported and tested like the other resolvers, so the
test covers what the buttons actually call.
Verified with tsc --noEmit. vitest cannot start on the local Node 20
(rolldown styleText crash), so the new specs are CI-verified only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — learn concept re-add + port guard

The diagnosis here is excellent — the "stale process on :5000" write-up is the most useful thing in the PR, and _refuse_if_port_taken is correctly scoped (dev __main__ only; backend/Dockerfile:37 runs uvicorn main:app, so containers never reach it). Replacing the delete path's .catch(() => {}) with a real restore is the right instinct. But the headline fix does not reach the reported flow: addConcept's new fallback is unreachable, because the composer that calls it is gated on the very value the fallback exists to work around. There is also a new failure mode in the delete restore (a 404 resurrects the node), a hand-rolled error formatter that duplicates lib/errorMessage.ts and prints raw JSON bodies into toasts, and ~70 lines of undocumented quiz deep-link feature in a PR whose description says it was split out to exclude quiz work.

Findings

P0

[P0] addConcept's new fallback is unreachable, and the reported bug survivesfrontend/src/components/screens/Learn.tsx:1754

{/* Add concept */}{cardCourseId&&(<divstyle={{padding: "4px 22px 24px"}}>

Both addConcept call sites live inside that block — :1763 (onKeyDown Enter) and :1780 (the Add button) — and there is no other caller in the file. So cardCourseId is always truthy when addConcept can run, which means resolveAddConceptCourseId(cardCourseId, lastCourseIdRef.current) at :1226 always returns cardCourseId. The lastCourseIdRef + effect (:474-477) and the new toast.error("Pick a course first…") branch (:1227-1230) are dead code.

Worse, the reported repro still fails. removeConcept is only reachable from the focus card — :1611 gates the button row on focusConcept, Remove is :1663-1683 — so the deleted node is always the focused one. After removal, activeFocusId = focusedNodeId ?? highlightId (:452) and highlightId is graphNodes.find(n => n.name.toLowerCase() === topic.trim().toLowerCase())?.id (:446) — the node just deleted. With selectedCourseId at its default "" (:392/:397, searchParams.get("course") ?? ""), resolveCardCourseId (:269-274) returns null and the whole "+ Add concept" affordance unmounts (block :1754-1815). The student cannot type the name back in at all, which is exactly "when deleting a concept node, i should be able to add it back of the same name by add concept".

The new specs pass because they call the exported resolver directly and never render the gate. The gate is where the fix belongs: resolve the add-course once and gate the composer on that, showing which course the concept will land in. (CodeRabbit raised the gate; this adds the proof that the fallback branch is unreachable and that the original repro is unchanged.)

P1

[P1] A 404 on delete resurrects the node and toasts a false statementfrontend/src/components/screens/Learn.tsx:1304-1318

deleteGraphNode(userId,nodeId).catch((err: unknown)=>{console.error("[removeConcept] failed",{ nodeId, err });if(!removedNode||/^(node-new-|stream-)/.test(nodeId))return;setGraphNodes(prev=>(prev.some(n=>n.id===nodeId) ? prev : [...prev,removedNode]));
...
constreason=errinstanceofApiError ? ` (${err.status})` : "";toast.error(`Couldn't delete “${removedNode.name}${reason} — it's still on your map.`);

The restore fires on any rejection, and 404 is precisely the status the endpoint returns for "the row is already gone": backend/routes/graph.py:119-125 raises HTTPException(status_code=404, …) whenever delete_node returns an error, and backend/services/graph_service.py:486-492 returns {"error": "Node not found", "deleted": False} when the owner-scoped select finds no row. So a second tab, a client-side node that is already stale, or a delete that already succeeded server-side now puts a phantom concept back on the map and tells the user "it's still on your map" when it is not. The old .catch(() => {}) produced the correct end state in exactly this case.

It compounds: delete X → 404 → user re-adds X (fresh row, new id) → the catch appends the old node object under its dead id and the map shows two "X" nodes, one of which doesn't exist. A 404 should be treated as success — nothing to restore. (The non-404 case is already handled correctly: the prev.some(n => n.id === nodeId) guard stops a double-insert when a re-add merged back into the surviving row.)

P2

[P2] Hand-rolled error copy duplicates lib/errorMessage.ts and dumps the raw response body into a toastfrontend/src/components/screens/Learn.tsx:1279-1285

constreason=errinstanceofApiError
? `${err.status}${err.message ? ` — ${err.message.slice(0,160)}` : ""}`
: errinstanceofError&&err.message
? err.message.slice(0,160)
: "the request never completed";toast.error(`Couldn't save “${label}” (${reason}).`);

ApiError.message is documented as the raw body — frontend/src/lib/api.ts:25-26: "message stays the raw body for backward compatibility" — so the toast renders Couldn't save “Markov Chains” (404 — {"detail":"Not Found"})., or 160 characters of a proxy's HTML error page. frontend/src/lib/errorMessage.ts exists for exactly this and says so in its header: "Rendering that with String(err) dumps JSON into the UI, which is what these helpers exist to prevent." Its humanizeError(err, fallback) (:156-160 — "A short sentence safe to put in a toast. Never returns … a raw JSON body") is already used across Gradebook, Calendar, Social, Study, ProfileView, the notetaker, SignInModal and QuizPanel; Learn.tsx imports nothing from it. The Engineering Style Guide's shared-primitive rule applies here the same way it does to components/ui/. It also removes the inconsistency CodeRabbit flagged — the delete toast at :1317 surfaces only the status and never the detail; both paths would read the same through one helper.

[P2] An undocumented quiz deep-link feature ships in a PR that says it excludes quiz workfrontend/src/components/screens/Learn.tsx:288-297, :1449-1457, :1640-1662

exportfunctionquizHref(conceptId: string|null|undefined,topic: string|null|undefined,): string{

quizHref plus two new "Quiz me" buttons (session toolbar :1449, focus card :1640) plus a five-case spec block (Learn.graph.test.ts:171-206) are roughly a quarter of the diff, and neither the title, the description, nor the release-note summary mentions them. The description says the opposite: "Split out of #534, which was carrying these three alongside unrelated quiz work." The code itself checks out — ?concept=/?topic= match screens/Quiz.tsx:86-94, resolveInitialSelection (lib/quizSelection.ts:71-75) degrades to {null, null} on an id it can't resolve, Icon has flask (Icon.tsx:78), .btn--sm exists (globals.css:233), and #534 carries no duplicate — but a new user-facing entry point riding along in a bugfix PR makes this hard to review and impossible to revert cleanly. Either name it in the description or move it out.

P3

[P3] A failed delete restores the node but not the focusfrontend/src/components/screens/Learn.tsx:1302

setFocusedNodeId(cur=>(cur===nodeId ? null : cur));

The catch at :1304-1318 undoes the node and edge removals but never the focus clear, so after a failed delete the concept reappears on the map while the rail's focus card is gone — and, per the P0 above, so is the Add-concept composer.

[P3] The port probe only covers IPv4 loopbackbackend/main.py:338-341

withsocket.socket(socket.AF_INET, socket.SOCK_STREAM) asprobe:
probe.settimeout(0.4)
ifprobe.connect_ex(("127.0.0.1", port)) !=0:
return

uvicorn.run(..., host="0.0.0.0") at :359 binds the IPv4 wildcard so the common case is covered, but a stale server bound to ::1 or to one specific interface passes the probe silently. Given the whole point is "tell the next person", resolving both families would close the gap.

What's good

  • The root-cause write-up in the description (two owning PIDs, the uvicorn reload child holding the socket, "if a route you can read in routes/ is missing from /openapi.json, you are talking to a different build") is worth more than the code — it belongs in CLAUDE.md or docs/local-supabase.md so it outlives the PR.
  • The guard is correctly classified and scoped: it is a dev-entrypoint TCP probe under if __name__ == "__main__", not a production origin/port check. backend/Dockerfile:37 is CMD ["uvicorn", "main:app", …] and scripts/e2e-up.sh invokes uvicorn directly, so neither containers nor the e2e lane can trip it.
  • The backend claim in the description holds: delete_node (graph_service.py:481-509) hard-deletes edges in both directions and then the row, with no soft-delete column and no unique-constraint collision on re-add — so add → delete → re-add really does create a fresh row.

Verdict: request changes — the P0 leaves the reported bug reproducible on the default path, and the P1 introduces a new map/server divergence in the opposite direction.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…p a 404 resurrecting a node
The previous pass added `resolveAddConceptCourseId` so add-concept could
survive the delete that clears the focus — and then gated the composer that
calls it on `cardCourseId`, the exact value the fallback exists to work
around. Both `addConcept` call sites lived inside that gate, so the fallback
could only ever run while `cardCourseId` was already truthy: dead code, and
the reported bug ("delete a concept, add it back under the same name")
unchanged. Deleting the focused concept with the optional course picker at
its "" default unmounted the whole "+ Add concept" affordance, so the name
could not be typed back in at all.
The gate and `addConcept` now read ONE resolved value, `addCourseId`, so
they cannot disagree again. `lastCourseId` became state rather than a ref
because the gate reads it during render, and a ref mutated in an effect
produces no re-render. The composer names the course the concept will land
in, since `addCourseId` can be the remembered fallback rather than what the
focus card shows — otherwise the destination is a silent guess.
Also in removeConcept:
- A 404 is the endpoint's answer for "that row is already gone"
(routes/graph.py::remove_node, from graph_service.delete_node's
{"error": "Node not found"}), so restoring on it put a phantom concept
back on the map under a toast asserting it was "still on your map". After
the student re-added the same name it showed two, one of which existed
nowhere. `shouldRestoreFailedDelete` draws the line; the double-insert
guard stays for genuine failures.
- A restored node now gets its focus back. Restoring the node but not the
focus left the concept on the map with the rail's focus card — and so the
Remove button and the composer — belonging to a different concept.
- Both the add-failure and delete-failure toasts go through
lib/errorMessage's `humanizeError` instead of interpolating
`ApiError.message`, which is the RAW response body: the toast used to read
`Couldn't save "X" (404 - {"detail":"Not Found"})`, or 160 characters of a
proxy's HTML error page.
Learn.addConcept.test.tsx is a component test on purpose: the gap here was
in which value the render gate reads, which no resolver-level test could
see. Each of the four fixes was verified to fail the suite when reverted.
The guard probed AF_INET/127.0.0.1 only. `localhost` resolves to both
127.0.0.1 and ::1 on any dual-stack box, and uvicorn binds whatever its
--host resolved to, so a stale server on ::1 (or on one specific interface)
walked straight past the check and the guard reported "port free" for exactly
the situation it was written to name — while `localhost:8000` in the browser
still reached the old server.
Both literals are probed now, with getaddrinfo("localhost") folded in on top
so a host whose localhost maps somewhere unusual is still covered, and an
unsupported family is skipped rather than raising. The SystemExit message
names the address that answered; without it the next person is told "already
serving" and has to guess which family to go hunting on.
Still dev-entrypoint only. tests/test_port_guard.py covers occupied IPv4,
occupied IPv6 (skipped where there is no IPv6 loopback), free, and the
message contents; the IPv6 case fails against the AF_INET-only probe.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Blocker

  • The add-concept fallback was unreachable and the reported bug still reproduced. The composer was gated on cardCourseId — the very value the fallback exists to work around — and both addConcept call sites live inside that gate, so resolveAddConceptCourseId could never return anything else and the whole "+ Add concept" affordance unmounted after deleting the focused concept. One resolved addCourseId now drives both the gate and addConcept, and the destination course is surfaced in the UI instead of being applied silently.

Major

  • A 404 on delete resurrected the node. 404 is exactly what the endpoint returns for "already gone" (routes/graph.pygraph_service.delete_node), so the restore produced a phantom concept and a toast claiming "it's still on your map" when it was not — compounding to two visible "X" nodes after a re-add. New shouldRestoreFailedDelete treats 404 as success; the genuine-failure path keeps its double-insert guard.

Minor / nits

  • Both toasts now go through lib/errorMessage.ts::humanizeError instead of printing ApiError.message — the documented raw body — into the UI. Also resolves the status-only vs body asymmetry between the add and delete paths.
  • A failed delete restores focus, not just the node and edges.
  • The dev port guard probes ::1 and getaddrinfo results as well as 127.0.0.1, and names the answering address.

Note

The quizHref / "Quiz me" deep-link code was left in place — it is correct, but it is not mentioned in the PR description, which says quiz work was split out. Worth naming in the body or moving out.

Verificationruff check . clean · 1869 passed, 49 skipped · tsc clean · 638 frontend tests pass · eslint 0 errors

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

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.

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(learn): add a concept back after deleting it, and stop a stale backend answering by Darkest-Teddy · Pull Request #536 · SaplingLearn/Sapling · GitHub
Skip to content

fix(learn): add a concept back after deleting it, and stop a stale backend answering - #536

Open
Darkest-Teddy wants to merge 7 commits into
mainfrom
fix/learn-add-concept-and-port-guard
Open

fix(learn): add a concept back after deleting it, and stop a stale backend answering#536
Darkest-Teddy wants to merge 7 commits into
mainfrom
fix/learn-add-concept-and-port-guard

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Split out of #534, which was carrying these three alongside unrelated quiz
work. Based on main; no dependency on #533/#534.

Reported as: "when deleting a concept node, i should be able to add it back
of the same name by add concept."

The reported bug was a stale server, not the graph

POST /api/graph/{user_id}/nodes returned 404 {"detail":"Not Found"} in
the browser while returning 200 through TestClient in the worktree. A backend
left running from a different working tree — older code, no create_node
was still answering :5000.

Windows lets a second process bind a port another process is already
listening on, and the first binder keeps the traffic. Restarts bound a
second socket, logged "Application startup complete", passed /api/health,
and served nobody. Get-NetTCPConnection listed two owning PIDs; the stale
parent looked dead in Win32_Process while its uvicorn reload child held
the socket, so killing the parent alone did nothing.

python main.py now probes the port first and exits with the kill command.
Dev entrypoint only — containers and Railway import main:app and never
reach it.

The tell, for next time: if a route you can read in routes/ is missing from
curl -s localhost:5000/openapi.json, you are talking to a different build.

Two real Learn bugs found on the way

Add-concept silently no-oped after a delete. The composer resolved its
course as topicNode?.course_id || selectedCourseId || null. The picker is
"Course (optional)" and starts at "", so for anyone who hasn't chosen one
the course id came entirely from the focused node — and deleting a concept
clears the focus. if (!label || !cardCourseId) return then bailed before
doing anything: no request, no toast, no rollback. resolveAddConceptCourseId
falls back to the last course that did resolve, and says so when there has
genuinely never been one. Exported, like resolveCardCourseId, so the test
exercises the resolution addConcept actually calls rather than a mirror.

Failures were unreadable..catch(() => toast.error("Couldn't save the concept — it was removed")) discarded the ApiError, giving a 401, a 404, a
500 and an unreachable backend one identical sentence. Surfacing the status
and body is what produced the 404 + request_id that ended the hunt. The
delete path had the same problem and worse: .catch(() => {}) let the node
vanish from the map while the row survived, so re-adding the same name
quietly merged into the undeleted row.

Verification

  • backend suite green on this branch; ruff check clean
  • port guard tested both ways (occupied → SystemExit, free → returns)
  • tsc --noEmit clean on the identical Learn.tsx
  • vitest cannot start on the local Node 20 (rolldown styleText crash), so
    the new specs are CI-verified only

The backend was never at fault: add → delete → re-add of the same name
creates a fresh row every time — verified against the real project.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved concept creation by retaining the previously selected course when focus is cleared.
    • Added clearer error messages when saving concepts or resolving a course fails.
    • Failed concept deletions now restore the affected content and connections when appropriate.
    • Added safeguards to prevent starting the development server when its configured port is already in use.
  • Tests
    • Added coverage for course resolution after concepts are deleted and re-added.

Darkest-Teddyand others added 3 commits August 12, 2026 10:43
The backend was never the obstacle: add -> delete -> add of the same name
creates a fresh row every time. graph_nodes has no soft delete, delete_node
hard-deletes the row and its edges, and the 0023 UNIQUE has nothing left to
collide with. Verified against the real project.
The break is in the rail's add-concept composer:
cardCourseId = topicNode?.course_id || selectedCourseId || null
...
if (!label || !cardCourseId) return;
The course picker is "Course (optional)" and starts at "" ("No course"), so
unless the user arrived with ?course= or picked one, cardCourseId comes
ENTIRELY from the focused node. Deleting a concept clears the focus — so the
delete removes the one thing supplying the course id, and the add then
returns before doing anything: no request, no toast, no rollback, the
composer just sitting there with the name typed in.
addConcept now resolves through resolveAddConceptCourseId, which falls back
to the last course that did resolve, and says so when there has genuinely
never been one instead of no-op'ing. Exported, like resolveCardCourseId, so
the test exercises the resolution addConcept actually calls rather than a
mirror of it.
Also stops removeConcept swallowing its own failures. `.catch(() => {})` is
not best-effort, it's a lie: the node vanishes from the map while the row
survives, so re-adding the same name quietly MERGES into the undeleted row
and toasts "Merged into your existing X" for a concept the user watched
themselves delete. It now restores the node and says the delete failed.
Verified with tsc --noEmit. vitest cannot start on the local Node 20
(rolldown's styleText crash), so the new specs are CI-verified only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The toast said "Couldn't save the concept — it was removed" for a 401, a
500, a 422 and an unreachable backend alike. Four different problems, four
different fixes, none of them guessable from the message — the catch
discarded the ApiError it was handed.
Chasing a real report of this cost several rounds of inference that the
status code would have ended immediately: the route itself returns 200 and
creates the row when called with the ids the client actually holds, so
whatever the browser hit is above add_node, and the toast was the only
witness. It now carries the status and the start of the body (the 500 path
includes a request_id that ties it to a log), and both handlers log the
full error to the console.
Not instrumentation to be removed later: an error message that names a
cause is what these two toasts should always have said.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The add-concept 404 was never a graph bug. A backend left running from the
MAIN working tree — older code, no create_node — was still answering :5000,
so POST /api/graph/{user_id}/nodes 404'd in the browser while returning 200
through TestClient in this worktree.
Windows lets a second process bind a port another process is already
listening on, and the FIRST binder keeps the traffic. Both restarts during
this session bound a second socket, logged "Application startup complete",
passed /api/health, and served nobody. Get-NetTCPConnection listed two
owning PIDs; the stale parent looked dead in Win32_Process while its uvicorn
reload child held the socket, so killing the parent alone did nothing.
`python main.py` now probes the port first and exits with the kill command
instead of starting a server that cannot receive a request. Dev entrypoint
only — containers and Railway import main:app and never reach it.
The tell, for next time: if a route you can read in routes/ is missing from
curl -s localhost:5000/openapi.json
you are talking to a different build, not looking at a routing bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 12, 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 Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:8 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: dcf757ba-3e52-4f90-b96f-af7d357a7767

📥 Commits

Reviewing files that changed from the base of the PR and between 76c7538 and b5c7bc9.

📒 Files selected for processing (5)
  • backend/main.py
  • backend/tests/test_port_guard.py
  • frontend/src/components/screens/Learn.addConcept.test.tsx
  • frontend/src/components/screens/Learn.graph.test.ts
  • frontend/src/components/screens/Learn.tsx
📝 Walkthrough

Walkthrough

The backend now checks development port occupancy before starting Uvicorn. The Learn screen retains course resolution after focus loss and improves concept save and deletion error handling.

Changes

Backend startup

Layer / File(s)Summary
Development port check
backend/main.py
The development entrypoint probes the configured localhost port and exits with platform-specific termination instructions when another server responds. Imported application startup remains unchanged.

Learn graph operations

Layer / File(s)Summary
Course resolution for concept creation
frontend/src/components/screens/Learn.tsx, frontend/src/components/screens/Learn.graph.test.ts
Concept creation uses the current course or the last resolved course. Tests cover fallback, current-course precedence, and the null case.
Graph error reporting and recovery
frontend/src/components/screens/Learn.tsx
Save failures include structured status or message details. Failed persisted deletions restore the concept and its edges, while temporary nodes remain removed.

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

Suggested reviewers:andresl230, jose-gael-cruz-lopez

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies both primary changes: Learn concept re-addition and stale backend process detection.
Description check✅ PassedThe description explains the bug, implementation, testing, and review context in detail, 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/learn-add-concept-and-port-guard

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 Aug 12, 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-stagingb5c7bc9Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:07 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: 2

🤖 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 `@frontend/src/components/screens/Learn.tsx`:
- Around line 1294-1295: Update the deletion error handling in Learn.tsx around
the ApiError check to include a truncated err.message alongside err.status in
the toast. Preserve the existing non-ApiError behavior and match the add failure
path’s response-detail formatting.
- Around line 1203-1206: Update Learn around resolveAddConceptCourseId and the
Add concept control so a single resolved add-course value is computed and reused
by both addConcept and the render condition, rather than gating the control
directly on cardCourseId. Preserve the fallback behavior when the picker has no
selection, and add a component test covering deletion of the focused concept
with no picker selection while confirming Add concept remains available.
🪄 Autofix

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: c58cf75a-76c6-43a2-9876-c9c6074f4f35

📥 Commits

Reviewing files that changed from the base of the PR and between 7681c48 and 76c7538.

📒 Files selected for processing (3)
  • backend/main.py
  • frontend/src/components/screens/Learn.graph.test.ts
  • frontend/src/components/screens/Learn.tsx

Comment threadfrontend/src/components/screens/Learn.tsx Outdated
Comment threadfrontend/src/components/screens/Learn.tsx Outdated
The tutor page had no quiz entry point at all — Learn.tsx did not mention
quiz anywhere. The only routes in were the nav's Quiz item, the dashboard,
the knowledge map and the notetaker, and every one of them drops the
session's context: the tutor knows exactly which concept the student is
working on, then hands them a page that asks them to pick it again.
Two buttons, one resolver:
focus card ("Quiz me") -> the rail's anchored concept
session toolbar ("Quiz me") -> the focused concept, else the session topic
`quizHref` prefers a node id, which screens/Quiz.tsx uses directly as
initialConceptId, and falls back to the topic NAME, which it resolves
against the concept list.
The placeholder guard is the part worth reading: an optimistically-added
concept carries a client-side `node-new-<ts>` id and a streamed one carries
`stream-<name>`, neither of which exists server-side. Passing either as
?concept= would preselect an id the quiz page cannot resolve — silently, as
an empty picker. Those fall through to the name, which resolves as soon as
the real row lands. Exported and tested like the other resolvers, so the
test covers what the buttons actually call.
Verified with tsc --noEmit. vitest cannot start on the local Node 20
(rolldown styleText crash), so the new specs are CI-verified only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — learn concept re-add + port guard

The diagnosis here is excellent — the "stale process on :5000" write-up is the most useful thing in the PR, and _refuse_if_port_taken is correctly scoped (dev __main__ only; backend/Dockerfile:37 runs uvicorn main:app, so containers never reach it). Replacing the delete path's .catch(() => {}) with a real restore is the right instinct. But the headline fix does not reach the reported flow: addConcept's new fallback is unreachable, because the composer that calls it is gated on the very value the fallback exists to work around. There is also a new failure mode in the delete restore (a 404 resurrects the node), a hand-rolled error formatter that duplicates lib/errorMessage.ts and prints raw JSON bodies into toasts, and ~70 lines of undocumented quiz deep-link feature in a PR whose description says it was split out to exclude quiz work.

Findings

P0

[P0] addConcept's new fallback is unreachable, and the reported bug survivesfrontend/src/components/screens/Learn.tsx:1754

{/* Add concept */}{cardCourseId&&(<divstyle={{padding: "4px 22px 24px"}}>

Both addConcept call sites live inside that block — :1763 (onKeyDown Enter) and :1780 (the Add button) — and there is no other caller in the file. So cardCourseId is always truthy when addConcept can run, which means resolveAddConceptCourseId(cardCourseId, lastCourseIdRef.current) at :1226 always returns cardCourseId. The lastCourseIdRef + effect (:474-477) and the new toast.error("Pick a course first…") branch (:1227-1230) are dead code.

Worse, the reported repro still fails. removeConcept is only reachable from the focus card — :1611 gates the button row on focusConcept, Remove is :1663-1683 — so the deleted node is always the focused one. After removal, activeFocusId = focusedNodeId ?? highlightId (:452) and highlightId is graphNodes.find(n => n.name.toLowerCase() === topic.trim().toLowerCase())?.id (:446) — the node just deleted. With selectedCourseId at its default "" (:392/:397, searchParams.get("course") ?? ""), resolveCardCourseId (:269-274) returns null and the whole "+ Add concept" affordance unmounts (block :1754-1815). The student cannot type the name back in at all, which is exactly "when deleting a concept node, i should be able to add it back of the same name by add concept".

The new specs pass because they call the exported resolver directly and never render the gate. The gate is where the fix belongs: resolve the add-course once and gate the composer on that, showing which course the concept will land in. (CodeRabbit raised the gate; this adds the proof that the fallback branch is unreachable and that the original repro is unchanged.)

P1

[P1] A 404 on delete resurrects the node and toasts a false statementfrontend/src/components/screens/Learn.tsx:1304-1318

deleteGraphNode(userId,nodeId).catch((err: unknown)=>{console.error("[removeConcept] failed",{ nodeId, err });if(!removedNode||/^(node-new-|stream-)/.test(nodeId))return;setGraphNodes(prev=>(prev.some(n=>n.id===nodeId) ? prev : [...prev,removedNode]));
...
constreason=errinstanceofApiError ? ` (${err.status})` : "";toast.error(`Couldn't delete “${removedNode.name}${reason} — it's still on your map.`);

The restore fires on any rejection, and 404 is precisely the status the endpoint returns for "the row is already gone": backend/routes/graph.py:119-125 raises HTTPException(status_code=404, …) whenever delete_node returns an error, and backend/services/graph_service.py:486-492 returns {"error": "Node not found", "deleted": False} when the owner-scoped select finds no row. So a second tab, a client-side node that is already stale, or a delete that already succeeded server-side now puts a phantom concept back on the map and tells the user "it's still on your map" when it is not. The old .catch(() => {}) produced the correct end state in exactly this case.

It compounds: delete X → 404 → user re-adds X (fresh row, new id) → the catch appends the old node object under its dead id and the map shows two "X" nodes, one of which doesn't exist. A 404 should be treated as success — nothing to restore. (The non-404 case is already handled correctly: the prev.some(n => n.id === nodeId) guard stops a double-insert when a re-add merged back into the surviving row.)

P2

[P2] Hand-rolled error copy duplicates lib/errorMessage.ts and dumps the raw response body into a toastfrontend/src/components/screens/Learn.tsx:1279-1285

constreason=errinstanceofApiError
? `${err.status}${err.message ? ` — ${err.message.slice(0,160)}` : ""}`
: errinstanceofError&&err.message
? err.message.slice(0,160)
: "the request never completed";toast.error(`Couldn't save “${label}” (${reason}).`);

ApiError.message is documented as the raw body — frontend/src/lib/api.ts:25-26: "message stays the raw body for backward compatibility" — so the toast renders Couldn't save “Markov Chains” (404 — {"detail":"Not Found"})., or 160 characters of a proxy's HTML error page. frontend/src/lib/errorMessage.ts exists for exactly this and says so in its header: "Rendering that with String(err) dumps JSON into the UI, which is what these helpers exist to prevent." Its humanizeError(err, fallback) (:156-160 — "A short sentence safe to put in a toast. Never returns … a raw JSON body") is already used across Gradebook, Calendar, Social, Study, ProfileView, the notetaker, SignInModal and QuizPanel; Learn.tsx imports nothing from it. The Engineering Style Guide's shared-primitive rule applies here the same way it does to components/ui/. It also removes the inconsistency CodeRabbit flagged — the delete toast at :1317 surfaces only the status and never the detail; both paths would read the same through one helper.

[P2] An undocumented quiz deep-link feature ships in a PR that says it excludes quiz workfrontend/src/components/screens/Learn.tsx:288-297, :1449-1457, :1640-1662

exportfunctionquizHref(conceptId: string|null|undefined,topic: string|null|undefined,): string{

quizHref plus two new "Quiz me" buttons (session toolbar :1449, focus card :1640) plus a five-case spec block (Learn.graph.test.ts:171-206) are roughly a quarter of the diff, and neither the title, the description, nor the release-note summary mentions them. The description says the opposite: "Split out of #534, which was carrying these three alongside unrelated quiz work." The code itself checks out — ?concept=/?topic= match screens/Quiz.tsx:86-94, resolveInitialSelection (lib/quizSelection.ts:71-75) degrades to {null, null} on an id it can't resolve, Icon has flask (Icon.tsx:78), .btn--sm exists (globals.css:233), and #534 carries no duplicate — but a new user-facing entry point riding along in a bugfix PR makes this hard to review and impossible to revert cleanly. Either name it in the description or move it out.

P3

[P3] A failed delete restores the node but not the focusfrontend/src/components/screens/Learn.tsx:1302

setFocusedNodeId(cur=>(cur===nodeId ? null : cur));

The catch at :1304-1318 undoes the node and edge removals but never the focus clear, so after a failed delete the concept reappears on the map while the rail's focus card is gone — and, per the P0 above, so is the Add-concept composer.

[P3] The port probe only covers IPv4 loopbackbackend/main.py:338-341

withsocket.socket(socket.AF_INET, socket.SOCK_STREAM) asprobe:
probe.settimeout(0.4)
ifprobe.connect_ex(("127.0.0.1", port)) !=0:
return

uvicorn.run(..., host="0.0.0.0") at :359 binds the IPv4 wildcard so the common case is covered, but a stale server bound to ::1 or to one specific interface passes the probe silently. Given the whole point is "tell the next person", resolving both families would close the gap.

What's good

  • The root-cause write-up in the description (two owning PIDs, the uvicorn reload child holding the socket, "if a route you can read in routes/ is missing from /openapi.json, you are talking to a different build") is worth more than the code — it belongs in CLAUDE.md or docs/local-supabase.md so it outlives the PR.
  • The guard is correctly classified and scoped: it is a dev-entrypoint TCP probe under if __name__ == "__main__", not a production origin/port check. backend/Dockerfile:37 is CMD ["uvicorn", "main:app", …] and scripts/e2e-up.sh invokes uvicorn directly, so neither containers nor the e2e lane can trip it.
  • The backend claim in the description holds: delete_node (graph_service.py:481-509) hard-deletes edges in both directions and then the row, with no soft-delete column and no unique-constraint collision on re-add — so add → delete → re-add really does create a fresh row.

Verdict: request changes — the P0 leaves the reported bug reproducible on the default path, and the P1 introduces a new map/server divergence in the opposite direction.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…p a 404 resurrecting a node
The previous pass added `resolveAddConceptCourseId` so add-concept could
survive the delete that clears the focus — and then gated the composer that
calls it on `cardCourseId`, the exact value the fallback exists to work
around. Both `addConcept` call sites lived inside that gate, so the fallback
could only ever run while `cardCourseId` was already truthy: dead code, and
the reported bug ("delete a concept, add it back under the same name")
unchanged. Deleting the focused concept with the optional course picker at
its "" default unmounted the whole "+ Add concept" affordance, so the name
could not be typed back in at all.
The gate and `addConcept` now read ONE resolved value, `addCourseId`, so
they cannot disagree again. `lastCourseId` became state rather than a ref
because the gate reads it during render, and a ref mutated in an effect
produces no re-render. The composer names the course the concept will land
in, since `addCourseId` can be the remembered fallback rather than what the
focus card shows — otherwise the destination is a silent guess.
Also in removeConcept:
- A 404 is the endpoint's answer for "that row is already gone"
(routes/graph.py::remove_node, from graph_service.delete_node's
{"error": "Node not found"}), so restoring on it put a phantom concept
back on the map under a toast asserting it was "still on your map". After
the student re-added the same name it showed two, one of which existed
nowhere. `shouldRestoreFailedDelete` draws the line; the double-insert
guard stays for genuine failures.
- A restored node now gets its focus back. Restoring the node but not the
focus left the concept on the map with the rail's focus card — and so the
Remove button and the composer — belonging to a different concept.
- Both the add-failure and delete-failure toasts go through
lib/errorMessage's `humanizeError` instead of interpolating
`ApiError.message`, which is the RAW response body: the toast used to read
`Couldn't save "X" (404 - {"detail":"Not Found"})`, or 160 characters of a
proxy's HTML error page.
Learn.addConcept.test.tsx is a component test on purpose: the gap here was
in which value the render gate reads, which no resolver-level test could
see. Each of the four fixes was verified to fail the suite when reverted.
The guard probed AF_INET/127.0.0.1 only. `localhost` resolves to both
127.0.0.1 and ::1 on any dual-stack box, and uvicorn binds whatever its
--host resolved to, so a stale server on ::1 (or on one specific interface)
walked straight past the check and the guard reported "port free" for exactly
the situation it was written to name — while `localhost:8000` in the browser
still reached the old server.
Both literals are probed now, with getaddrinfo("localhost") folded in on top
so a host whose localhost maps somewhere unusual is still covered, and an
unsupported family is skipped rather than raising. The SystemExit message
names the address that answered; without it the next person is told "already
serving" and has to guess which family to go hunting on.
Still dev-entrypoint only. tests/test_port_guard.py covers occupied IPv4,
occupied IPv6 (skipped where there is no IPv6 loopback), free, and the
message contents; the IPv6 case fails against the AF_INET-only probe.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Blocker

  • The add-concept fallback was unreachable and the reported bug still reproduced. The composer was gated on cardCourseId — the very value the fallback exists to work around — and both addConcept call sites live inside that gate, so resolveAddConceptCourseId could never return anything else and the whole "+ Add concept" affordance unmounted after deleting the focused concept. One resolved addCourseId now drives both the gate and addConcept, and the destination course is surfaced in the UI instead of being applied silently.

Major

  • A 404 on delete resurrected the node. 404 is exactly what the endpoint returns for "already gone" (routes/graph.pygraph_service.delete_node), so the restore produced a phantom concept and a toast claiming "it's still on your map" when it was not — compounding to two visible "X" nodes after a re-add. New shouldRestoreFailedDelete treats 404 as success; the genuine-failure path keeps its double-insert guard.

Minor / nits

  • Both toasts now go through lib/errorMessage.ts::humanizeError instead of printing ApiError.message — the documented raw body — into the UI. Also resolves the status-only vs body asymmetry between the add and delete paths.
  • A failed delete restores focus, not just the node and edges.
  • The dev port guard probes ::1 and getaddrinfo results as well as 127.0.0.1, and names the answering address.

Note

The quizHref / "Quiz me" deep-link code was left in place — it is correct, but it is not mentioned in the PR description, which says quiz work was split out. Worth naming in the body or moving out.

Verificationruff check . clean · 1869 passed, 49 skipped · tsc clean · 638 frontend tests pass · eslint 0 errors

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

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.

2 participants

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

fix(learn): add a concept back after deleting it, and stop a stale backend answering - #536

Open
Darkest-Teddy wants to merge 7 commits into
mainfrom
fix/learn-add-concept-and-port-guard
Open

fix(learn): add a concept back after deleting it, and stop a stale backend answering#536
Darkest-Teddy wants to merge 7 commits into
mainfrom
fix/learn-add-concept-and-port-guard

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Split out of #534, which was carrying these three alongside unrelated quiz
work. Based on main; no dependency on #533/#534.

Reported as: "when deleting a concept node, i should be able to add it back
of the same name by add concept."

The reported bug was a stale server, not the graph

POST /api/graph/{user_id}/nodes returned 404 {"detail":"Not Found"} in
the browser while returning 200 through TestClient in the worktree. A backend
left running from a different working tree — older code, no create_node
was still answering :5000.

Windows lets a second process bind a port another process is already
listening on, and the first binder keeps the traffic. Restarts bound a
second socket, logged "Application startup complete", passed /api/health,
and served nobody. Get-NetTCPConnection listed two owning PIDs; the stale
parent looked dead in Win32_Process while its uvicorn reload child held
the socket, so killing the parent alone did nothing.

python main.py now probes the port first and exits with the kill command.
Dev entrypoint only — containers and Railway import main:app and never
reach it.

The tell, for next time: if a route you can read in routes/ is missing from
curl -s localhost:5000/openapi.json, you are talking to a different build.

Two real Learn bugs found on the way

Add-concept silently no-oped after a delete. The composer resolved its
course as topicNode?.course_id || selectedCourseId || null. The picker is
"Course (optional)" and starts at "", so for anyone who hasn't chosen one
the course id came entirely from the focused node — and deleting a concept
clears the focus. if (!label || !cardCourseId) return then bailed before
doing anything: no request, no toast, no rollback. resolveAddConceptCourseId
falls back to the last course that did resolve, and says so when there has
genuinely never been one. Exported, like resolveCardCourseId, so the test
exercises the resolution addConcept actually calls rather than a mirror.

Failures were unreadable..catch(() => toast.error("Couldn't save the concept — it was removed")) discarded the ApiError, giving a 401, a 404, a
500 and an unreachable backend one identical sentence. Surfacing the status
and body is what produced the 404 + request_id that ended the hunt. The
delete path had the same problem and worse: .catch(() => {}) let the node
vanish from the map while the row survived, so re-adding the same name
quietly merged into the undeleted row.

Verification

  • backend suite green on this branch; ruff check clean
  • port guard tested both ways (occupied → SystemExit, free → returns)
  • tsc --noEmit clean on the identical Learn.tsx
  • vitest cannot start on the local Node 20 (rolldown styleText crash), so
    the new specs are CI-verified only

The backend was never at fault: add → delete → re-add of the same name
creates a fresh row every time — verified against the real project.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved concept creation by retaining the previously selected course when focus is cleared.
    • Added clearer error messages when saving concepts or resolving a course fails.
    • Failed concept deletions now restore the affected content and connections when appropriate.
    • Added safeguards to prevent starting the development server when its configured port is already in use.
  • Tests
    • Added coverage for course resolution after concepts are deleted and re-added.

Darkest-Teddyand others added 3 commits August 12, 2026 10:43
The backend was never the obstacle: add -> delete -> add of the same name
creates a fresh row every time. graph_nodes has no soft delete, delete_node
hard-deletes the row and its edges, and the 0023 UNIQUE has nothing left to
collide with. Verified against the real project.
The break is in the rail's add-concept composer:
cardCourseId = topicNode?.course_id || selectedCourseId || null
...
if (!label || !cardCourseId) return;
The course picker is "Course (optional)" and starts at "" ("No course"), so
unless the user arrived with ?course= or picked one, cardCourseId comes
ENTIRELY from the focused node. Deleting a concept clears the focus — so the
delete removes the one thing supplying the course id, and the add then
returns before doing anything: no request, no toast, no rollback, the
composer just sitting there with the name typed in.
addConcept now resolves through resolveAddConceptCourseId, which falls back
to the last course that did resolve, and says so when there has genuinely
never been one instead of no-op'ing. Exported, like resolveCardCourseId, so
the test exercises the resolution addConcept actually calls rather than a
mirror of it.
Also stops removeConcept swallowing its own failures. `.catch(() => {})` is
not best-effort, it's a lie: the node vanishes from the map while the row
survives, so re-adding the same name quietly MERGES into the undeleted row
and toasts "Merged into your existing X" for a concept the user watched
themselves delete. It now restores the node and says the delete failed.
Verified with tsc --noEmit. vitest cannot start on the local Node 20
(rolldown's styleText crash), so the new specs are CI-verified only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The toast said "Couldn't save the concept — it was removed" for a 401, a
500, a 422 and an unreachable backend alike. Four different problems, four
different fixes, none of them guessable from the message — the catch
discarded the ApiError it was handed.
Chasing a real report of this cost several rounds of inference that the
status code would have ended immediately: the route itself returns 200 and
creates the row when called with the ids the client actually holds, so
whatever the browser hit is above add_node, and the toast was the only
witness. It now carries the status and the start of the body (the 500 path
includes a request_id that ties it to a log), and both handlers log the
full error to the console.
Not instrumentation to be removed later: an error message that names a
cause is what these two toasts should always have said.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The add-concept 404 was never a graph bug. A backend left running from the
MAIN working tree — older code, no create_node — was still answering :5000,
so POST /api/graph/{user_id}/nodes 404'd in the browser while returning 200
through TestClient in this worktree.
Windows lets a second process bind a port another process is already
listening on, and the FIRST binder keeps the traffic. Both restarts during
this session bound a second socket, logged "Application startup complete",
passed /api/health, and served nobody. Get-NetTCPConnection listed two
owning PIDs; the stale parent looked dead in Win32_Process while its uvicorn
reload child held the socket, so killing the parent alone did nothing.
`python main.py` now probes the port first and exits with the kill command
instead of starting a server that cannot receive a request. Dev entrypoint
only — containers and Railway import main:app and never reach it.
The tell, for next time: if a route you can read in routes/ is missing from
curl -s localhost:5000/openapi.json
you are talking to a different build, not looking at a routing bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 12, 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 Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:8 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: dcf757ba-3e52-4f90-b96f-af7d357a7767

📥 Commits

Reviewing files that changed from the base of the PR and between 76c7538 and b5c7bc9.

📒 Files selected for processing (5)
  • backend/main.py
  • backend/tests/test_port_guard.py
  • frontend/src/components/screens/Learn.addConcept.test.tsx
  • frontend/src/components/screens/Learn.graph.test.ts
  • frontend/src/components/screens/Learn.tsx
📝 Walkthrough

Walkthrough

The backend now checks development port occupancy before starting Uvicorn. The Learn screen retains course resolution after focus loss and improves concept save and deletion error handling.

Changes

Backend startup

Layer / File(s)Summary
Development port check
backend/main.py
The development entrypoint probes the configured localhost port and exits with platform-specific termination instructions when another server responds. Imported application startup remains unchanged.

Learn graph operations

Layer / File(s)Summary
Course resolution for concept creation
frontend/src/components/screens/Learn.tsx, frontend/src/components/screens/Learn.graph.test.ts
Concept creation uses the current course or the last resolved course. Tests cover fallback, current-course precedence, and the null case.
Graph error reporting and recovery
frontend/src/components/screens/Learn.tsx
Save failures include structured status or message details. Failed persisted deletions restore the concept and its edges, while temporary nodes remain removed.

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

Suggested reviewers:andresl230, jose-gael-cruz-lopez

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies both primary changes: Learn concept re-addition and stale backend process detection.
Description check✅ PassedThe description explains the bug, implementation, testing, and review context in detail, 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/learn-add-concept-and-port-guard

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 Aug 12, 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-stagingb5c7bc9Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:07 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: 2

🤖 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 `@frontend/src/components/screens/Learn.tsx`:
- Around line 1294-1295: Update the deletion error handling in Learn.tsx around
the ApiError check to include a truncated err.message alongside err.status in
the toast. Preserve the existing non-ApiError behavior and match the add failure
path’s response-detail formatting.
- Around line 1203-1206: Update Learn around resolveAddConceptCourseId and the
Add concept control so a single resolved add-course value is computed and reused
by both addConcept and the render condition, rather than gating the control
directly on cardCourseId. Preserve the fallback behavior when the picker has no
selection, and add a component test covering deletion of the focused concept
with no picker selection while confirming Add concept remains available.
🪄 Autofix

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: c58cf75a-76c6-43a2-9876-c9c6074f4f35

📥 Commits

Reviewing files that changed from the base of the PR and between 7681c48 and 76c7538.

📒 Files selected for processing (3)
  • backend/main.py
  • frontend/src/components/screens/Learn.graph.test.ts
  • frontend/src/components/screens/Learn.tsx

Comment threadfrontend/src/components/screens/Learn.tsx Outdated
Comment threadfrontend/src/components/screens/Learn.tsx Outdated
The tutor page had no quiz entry point at all — Learn.tsx did not mention
quiz anywhere. The only routes in were the nav's Quiz item, the dashboard,
the knowledge map and the notetaker, and every one of them drops the
session's context: the tutor knows exactly which concept the student is
working on, then hands them a page that asks them to pick it again.
Two buttons, one resolver:
focus card ("Quiz me") -> the rail's anchored concept
session toolbar ("Quiz me") -> the focused concept, else the session topic
`quizHref` prefers a node id, which screens/Quiz.tsx uses directly as
initialConceptId, and falls back to the topic NAME, which it resolves
against the concept list.
The placeholder guard is the part worth reading: an optimistically-added
concept carries a client-side `node-new-<ts>` id and a streamed one carries
`stream-<name>`, neither of which exists server-side. Passing either as
?concept= would preselect an id the quiz page cannot resolve — silently, as
an empty picker. Those fall through to the name, which resolves as soon as
the real row lands. Exported and tested like the other resolvers, so the
test covers what the buttons actually call.
Verified with tsc --noEmit. vitest cannot start on the local Node 20
(rolldown styleText crash), so the new specs are CI-verified only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — learn concept re-add + port guard

The diagnosis here is excellent — the "stale process on :5000" write-up is the most useful thing in the PR, and _refuse_if_port_taken is correctly scoped (dev __main__ only; backend/Dockerfile:37 runs uvicorn main:app, so containers never reach it). Replacing the delete path's .catch(() => {}) with a real restore is the right instinct. But the headline fix does not reach the reported flow: addConcept's new fallback is unreachable, because the composer that calls it is gated on the very value the fallback exists to work around. There is also a new failure mode in the delete restore (a 404 resurrects the node), a hand-rolled error formatter that duplicates lib/errorMessage.ts and prints raw JSON bodies into toasts, and ~70 lines of undocumented quiz deep-link feature in a PR whose description says it was split out to exclude quiz work.

Findings

P0

[P0] addConcept's new fallback is unreachable, and the reported bug survivesfrontend/src/components/screens/Learn.tsx:1754

{/* Add concept */}{cardCourseId&&(<divstyle={{padding: "4px 22px 24px"}}>

Both addConcept call sites live inside that block — :1763 (onKeyDown Enter) and :1780 (the Add button) — and there is no other caller in the file. So cardCourseId is always truthy when addConcept can run, which means resolveAddConceptCourseId(cardCourseId, lastCourseIdRef.current) at :1226 always returns cardCourseId. The lastCourseIdRef + effect (:474-477) and the new toast.error("Pick a course first…") branch (:1227-1230) are dead code.

Worse, the reported repro still fails. removeConcept is only reachable from the focus card — :1611 gates the button row on focusConcept, Remove is :1663-1683 — so the deleted node is always the focused one. After removal, activeFocusId = focusedNodeId ?? highlightId (:452) and highlightId is graphNodes.find(n => n.name.toLowerCase() === topic.trim().toLowerCase())?.id (:446) — the node just deleted. With selectedCourseId at its default "" (:392/:397, searchParams.get("course") ?? ""), resolveCardCourseId (:269-274) returns null and the whole "+ Add concept" affordance unmounts (block :1754-1815). The student cannot type the name back in at all, which is exactly "when deleting a concept node, i should be able to add it back of the same name by add concept".

The new specs pass because they call the exported resolver directly and never render the gate. The gate is where the fix belongs: resolve the add-course once and gate the composer on that, showing which course the concept will land in. (CodeRabbit raised the gate; this adds the proof that the fallback branch is unreachable and that the original repro is unchanged.)

P1

[P1] A 404 on delete resurrects the node and toasts a false statementfrontend/src/components/screens/Learn.tsx:1304-1318

deleteGraphNode(userId,nodeId).catch((err: unknown)=>{console.error("[removeConcept] failed",{ nodeId, err });if(!removedNode||/^(node-new-|stream-)/.test(nodeId))return;setGraphNodes(prev=>(prev.some(n=>n.id===nodeId) ? prev : [...prev,removedNode]));
...
constreason=errinstanceofApiError ? ` (${err.status})` : "";toast.error(`Couldn't delete “${removedNode.name}${reason} — it's still on your map.`);

The restore fires on any rejection, and 404 is precisely the status the endpoint returns for "the row is already gone": backend/routes/graph.py:119-125 raises HTTPException(status_code=404, …) whenever delete_node returns an error, and backend/services/graph_service.py:486-492 returns {"error": "Node not found", "deleted": False} when the owner-scoped select finds no row. So a second tab, a client-side node that is already stale, or a delete that already succeeded server-side now puts a phantom concept back on the map and tells the user "it's still on your map" when it is not. The old .catch(() => {}) produced the correct end state in exactly this case.

It compounds: delete X → 404 → user re-adds X (fresh row, new id) → the catch appends the old node object under its dead id and the map shows two "X" nodes, one of which doesn't exist. A 404 should be treated as success — nothing to restore. (The non-404 case is already handled correctly: the prev.some(n => n.id === nodeId) guard stops a double-insert when a re-add merged back into the surviving row.)

P2

[P2] Hand-rolled error copy duplicates lib/errorMessage.ts and dumps the raw response body into a toastfrontend/src/components/screens/Learn.tsx:1279-1285

constreason=errinstanceofApiError
? `${err.status}${err.message ? ` — ${err.message.slice(0,160)}` : ""}`
: errinstanceofError&&err.message
? err.message.slice(0,160)
: "the request never completed";toast.error(`Couldn't save “${label}” (${reason}).`);

ApiError.message is documented as the raw body — frontend/src/lib/api.ts:25-26: "message stays the raw body for backward compatibility" — so the toast renders Couldn't save “Markov Chains” (404 — {"detail":"Not Found"})., or 160 characters of a proxy's HTML error page. frontend/src/lib/errorMessage.ts exists for exactly this and says so in its header: "Rendering that with String(err) dumps JSON into the UI, which is what these helpers exist to prevent." Its humanizeError(err, fallback) (:156-160 — "A short sentence safe to put in a toast. Never returns … a raw JSON body") is already used across Gradebook, Calendar, Social, Study, ProfileView, the notetaker, SignInModal and QuizPanel; Learn.tsx imports nothing from it. The Engineering Style Guide's shared-primitive rule applies here the same way it does to components/ui/. It also removes the inconsistency CodeRabbit flagged — the delete toast at :1317 surfaces only the status and never the detail; both paths would read the same through one helper.

[P2] An undocumented quiz deep-link feature ships in a PR that says it excludes quiz workfrontend/src/components/screens/Learn.tsx:288-297, :1449-1457, :1640-1662

exportfunctionquizHref(conceptId: string|null|undefined,topic: string|null|undefined,): string{

quizHref plus two new "Quiz me" buttons (session toolbar :1449, focus card :1640) plus a five-case spec block (Learn.graph.test.ts:171-206) are roughly a quarter of the diff, and neither the title, the description, nor the release-note summary mentions them. The description says the opposite: "Split out of #534, which was carrying these three alongside unrelated quiz work." The code itself checks out — ?concept=/?topic= match screens/Quiz.tsx:86-94, resolveInitialSelection (lib/quizSelection.ts:71-75) degrades to {null, null} on an id it can't resolve, Icon has flask (Icon.tsx:78), .btn--sm exists (globals.css:233), and #534 carries no duplicate — but a new user-facing entry point riding along in a bugfix PR makes this hard to review and impossible to revert cleanly. Either name it in the description or move it out.

P3

[P3] A failed delete restores the node but not the focusfrontend/src/components/screens/Learn.tsx:1302

setFocusedNodeId(cur=>(cur===nodeId ? null : cur));

The catch at :1304-1318 undoes the node and edge removals but never the focus clear, so after a failed delete the concept reappears on the map while the rail's focus card is gone — and, per the P0 above, so is the Add-concept composer.

[P3] The port probe only covers IPv4 loopbackbackend/main.py:338-341

withsocket.socket(socket.AF_INET, socket.SOCK_STREAM) asprobe:
probe.settimeout(0.4)
ifprobe.connect_ex(("127.0.0.1", port)) !=0:
return

uvicorn.run(..., host="0.0.0.0") at :359 binds the IPv4 wildcard so the common case is covered, but a stale server bound to ::1 or to one specific interface passes the probe silently. Given the whole point is "tell the next person", resolving both families would close the gap.

What's good

  • The root-cause write-up in the description (two owning PIDs, the uvicorn reload child holding the socket, "if a route you can read in routes/ is missing from /openapi.json, you are talking to a different build") is worth more than the code — it belongs in CLAUDE.md or docs/local-supabase.md so it outlives the PR.
  • The guard is correctly classified and scoped: it is a dev-entrypoint TCP probe under if __name__ == "__main__", not a production origin/port check. backend/Dockerfile:37 is CMD ["uvicorn", "main:app", …] and scripts/e2e-up.sh invokes uvicorn directly, so neither containers nor the e2e lane can trip it.
  • The backend claim in the description holds: delete_node (graph_service.py:481-509) hard-deletes edges in both directions and then the row, with no soft-delete column and no unique-constraint collision on re-add — so add → delete → re-add really does create a fresh row.

Verdict: request changes — the P0 leaves the reported bug reproducible on the default path, and the P1 introduces a new map/server divergence in the opposite direction.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…p a 404 resurrecting a node
The previous pass added `resolveAddConceptCourseId` so add-concept could
survive the delete that clears the focus — and then gated the composer that
calls it on `cardCourseId`, the exact value the fallback exists to work
around. Both `addConcept` call sites lived inside that gate, so the fallback
could only ever run while `cardCourseId` was already truthy: dead code, and
the reported bug ("delete a concept, add it back under the same name")
unchanged. Deleting the focused concept with the optional course picker at
its "" default unmounted the whole "+ Add concept" affordance, so the name
could not be typed back in at all.
The gate and `addConcept` now read ONE resolved value, `addCourseId`, so
they cannot disagree again. `lastCourseId` became state rather than a ref
because the gate reads it during render, and a ref mutated in an effect
produces no re-render. The composer names the course the concept will land
in, since `addCourseId` can be the remembered fallback rather than what the
focus card shows — otherwise the destination is a silent guess.
Also in removeConcept:
- A 404 is the endpoint's answer for "that row is already gone"
(routes/graph.py::remove_node, from graph_service.delete_node's
{"error": "Node not found"}), so restoring on it put a phantom concept
back on the map under a toast asserting it was "still on your map". After
the student re-added the same name it showed two, one of which existed
nowhere. `shouldRestoreFailedDelete` draws the line; the double-insert
guard stays for genuine failures.
- A restored node now gets its focus back. Restoring the node but not the
focus left the concept on the map with the rail's focus card — and so the
Remove button and the composer — belonging to a different concept.
- Both the add-failure and delete-failure toasts go through
lib/errorMessage's `humanizeError` instead of interpolating
`ApiError.message`, which is the RAW response body: the toast used to read
`Couldn't save "X" (404 - {"detail":"Not Found"})`, or 160 characters of a
proxy's HTML error page.
Learn.addConcept.test.tsx is a component test on purpose: the gap here was
in which value the render gate reads, which no resolver-level test could
see. Each of the four fixes was verified to fail the suite when reverted.
The guard probed AF_INET/127.0.0.1 only. `localhost` resolves to both
127.0.0.1 and ::1 on any dual-stack box, and uvicorn binds whatever its
--host resolved to, so a stale server on ::1 (or on one specific interface)
walked straight past the check and the guard reported "port free" for exactly
the situation it was written to name — while `localhost:8000` in the browser
still reached the old server.
Both literals are probed now, with getaddrinfo("localhost") folded in on top
so a host whose localhost maps somewhere unusual is still covered, and an
unsupported family is skipped rather than raising. The SystemExit message
names the address that answered; without it the next person is told "already
serving" and has to guess which family to go hunting on.
Still dev-entrypoint only. tests/test_port_guard.py covers occupied IPv4,
occupied IPv6 (skipped where there is no IPv6 loopback), free, and the
message contents; the IPv6 case fails against the AF_INET-only probe.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Blocker

  • The add-concept fallback was unreachable and the reported bug still reproduced. The composer was gated on cardCourseId — the very value the fallback exists to work around — and both addConcept call sites live inside that gate, so resolveAddConceptCourseId could never return anything else and the whole "+ Add concept" affordance unmounted after deleting the focused concept. One resolved addCourseId now drives both the gate and addConcept, and the destination course is surfaced in the UI instead of being applied silently.

Major

  • A 404 on delete resurrected the node. 404 is exactly what the endpoint returns for "already gone" (routes/graph.pygraph_service.delete_node), so the restore produced a phantom concept and a toast claiming "it's still on your map" when it was not — compounding to two visible "X" nodes after a re-add. New shouldRestoreFailedDelete treats 404 as success; the genuine-failure path keeps its double-insert guard.

Minor / nits

  • Both toasts now go through lib/errorMessage.ts::humanizeError instead of printing ApiError.message — the documented raw body — into the UI. Also resolves the status-only vs body asymmetry between the add and delete paths.
  • A failed delete restores focus, not just the node and edges.
  • The dev port guard probes ::1 and getaddrinfo results as well as 127.0.0.1, and names the answering address.

Note

The quizHref / "Quiz me" deep-link code was left in place — it is correct, but it is not mentioned in the PR description, which says quiz work was split out. Worth naming in the body or moving out.

Verificationruff check . clean · 1869 passed, 49 skipped · tsc clean · 638 frontend tests pass · eslint 0 errors

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

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.

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(learn): add a concept back after deleting it, and stop a stale backend answering by Darkest-Teddy · Pull Request #536 · SaplingLearn/Sapling · GitHub
Skip to content

fix(learn): add a concept back after deleting it, and stop a stale backend answering - #536

Open
Darkest-Teddy wants to merge 7 commits into
mainfrom
fix/learn-add-concept-and-port-guard
Open

fix(learn): add a concept back after deleting it, and stop a stale backend answering#536
Darkest-Teddy wants to merge 7 commits into
mainfrom
fix/learn-add-concept-and-port-guard

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Split out of #534, which was carrying these three alongside unrelated quiz
work. Based on main; no dependency on #533/#534.

Reported as: "when deleting a concept node, i should be able to add it back
of the same name by add concept."

The reported bug was a stale server, not the graph

POST /api/graph/{user_id}/nodes returned 404 {"detail":"Not Found"} in
the browser while returning 200 through TestClient in the worktree. A backend
left running from a different working tree — older code, no create_node
was still answering :5000.

Windows lets a second process bind a port another process is already
listening on, and the first binder keeps the traffic. Restarts bound a
second socket, logged "Application startup complete", passed /api/health,
and served nobody. Get-NetTCPConnection listed two owning PIDs; the stale
parent looked dead in Win32_Process while its uvicorn reload child held
the socket, so killing the parent alone did nothing.

python main.py now probes the port first and exits with the kill command.
Dev entrypoint only — containers and Railway import main:app and never
reach it.

The tell, for next time: if a route you can read in routes/ is missing from
curl -s localhost:5000/openapi.json, you are talking to a different build.

Two real Learn bugs found on the way

Add-concept silently no-oped after a delete. The composer resolved its
course as topicNode?.course_id || selectedCourseId || null. The picker is
"Course (optional)" and starts at "", so for anyone who hasn't chosen one
the course id came entirely from the focused node — and deleting a concept
clears the focus. if (!label || !cardCourseId) return then bailed before
doing anything: no request, no toast, no rollback. resolveAddConceptCourseId
falls back to the last course that did resolve, and says so when there has
genuinely never been one. Exported, like resolveCardCourseId, so the test
exercises the resolution addConcept actually calls rather than a mirror.

Failures were unreadable..catch(() => toast.error("Couldn't save the concept — it was removed")) discarded the ApiError, giving a 401, a 404, a
500 and an unreachable backend one identical sentence. Surfacing the status
and body is what produced the 404 + request_id that ended the hunt. The
delete path had the same problem and worse: .catch(() => {}) let the node
vanish from the map while the row survived, so re-adding the same name
quietly merged into the undeleted row.

Verification

  • backend suite green on this branch; ruff check clean
  • port guard tested both ways (occupied → SystemExit, free → returns)
  • tsc --noEmit clean on the identical Learn.tsx
  • vitest cannot start on the local Node 20 (rolldown styleText crash), so
    the new specs are CI-verified only

The backend was never at fault: add → delete → re-add of the same name
creates a fresh row every time — verified against the real project.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved concept creation by retaining the previously selected course when focus is cleared.
    • Added clearer error messages when saving concepts or resolving a course fails.
    • Failed concept deletions now restore the affected content and connections when appropriate.
    • Added safeguards to prevent starting the development server when its configured port is already in use.
  • Tests
    • Added coverage for course resolution after concepts are deleted and re-added.

Darkest-Teddyand others added 3 commits August 12, 2026 10:43
The backend was never the obstacle: add -> delete -> add of the same name
creates a fresh row every time. graph_nodes has no soft delete, delete_node
hard-deletes the row and its edges, and the 0023 UNIQUE has nothing left to
collide with. Verified against the real project.
The break is in the rail's add-concept composer:
cardCourseId = topicNode?.course_id || selectedCourseId || null
...
if (!label || !cardCourseId) return;
The course picker is "Course (optional)" and starts at "" ("No course"), so
unless the user arrived with ?course= or picked one, cardCourseId comes
ENTIRELY from the focused node. Deleting a concept clears the focus — so the
delete removes the one thing supplying the course id, and the add then
returns before doing anything: no request, no toast, no rollback, the
composer just sitting there with the name typed in.
addConcept now resolves through resolveAddConceptCourseId, which falls back
to the last course that did resolve, and says so when there has genuinely
never been one instead of no-op'ing. Exported, like resolveCardCourseId, so
the test exercises the resolution addConcept actually calls rather than a
mirror of it.
Also stops removeConcept swallowing its own failures. `.catch(() => {})` is
not best-effort, it's a lie: the node vanishes from the map while the row
survives, so re-adding the same name quietly MERGES into the undeleted row
and toasts "Merged into your existing X" for a concept the user watched
themselves delete. It now restores the node and says the delete failed.
Verified with tsc --noEmit. vitest cannot start on the local Node 20
(rolldown's styleText crash), so the new specs are CI-verified only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The toast said "Couldn't save the concept — it was removed" for a 401, a
500, a 422 and an unreachable backend alike. Four different problems, four
different fixes, none of them guessable from the message — the catch
discarded the ApiError it was handed.
Chasing a real report of this cost several rounds of inference that the
status code would have ended immediately: the route itself returns 200 and
creates the row when called with the ids the client actually holds, so
whatever the browser hit is above add_node, and the toast was the only
witness. It now carries the status and the start of the body (the 500 path
includes a request_id that ties it to a log), and both handlers log the
full error to the console.
Not instrumentation to be removed later: an error message that names a
cause is what these two toasts should always have said.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The add-concept 404 was never a graph bug. A backend left running from the
MAIN working tree — older code, no create_node — was still answering :5000,
so POST /api/graph/{user_id}/nodes 404'd in the browser while returning 200
through TestClient in this worktree.
Windows lets a second process bind a port another process is already
listening on, and the FIRST binder keeps the traffic. Both restarts during
this session bound a second socket, logged "Application startup complete",
passed /api/health, and served nobody. Get-NetTCPConnection listed two
owning PIDs; the stale parent looked dead in Win32_Process while its uvicorn
reload child held the socket, so killing the parent alone did nothing.
`python main.py` now probes the port first and exits with the kill command
instead of starting a server that cannot receive a request. Dev entrypoint
only — containers and Railway import main:app and never reach it.
The tell, for next time: if a route you can read in routes/ is missing from
curl -s localhost:5000/openapi.json
you are talking to a different build, not looking at a routing bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 12, 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 Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:8 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: dcf757ba-3e52-4f90-b96f-af7d357a7767

📥 Commits

Reviewing files that changed from the base of the PR and between 76c7538 and b5c7bc9.

📒 Files selected for processing (5)
  • backend/main.py
  • backend/tests/test_port_guard.py
  • frontend/src/components/screens/Learn.addConcept.test.tsx
  • frontend/src/components/screens/Learn.graph.test.ts
  • frontend/src/components/screens/Learn.tsx
📝 Walkthrough

Walkthrough

The backend now checks development port occupancy before starting Uvicorn. The Learn screen retains course resolution after focus loss and improves concept save and deletion error handling.

Changes

Backend startup

Layer / File(s)Summary
Development port check
backend/main.py
The development entrypoint probes the configured localhost port and exits with platform-specific termination instructions when another server responds. Imported application startup remains unchanged.

Learn graph operations

Layer / File(s)Summary
Course resolution for concept creation
frontend/src/components/screens/Learn.tsx, frontend/src/components/screens/Learn.graph.test.ts
Concept creation uses the current course or the last resolved course. Tests cover fallback, current-course precedence, and the null case.
Graph error reporting and recovery
frontend/src/components/screens/Learn.tsx
Save failures include structured status or message details. Failed persisted deletions restore the concept and its edges, while temporary nodes remain removed.

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

Suggested reviewers:andresl230, jose-gael-cruz-lopez

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies both primary changes: Learn concept re-addition and stale backend process detection.
Description check✅ PassedThe description explains the bug, implementation, testing, and review context in detail, 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/learn-add-concept-and-port-guard

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 Aug 12, 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-stagingb5c7bc9Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:07 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: 2

🤖 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 `@frontend/src/components/screens/Learn.tsx`:
- Around line 1294-1295: Update the deletion error handling in Learn.tsx around
the ApiError check to include a truncated err.message alongside err.status in
the toast. Preserve the existing non-ApiError behavior and match the add failure
path’s response-detail formatting.
- Around line 1203-1206: Update Learn around resolveAddConceptCourseId and the
Add concept control so a single resolved add-course value is computed and reused
by both addConcept and the render condition, rather than gating the control
directly on cardCourseId. Preserve the fallback behavior when the picker has no
selection, and add a component test covering deletion of the focused concept
with no picker selection while confirming Add concept remains available.
🪄 Autofix

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: c58cf75a-76c6-43a2-9876-c9c6074f4f35

📥 Commits

Reviewing files that changed from the base of the PR and between 7681c48 and 76c7538.

📒 Files selected for processing (3)
  • backend/main.py
  • frontend/src/components/screens/Learn.graph.test.ts
  • frontend/src/components/screens/Learn.tsx

Comment threadfrontend/src/components/screens/Learn.tsx Outdated
Comment threadfrontend/src/components/screens/Learn.tsx Outdated
The tutor page had no quiz entry point at all — Learn.tsx did not mention
quiz anywhere. The only routes in were the nav's Quiz item, the dashboard,
the knowledge map and the notetaker, and every one of them drops the
session's context: the tutor knows exactly which concept the student is
working on, then hands them a page that asks them to pick it again.
Two buttons, one resolver:
focus card ("Quiz me") -> the rail's anchored concept
session toolbar ("Quiz me") -> the focused concept, else the session topic
`quizHref` prefers a node id, which screens/Quiz.tsx uses directly as
initialConceptId, and falls back to the topic NAME, which it resolves
against the concept list.
The placeholder guard is the part worth reading: an optimistically-added
concept carries a client-side `node-new-<ts>` id and a streamed one carries
`stream-<name>`, neither of which exists server-side. Passing either as
?concept= would preselect an id the quiz page cannot resolve — silently, as
an empty picker. Those fall through to the name, which resolves as soon as
the real row lands. Exported and tested like the other resolvers, so the
test covers what the buttons actually call.
Verified with tsc --noEmit. vitest cannot start on the local Node 20
(rolldown styleText crash), so the new specs are CI-verified only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — learn concept re-add + port guard

The diagnosis here is excellent — the "stale process on :5000" write-up is the most useful thing in the PR, and _refuse_if_port_taken is correctly scoped (dev __main__ only; backend/Dockerfile:37 runs uvicorn main:app, so containers never reach it). Replacing the delete path's .catch(() => {}) with a real restore is the right instinct. But the headline fix does not reach the reported flow: addConcept's new fallback is unreachable, because the composer that calls it is gated on the very value the fallback exists to work around. There is also a new failure mode in the delete restore (a 404 resurrects the node), a hand-rolled error formatter that duplicates lib/errorMessage.ts and prints raw JSON bodies into toasts, and ~70 lines of undocumented quiz deep-link feature in a PR whose description says it was split out to exclude quiz work.

Findings

P0

[P0] addConcept's new fallback is unreachable, and the reported bug survivesfrontend/src/components/screens/Learn.tsx:1754

{/* Add concept */}{cardCourseId&&(<divstyle={{padding: "4px 22px 24px"}}>

Both addConcept call sites live inside that block — :1763 (onKeyDown Enter) and :1780 (the Add button) — and there is no other caller in the file. So cardCourseId is always truthy when addConcept can run, which means resolveAddConceptCourseId(cardCourseId, lastCourseIdRef.current) at :1226 always returns cardCourseId. The lastCourseIdRef + effect (:474-477) and the new toast.error("Pick a course first…") branch (:1227-1230) are dead code.

Worse, the reported repro still fails. removeConcept is only reachable from the focus card — :1611 gates the button row on focusConcept, Remove is :1663-1683 — so the deleted node is always the focused one. After removal, activeFocusId = focusedNodeId ?? highlightId (:452) and highlightId is graphNodes.find(n => n.name.toLowerCase() === topic.trim().toLowerCase())?.id (:446) — the node just deleted. With selectedCourseId at its default "" (:392/:397, searchParams.get("course") ?? ""), resolveCardCourseId (:269-274) returns null and the whole "+ Add concept" affordance unmounts (block :1754-1815). The student cannot type the name back in at all, which is exactly "when deleting a concept node, i should be able to add it back of the same name by add concept".

The new specs pass because they call the exported resolver directly and never render the gate. The gate is where the fix belongs: resolve the add-course once and gate the composer on that, showing which course the concept will land in. (CodeRabbit raised the gate; this adds the proof that the fallback branch is unreachable and that the original repro is unchanged.)

P1

[P1] A 404 on delete resurrects the node and toasts a false statementfrontend/src/components/screens/Learn.tsx:1304-1318

deleteGraphNode(userId,nodeId).catch((err: unknown)=>{console.error("[removeConcept] failed",{ nodeId, err });if(!removedNode||/^(node-new-|stream-)/.test(nodeId))return;setGraphNodes(prev=>(prev.some(n=>n.id===nodeId) ? prev : [...prev,removedNode]));
...
constreason=errinstanceofApiError ? ` (${err.status})` : "";toast.error(`Couldn't delete “${removedNode.name}${reason} — it's still on your map.`);

The restore fires on any rejection, and 404 is precisely the status the endpoint returns for "the row is already gone": backend/routes/graph.py:119-125 raises HTTPException(status_code=404, …) whenever delete_node returns an error, and backend/services/graph_service.py:486-492 returns {"error": "Node not found", "deleted": False} when the owner-scoped select finds no row. So a second tab, a client-side node that is already stale, or a delete that already succeeded server-side now puts a phantom concept back on the map and tells the user "it's still on your map" when it is not. The old .catch(() => {}) produced the correct end state in exactly this case.

It compounds: delete X → 404 → user re-adds X (fresh row, new id) → the catch appends the old node object under its dead id and the map shows two "X" nodes, one of which doesn't exist. A 404 should be treated as success — nothing to restore. (The non-404 case is already handled correctly: the prev.some(n => n.id === nodeId) guard stops a double-insert when a re-add merged back into the surviving row.)

P2

[P2] Hand-rolled error copy duplicates lib/errorMessage.ts and dumps the raw response body into a toastfrontend/src/components/screens/Learn.tsx:1279-1285

constreason=errinstanceofApiError
? `${err.status}${err.message ? ` — ${err.message.slice(0,160)}` : ""}`
: errinstanceofError&&err.message
? err.message.slice(0,160)
: "the request never completed";toast.error(`Couldn't save “${label}” (${reason}).`);

ApiError.message is documented as the raw body — frontend/src/lib/api.ts:25-26: "message stays the raw body for backward compatibility" — so the toast renders Couldn't save “Markov Chains” (404 — {"detail":"Not Found"})., or 160 characters of a proxy's HTML error page. frontend/src/lib/errorMessage.ts exists for exactly this and says so in its header: "Rendering that with String(err) dumps JSON into the UI, which is what these helpers exist to prevent." Its humanizeError(err, fallback) (:156-160 — "A short sentence safe to put in a toast. Never returns … a raw JSON body") is already used across Gradebook, Calendar, Social, Study, ProfileView, the notetaker, SignInModal and QuizPanel; Learn.tsx imports nothing from it. The Engineering Style Guide's shared-primitive rule applies here the same way it does to components/ui/. It also removes the inconsistency CodeRabbit flagged — the delete toast at :1317 surfaces only the status and never the detail; both paths would read the same through one helper.

[P2] An undocumented quiz deep-link feature ships in a PR that says it excludes quiz workfrontend/src/components/screens/Learn.tsx:288-297, :1449-1457, :1640-1662

exportfunctionquizHref(conceptId: string|null|undefined,topic: string|null|undefined,): string{

quizHref plus two new "Quiz me" buttons (session toolbar :1449, focus card :1640) plus a five-case spec block (Learn.graph.test.ts:171-206) are roughly a quarter of the diff, and neither the title, the description, nor the release-note summary mentions them. The description says the opposite: "Split out of #534, which was carrying these three alongside unrelated quiz work." The code itself checks out — ?concept=/?topic= match screens/Quiz.tsx:86-94, resolveInitialSelection (lib/quizSelection.ts:71-75) degrades to {null, null} on an id it can't resolve, Icon has flask (Icon.tsx:78), .btn--sm exists (globals.css:233), and #534 carries no duplicate — but a new user-facing entry point riding along in a bugfix PR makes this hard to review and impossible to revert cleanly. Either name it in the description or move it out.

P3

[P3] A failed delete restores the node but not the focusfrontend/src/components/screens/Learn.tsx:1302

setFocusedNodeId(cur=>(cur===nodeId ? null : cur));

The catch at :1304-1318 undoes the node and edge removals but never the focus clear, so after a failed delete the concept reappears on the map while the rail's focus card is gone — and, per the P0 above, so is the Add-concept composer.

[P3] The port probe only covers IPv4 loopbackbackend/main.py:338-341

withsocket.socket(socket.AF_INET, socket.SOCK_STREAM) asprobe:
probe.settimeout(0.4)
ifprobe.connect_ex(("127.0.0.1", port)) !=0:
return

uvicorn.run(..., host="0.0.0.0") at :359 binds the IPv4 wildcard so the common case is covered, but a stale server bound to ::1 or to one specific interface passes the probe silently. Given the whole point is "tell the next person", resolving both families would close the gap.

What's good

  • The root-cause write-up in the description (two owning PIDs, the uvicorn reload child holding the socket, "if a route you can read in routes/ is missing from /openapi.json, you are talking to a different build") is worth more than the code — it belongs in CLAUDE.md or docs/local-supabase.md so it outlives the PR.
  • The guard is correctly classified and scoped: it is a dev-entrypoint TCP probe under if __name__ == "__main__", not a production origin/port check. backend/Dockerfile:37 is CMD ["uvicorn", "main:app", …] and scripts/e2e-up.sh invokes uvicorn directly, so neither containers nor the e2e lane can trip it.
  • The backend claim in the description holds: delete_node (graph_service.py:481-509) hard-deletes edges in both directions and then the row, with no soft-delete column and no unique-constraint collision on re-add — so add → delete → re-add really does create a fresh row.

Verdict: request changes — the P0 leaves the reported bug reproducible on the default path, and the P1 introduces a new map/server divergence in the opposite direction.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…p a 404 resurrecting a node
The previous pass added `resolveAddConceptCourseId` so add-concept could
survive the delete that clears the focus — and then gated the composer that
calls it on `cardCourseId`, the exact value the fallback exists to work
around. Both `addConcept` call sites lived inside that gate, so the fallback
could only ever run while `cardCourseId` was already truthy: dead code, and
the reported bug ("delete a concept, add it back under the same name")
unchanged. Deleting the focused concept with the optional course picker at
its "" default unmounted the whole "+ Add concept" affordance, so the name
could not be typed back in at all.
The gate and `addConcept` now read ONE resolved value, `addCourseId`, so
they cannot disagree again. `lastCourseId` became state rather than a ref
because the gate reads it during render, and a ref mutated in an effect
produces no re-render. The composer names the course the concept will land
in, since `addCourseId` can be the remembered fallback rather than what the
focus card shows — otherwise the destination is a silent guess.
Also in removeConcept:
- A 404 is the endpoint's answer for "that row is already gone"
(routes/graph.py::remove_node, from graph_service.delete_node's
{"error": "Node not found"}), so restoring on it put a phantom concept
back on the map under a toast asserting it was "still on your map". After
the student re-added the same name it showed two, one of which existed
nowhere. `shouldRestoreFailedDelete` draws the line; the double-insert
guard stays for genuine failures.
- A restored node now gets its focus back. Restoring the node but not the
focus left the concept on the map with the rail's focus card — and so the
Remove button and the composer — belonging to a different concept.
- Both the add-failure and delete-failure toasts go through
lib/errorMessage's `humanizeError` instead of interpolating
`ApiError.message`, which is the RAW response body: the toast used to read
`Couldn't save "X" (404 - {"detail":"Not Found"})`, or 160 characters of a
proxy's HTML error page.
Learn.addConcept.test.tsx is a component test on purpose: the gap here was
in which value the render gate reads, which no resolver-level test could
see. Each of the four fixes was verified to fail the suite when reverted.
The guard probed AF_INET/127.0.0.1 only. `localhost` resolves to both
127.0.0.1 and ::1 on any dual-stack box, and uvicorn binds whatever its
--host resolved to, so a stale server on ::1 (or on one specific interface)
walked straight past the check and the guard reported "port free" for exactly
the situation it was written to name — while `localhost:8000` in the browser
still reached the old server.
Both literals are probed now, with getaddrinfo("localhost") folded in on top
so a host whose localhost maps somewhere unusual is still covered, and an
unsupported family is skipped rather than raising. The SystemExit message
names the address that answered; without it the next person is told "already
serving" and has to guess which family to go hunting on.
Still dev-entrypoint only. tests/test_port_guard.py covers occupied IPv4,
occupied IPv6 (skipped where there is no IPv6 loopback), free, and the
message contents; the IPv6 case fails against the AF_INET-only probe.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Blocker

  • The add-concept fallback was unreachable and the reported bug still reproduced. The composer was gated on cardCourseId — the very value the fallback exists to work around — and both addConcept call sites live inside that gate, so resolveAddConceptCourseId could never return anything else and the whole "+ Add concept" affordance unmounted after deleting the focused concept. One resolved addCourseId now drives both the gate and addConcept, and the destination course is surfaced in the UI instead of being applied silently.

Major

  • A 404 on delete resurrected the node. 404 is exactly what the endpoint returns for "already gone" (routes/graph.pygraph_service.delete_node), so the restore produced a phantom concept and a toast claiming "it's still on your map" when it was not — compounding to two visible "X" nodes after a re-add. New shouldRestoreFailedDelete treats 404 as success; the genuine-failure path keeps its double-insert guard.

Minor / nits

  • Both toasts now go through lib/errorMessage.ts::humanizeError instead of printing ApiError.message — the documented raw body — into the UI. Also resolves the status-only vs body asymmetry between the add and delete paths.
  • A failed delete restores focus, not just the node and edges.
  • The dev port guard probes ::1 and getaddrinfo results as well as 127.0.0.1, and names the answering address.

Note

The quizHref / "Quiz me" deep-link code was left in place — it is correct, but it is not mentioned in the PR description, which says quiz work was split out. Worth naming in the body or moving out.

Verificationruff check . clean · 1869 passed, 49 skipped · tsc clean · 638 frontend tests pass · eslint 0 errors

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

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.

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(learn): add a concept back after deleting it, and stop a stale backend answering by Darkest-Teddy · Pull Request #536 · SaplingLearn/Sapling · GitHub
Skip to content

fix(learn): add a concept back after deleting it, and stop a stale backend answering - #536

Open
Darkest-Teddy wants to merge 7 commits into
mainfrom
fix/learn-add-concept-and-port-guard
Open

fix(learn): add a concept back after deleting it, and stop a stale backend answering#536
Darkest-Teddy wants to merge 7 commits into
mainfrom
fix/learn-add-concept-and-port-guard

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Split out of #534, which was carrying these three alongside unrelated quiz
work. Based on main; no dependency on #533/#534.

Reported as: "when deleting a concept node, i should be able to add it back
of the same name by add concept."

The reported bug was a stale server, not the graph

POST /api/graph/{user_id}/nodes returned 404 {"detail":"Not Found"} in
the browser while returning 200 through TestClient in the worktree. A backend
left running from a different working tree — older code, no create_node
was still answering :5000.

Windows lets a second process bind a port another process is already
listening on, and the first binder keeps the traffic. Restarts bound a
second socket, logged "Application startup complete", passed /api/health,
and served nobody. Get-NetTCPConnection listed two owning PIDs; the stale
parent looked dead in Win32_Process while its uvicorn reload child held
the socket, so killing the parent alone did nothing.

python main.py now probes the port first and exits with the kill command.
Dev entrypoint only — containers and Railway import main:app and never
reach it.

The tell, for next time: if a route you can read in routes/ is missing from
curl -s localhost:5000/openapi.json, you are talking to a different build.

Two real Learn bugs found on the way

Add-concept silently no-oped after a delete. The composer resolved its
course as topicNode?.course_id || selectedCourseId || null. The picker is
"Course (optional)" and starts at "", so for anyone who hasn't chosen one
the course id came entirely from the focused node — and deleting a concept
clears the focus. if (!label || !cardCourseId) return then bailed before
doing anything: no request, no toast, no rollback. resolveAddConceptCourseId
falls back to the last course that did resolve, and says so when there has
genuinely never been one. Exported, like resolveCardCourseId, so the test
exercises the resolution addConcept actually calls rather than a mirror.

Failures were unreadable..catch(() => toast.error("Couldn't save the concept — it was removed")) discarded the ApiError, giving a 401, a 404, a
500 and an unreachable backend one identical sentence. Surfacing the status
and body is what produced the 404 + request_id that ended the hunt. The
delete path had the same problem and worse: .catch(() => {}) let the node
vanish from the map while the row survived, so re-adding the same name
quietly merged into the undeleted row.

Verification

  • backend suite green on this branch; ruff check clean
  • port guard tested both ways (occupied → SystemExit, free → returns)
  • tsc --noEmit clean on the identical Learn.tsx
  • vitest cannot start on the local Node 20 (rolldown styleText crash), so
    the new specs are CI-verified only

The backend was never at fault: add → delete → re-add of the same name
creates a fresh row every time — verified against the real project.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved concept creation by retaining the previously selected course when focus is cleared.
    • Added clearer error messages when saving concepts or resolving a course fails.
    • Failed concept deletions now restore the affected content and connections when appropriate.
    • Added safeguards to prevent starting the development server when its configured port is already in use.
  • Tests
    • Added coverage for course resolution after concepts are deleted and re-added.

Darkest-Teddyand others added 3 commits August 12, 2026 10:43
The backend was never the obstacle: add -> delete -> add of the same name
creates a fresh row every time. graph_nodes has no soft delete, delete_node
hard-deletes the row and its edges, and the 0023 UNIQUE has nothing left to
collide with. Verified against the real project.
The break is in the rail's add-concept composer:
cardCourseId = topicNode?.course_id || selectedCourseId || null
...
if (!label || !cardCourseId) return;
The course picker is "Course (optional)" and starts at "" ("No course"), so
unless the user arrived with ?course= or picked one, cardCourseId comes
ENTIRELY from the focused node. Deleting a concept clears the focus — so the
delete removes the one thing supplying the course id, and the add then
returns before doing anything: no request, no toast, no rollback, the
composer just sitting there with the name typed in.
addConcept now resolves through resolveAddConceptCourseId, which falls back
to the last course that did resolve, and says so when there has genuinely
never been one instead of no-op'ing. Exported, like resolveCardCourseId, so
the test exercises the resolution addConcept actually calls rather than a
mirror of it.
Also stops removeConcept swallowing its own failures. `.catch(() => {})` is
not best-effort, it's a lie: the node vanishes from the map while the row
survives, so re-adding the same name quietly MERGES into the undeleted row
and toasts "Merged into your existing X" for a concept the user watched
themselves delete. It now restores the node and says the delete failed.
Verified with tsc --noEmit. vitest cannot start on the local Node 20
(rolldown's styleText crash), so the new specs are CI-verified only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The toast said "Couldn't save the concept — it was removed" for a 401, a
500, a 422 and an unreachable backend alike. Four different problems, four
different fixes, none of them guessable from the message — the catch
discarded the ApiError it was handed.
Chasing a real report of this cost several rounds of inference that the
status code would have ended immediately: the route itself returns 200 and
creates the row when called with the ids the client actually holds, so
whatever the browser hit is above add_node, and the toast was the only
witness. It now carries the status and the start of the body (the 500 path
includes a request_id that ties it to a log), and both handlers log the
full error to the console.
Not instrumentation to be removed later: an error message that names a
cause is what these two toasts should always have said.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The add-concept 404 was never a graph bug. A backend left running from the
MAIN working tree — older code, no create_node — was still answering :5000,
so POST /api/graph/{user_id}/nodes 404'd in the browser while returning 200
through TestClient in this worktree.
Windows lets a second process bind a port another process is already
listening on, and the FIRST binder keeps the traffic. Both restarts during
this session bound a second socket, logged "Application startup complete",
passed /api/health, and served nobody. Get-NetTCPConnection listed two
owning PIDs; the stale parent looked dead in Win32_Process while its uvicorn
reload child held the socket, so killing the parent alone did nothing.
`python main.py` now probes the port first and exits with the kill command
instead of starting a server that cannot receive a request. Dev entrypoint
only — containers and Railway import main:app and never reach it.
The tell, for next time: if a route you can read in routes/ is missing from
curl -s localhost:5000/openapi.json
you are talking to a different build, not looking at a routing bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 12, 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 Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:8 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: dcf757ba-3e52-4f90-b96f-af7d357a7767

📥 Commits

Reviewing files that changed from the base of the PR and between 76c7538 and b5c7bc9.

📒 Files selected for processing (5)
  • backend/main.py
  • backend/tests/test_port_guard.py
  • frontend/src/components/screens/Learn.addConcept.test.tsx
  • frontend/src/components/screens/Learn.graph.test.ts
  • frontend/src/components/screens/Learn.tsx
📝 Walkthrough

Walkthrough

The backend now checks development port occupancy before starting Uvicorn. The Learn screen retains course resolution after focus loss and improves concept save and deletion error handling.

Changes

Backend startup

Layer / File(s)Summary
Development port check
backend/main.py
The development entrypoint probes the configured localhost port and exits with platform-specific termination instructions when another server responds. Imported application startup remains unchanged.

Learn graph operations

Layer / File(s)Summary
Course resolution for concept creation
frontend/src/components/screens/Learn.tsx, frontend/src/components/screens/Learn.graph.test.ts
Concept creation uses the current course or the last resolved course. Tests cover fallback, current-course precedence, and the null case.
Graph error reporting and recovery
frontend/src/components/screens/Learn.tsx
Save failures include structured status or message details. Failed persisted deletions restore the concept and its edges, while temporary nodes remain removed.

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

Suggested reviewers:andresl230, jose-gael-cruz-lopez

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies both primary changes: Learn concept re-addition and stale backend process detection.
Description check✅ PassedThe description explains the bug, implementation, testing, and review context in detail, 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/learn-add-concept-and-port-guard

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 Aug 12, 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-stagingb5c7bc9Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:07 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: 2

🤖 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 `@frontend/src/components/screens/Learn.tsx`:
- Around line 1294-1295: Update the deletion error handling in Learn.tsx around
the ApiError check to include a truncated err.message alongside err.status in
the toast. Preserve the existing non-ApiError behavior and match the add failure
path’s response-detail formatting.
- Around line 1203-1206: Update Learn around resolveAddConceptCourseId and the
Add concept control so a single resolved add-course value is computed and reused
by both addConcept and the render condition, rather than gating the control
directly on cardCourseId. Preserve the fallback behavior when the picker has no
selection, and add a component test covering deletion of the focused concept
with no picker selection while confirming Add concept remains available.
🪄 Autofix

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: c58cf75a-76c6-43a2-9876-c9c6074f4f35

📥 Commits

Reviewing files that changed from the base of the PR and between 7681c48 and 76c7538.

📒 Files selected for processing (3)
  • backend/main.py
  • frontend/src/components/screens/Learn.graph.test.ts
  • frontend/src/components/screens/Learn.tsx

Comment threadfrontend/src/components/screens/Learn.tsx Outdated
Comment threadfrontend/src/components/screens/Learn.tsx Outdated
The tutor page had no quiz entry point at all — Learn.tsx did not mention
quiz anywhere. The only routes in were the nav's Quiz item, the dashboard,
the knowledge map and the notetaker, and every one of them drops the
session's context: the tutor knows exactly which concept the student is
working on, then hands them a page that asks them to pick it again.
Two buttons, one resolver:
focus card ("Quiz me") -> the rail's anchored concept
session toolbar ("Quiz me") -> the focused concept, else the session topic
`quizHref` prefers a node id, which screens/Quiz.tsx uses directly as
initialConceptId, and falls back to the topic NAME, which it resolves
against the concept list.
The placeholder guard is the part worth reading: an optimistically-added
concept carries a client-side `node-new-<ts>` id and a streamed one carries
`stream-<name>`, neither of which exists server-side. Passing either as
?concept= would preselect an id the quiz page cannot resolve — silently, as
an empty picker. Those fall through to the name, which resolves as soon as
the real row lands. Exported and tested like the other resolvers, so the
test covers what the buttons actually call.
Verified with tsc --noEmit. vitest cannot start on the local Node 20
(rolldown styleText crash), so the new specs are CI-verified only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — learn concept re-add + port guard

The diagnosis here is excellent — the "stale process on :5000" write-up is the most useful thing in the PR, and _refuse_if_port_taken is correctly scoped (dev __main__ only; backend/Dockerfile:37 runs uvicorn main:app, so containers never reach it). Replacing the delete path's .catch(() => {}) with a real restore is the right instinct. But the headline fix does not reach the reported flow: addConcept's new fallback is unreachable, because the composer that calls it is gated on the very value the fallback exists to work around. There is also a new failure mode in the delete restore (a 404 resurrects the node), a hand-rolled error formatter that duplicates lib/errorMessage.ts and prints raw JSON bodies into toasts, and ~70 lines of undocumented quiz deep-link feature in a PR whose description says it was split out to exclude quiz work.

Findings

P0

[P0] addConcept's new fallback is unreachable, and the reported bug survivesfrontend/src/components/screens/Learn.tsx:1754

{/* Add concept */}{cardCourseId&&(<divstyle={{padding: "4px 22px 24px"}}>

Both addConcept call sites live inside that block — :1763 (onKeyDown Enter) and :1780 (the Add button) — and there is no other caller in the file. So cardCourseId is always truthy when addConcept can run, which means resolveAddConceptCourseId(cardCourseId, lastCourseIdRef.current) at :1226 always returns cardCourseId. The lastCourseIdRef + effect (:474-477) and the new toast.error("Pick a course first…") branch (:1227-1230) are dead code.

Worse, the reported repro still fails. removeConcept is only reachable from the focus card — :1611 gates the button row on focusConcept, Remove is :1663-1683 — so the deleted node is always the focused one. After removal, activeFocusId = focusedNodeId ?? highlightId (:452) and highlightId is graphNodes.find(n => n.name.toLowerCase() === topic.trim().toLowerCase())?.id (:446) — the node just deleted. With selectedCourseId at its default "" (:392/:397, searchParams.get("course") ?? ""), resolveCardCourseId (:269-274) returns null and the whole "+ Add concept" affordance unmounts (block :1754-1815). The student cannot type the name back in at all, which is exactly "when deleting a concept node, i should be able to add it back of the same name by add concept".

The new specs pass because they call the exported resolver directly and never render the gate. The gate is where the fix belongs: resolve the add-course once and gate the composer on that, showing which course the concept will land in. (CodeRabbit raised the gate; this adds the proof that the fallback branch is unreachable and that the original repro is unchanged.)

P1

[P1] A 404 on delete resurrects the node and toasts a false statementfrontend/src/components/screens/Learn.tsx:1304-1318

deleteGraphNode(userId,nodeId).catch((err: unknown)=>{console.error("[removeConcept] failed",{ nodeId, err });if(!removedNode||/^(node-new-|stream-)/.test(nodeId))return;setGraphNodes(prev=>(prev.some(n=>n.id===nodeId) ? prev : [...prev,removedNode]));
...
constreason=errinstanceofApiError ? ` (${err.status})` : "";toast.error(`Couldn't delete “${removedNode.name}${reason} — it's still on your map.`);

The restore fires on any rejection, and 404 is precisely the status the endpoint returns for "the row is already gone": backend/routes/graph.py:119-125 raises HTTPException(status_code=404, …) whenever delete_node returns an error, and backend/services/graph_service.py:486-492 returns {"error": "Node not found", "deleted": False} when the owner-scoped select finds no row. So a second tab, a client-side node that is already stale, or a delete that already succeeded server-side now puts a phantom concept back on the map and tells the user "it's still on your map" when it is not. The old .catch(() => {}) produced the correct end state in exactly this case.

It compounds: delete X → 404 → user re-adds X (fresh row, new id) → the catch appends the old node object under its dead id and the map shows two "X" nodes, one of which doesn't exist. A 404 should be treated as success — nothing to restore. (The non-404 case is already handled correctly: the prev.some(n => n.id === nodeId) guard stops a double-insert when a re-add merged back into the surviving row.)

P2

[P2] Hand-rolled error copy duplicates lib/errorMessage.ts and dumps the raw response body into a toastfrontend/src/components/screens/Learn.tsx:1279-1285

constreason=errinstanceofApiError
? `${err.status}${err.message ? ` — ${err.message.slice(0,160)}` : ""}`
: errinstanceofError&&err.message
? err.message.slice(0,160)
: "the request never completed";toast.error(`Couldn't save “${label}” (${reason}).`);

ApiError.message is documented as the raw body — frontend/src/lib/api.ts:25-26: "message stays the raw body for backward compatibility" — so the toast renders Couldn't save “Markov Chains” (404 — {"detail":"Not Found"})., or 160 characters of a proxy's HTML error page. frontend/src/lib/errorMessage.ts exists for exactly this and says so in its header: "Rendering that with String(err) dumps JSON into the UI, which is what these helpers exist to prevent." Its humanizeError(err, fallback) (:156-160 — "A short sentence safe to put in a toast. Never returns … a raw JSON body") is already used across Gradebook, Calendar, Social, Study, ProfileView, the notetaker, SignInModal and QuizPanel; Learn.tsx imports nothing from it. The Engineering Style Guide's shared-primitive rule applies here the same way it does to components/ui/. It also removes the inconsistency CodeRabbit flagged — the delete toast at :1317 surfaces only the status and never the detail; both paths would read the same through one helper.

[P2] An undocumented quiz deep-link feature ships in a PR that says it excludes quiz workfrontend/src/components/screens/Learn.tsx:288-297, :1449-1457, :1640-1662

exportfunctionquizHref(conceptId: string|null|undefined,topic: string|null|undefined,): string{

quizHref plus two new "Quiz me" buttons (session toolbar :1449, focus card :1640) plus a five-case spec block (Learn.graph.test.ts:171-206) are roughly a quarter of the diff, and neither the title, the description, nor the release-note summary mentions them. The description says the opposite: "Split out of #534, which was carrying these three alongside unrelated quiz work." The code itself checks out — ?concept=/?topic= match screens/Quiz.tsx:86-94, resolveInitialSelection (lib/quizSelection.ts:71-75) degrades to {null, null} on an id it can't resolve, Icon has flask (Icon.tsx:78), .btn--sm exists (globals.css:233), and #534 carries no duplicate — but a new user-facing entry point riding along in a bugfix PR makes this hard to review and impossible to revert cleanly. Either name it in the description or move it out.

P3

[P3] A failed delete restores the node but not the focusfrontend/src/components/screens/Learn.tsx:1302

setFocusedNodeId(cur=>(cur===nodeId ? null : cur));

The catch at :1304-1318 undoes the node and edge removals but never the focus clear, so after a failed delete the concept reappears on the map while the rail's focus card is gone — and, per the P0 above, so is the Add-concept composer.

[P3] The port probe only covers IPv4 loopbackbackend/main.py:338-341

withsocket.socket(socket.AF_INET, socket.SOCK_STREAM) asprobe:
probe.settimeout(0.4)
ifprobe.connect_ex(("127.0.0.1", port)) !=0:
return

uvicorn.run(..., host="0.0.0.0") at :359 binds the IPv4 wildcard so the common case is covered, but a stale server bound to ::1 or to one specific interface passes the probe silently. Given the whole point is "tell the next person", resolving both families would close the gap.

What's good

  • The root-cause write-up in the description (two owning PIDs, the uvicorn reload child holding the socket, "if a route you can read in routes/ is missing from /openapi.json, you are talking to a different build") is worth more than the code — it belongs in CLAUDE.md or docs/local-supabase.md so it outlives the PR.
  • The guard is correctly classified and scoped: it is a dev-entrypoint TCP probe under if __name__ == "__main__", not a production origin/port check. backend/Dockerfile:37 is CMD ["uvicorn", "main:app", …] and scripts/e2e-up.sh invokes uvicorn directly, so neither containers nor the e2e lane can trip it.
  • The backend claim in the description holds: delete_node (graph_service.py:481-509) hard-deletes edges in both directions and then the row, with no soft-delete column and no unique-constraint collision on re-add — so add → delete → re-add really does create a fresh row.

Verdict: request changes — the P0 leaves the reported bug reproducible on the default path, and the P1 introduces a new map/server divergence in the opposite direction.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…p a 404 resurrecting a node
The previous pass added `resolveAddConceptCourseId` so add-concept could
survive the delete that clears the focus — and then gated the composer that
calls it on `cardCourseId`, the exact value the fallback exists to work
around. Both `addConcept` call sites lived inside that gate, so the fallback
could only ever run while `cardCourseId` was already truthy: dead code, and
the reported bug ("delete a concept, add it back under the same name")
unchanged. Deleting the focused concept with the optional course picker at
its "" default unmounted the whole "+ Add concept" affordance, so the name
could not be typed back in at all.
The gate and `addConcept` now read ONE resolved value, `addCourseId`, so
they cannot disagree again. `lastCourseId` became state rather than a ref
because the gate reads it during render, and a ref mutated in an effect
produces no re-render. The composer names the course the concept will land
in, since `addCourseId` can be the remembered fallback rather than what the
focus card shows — otherwise the destination is a silent guess.
Also in removeConcept:
- A 404 is the endpoint's answer for "that row is already gone"
(routes/graph.py::remove_node, from graph_service.delete_node's
{"error": "Node not found"}), so restoring on it put a phantom concept
back on the map under a toast asserting it was "still on your map". After
the student re-added the same name it showed two, one of which existed
nowhere. `shouldRestoreFailedDelete` draws the line; the double-insert
guard stays for genuine failures.
- A restored node now gets its focus back. Restoring the node but not the
focus left the concept on the map with the rail's focus card — and so the
Remove button and the composer — belonging to a different concept.
- Both the add-failure and delete-failure toasts go through
lib/errorMessage's `humanizeError` instead of interpolating
`ApiError.message`, which is the RAW response body: the toast used to read
`Couldn't save "X" (404 - {"detail":"Not Found"})`, or 160 characters of a
proxy's HTML error page.
Learn.addConcept.test.tsx is a component test on purpose: the gap here was
in which value the render gate reads, which no resolver-level test could
see. Each of the four fixes was verified to fail the suite when reverted.
The guard probed AF_INET/127.0.0.1 only. `localhost` resolves to both
127.0.0.1 and ::1 on any dual-stack box, and uvicorn binds whatever its
--host resolved to, so a stale server on ::1 (or on one specific interface)
walked straight past the check and the guard reported "port free" for exactly
the situation it was written to name — while `localhost:8000` in the browser
still reached the old server.
Both literals are probed now, with getaddrinfo("localhost") folded in on top
so a host whose localhost maps somewhere unusual is still covered, and an
unsupported family is skipped rather than raising. The SystemExit message
names the address that answered; without it the next person is told "already
serving" and has to guess which family to go hunting on.
Still dev-entrypoint only. tests/test_port_guard.py covers occupied IPv4,
occupied IPv6 (skipped where there is no IPv6 loopback), free, and the
message contents; the IPv6 case fails against the AF_INET-only probe.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Blocker

  • The add-concept fallback was unreachable and the reported bug still reproduced. The composer was gated on cardCourseId — the very value the fallback exists to work around — and both addConcept call sites live inside that gate, so resolveAddConceptCourseId could never return anything else and the whole "+ Add concept" affordance unmounted after deleting the focused concept. One resolved addCourseId now drives both the gate and addConcept, and the destination course is surfaced in the UI instead of being applied silently.

Major

  • A 404 on delete resurrected the node. 404 is exactly what the endpoint returns for "already gone" (routes/graph.pygraph_service.delete_node), so the restore produced a phantom concept and a toast claiming "it's still on your map" when it was not — compounding to two visible "X" nodes after a re-add. New shouldRestoreFailedDelete treats 404 as success; the genuine-failure path keeps its double-insert guard.

Minor / nits

  • Both toasts now go through lib/errorMessage.ts::humanizeError instead of printing ApiError.message — the documented raw body — into the UI. Also resolves the status-only vs body asymmetry between the add and delete paths.
  • A failed delete restores focus, not just the node and edges.
  • The dev port guard probes ::1 and getaddrinfo results as well as 127.0.0.1, and names the answering address.

Note

The quizHref / "Quiz me" deep-link code was left in place — it is correct, but it is not mentioned in the PR description, which says quiz work was split out. Worth naming in the body or moving out.

Verificationruff check . clean · 1869 passed, 49 skipped · tsc clean · 638 frontend tests pass · eslint 0 errors

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

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.

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(learn): add a concept back after deleting it, and stop a stale backend answering by Darkest-Teddy · Pull Request #536 · SaplingLearn/Sapling · GitHub
Skip to content

fix(learn): add a concept back after deleting it, and stop a stale backend answering - #536

Open
Darkest-Teddy wants to merge 7 commits into
mainfrom
fix/learn-add-concept-and-port-guard
Open

fix(learn): add a concept back after deleting it, and stop a stale backend answering#536
Darkest-Teddy wants to merge 7 commits into
mainfrom
fix/learn-add-concept-and-port-guard

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Split out of #534, which was carrying these three alongside unrelated quiz
work. Based on main; no dependency on #533/#534.

Reported as: "when deleting a concept node, i should be able to add it back
of the same name by add concept."

The reported bug was a stale server, not the graph

POST /api/graph/{user_id}/nodes returned 404 {"detail":"Not Found"} in
the browser while returning 200 through TestClient in the worktree. A backend
left running from a different working tree — older code, no create_node
was still answering :5000.

Windows lets a second process bind a port another process is already
listening on, and the first binder keeps the traffic. Restarts bound a
second socket, logged "Application startup complete", passed /api/health,
and served nobody. Get-NetTCPConnection listed two owning PIDs; the stale
parent looked dead in Win32_Process while its uvicorn reload child held
the socket, so killing the parent alone did nothing.

python main.py now probes the port first and exits with the kill command.
Dev entrypoint only — containers and Railway import main:app and never
reach it.

The tell, for next time: if a route you can read in routes/ is missing from
curl -s localhost:5000/openapi.json, you are talking to a different build.

Two real Learn bugs found on the way

Add-concept silently no-oped after a delete. The composer resolved its
course as topicNode?.course_id || selectedCourseId || null. The picker is
"Course (optional)" and starts at "", so for anyone who hasn't chosen one
the course id came entirely from the focused node — and deleting a concept
clears the focus. if (!label || !cardCourseId) return then bailed before
doing anything: no request, no toast, no rollback. resolveAddConceptCourseId
falls back to the last course that did resolve, and says so when there has
genuinely never been one. Exported, like resolveCardCourseId, so the test
exercises the resolution addConcept actually calls rather than a mirror.

Failures were unreadable..catch(() => toast.error("Couldn't save the concept — it was removed")) discarded the ApiError, giving a 401, a 404, a
500 and an unreachable backend one identical sentence. Surfacing the status
and body is what produced the 404 + request_id that ended the hunt. The
delete path had the same problem and worse: .catch(() => {}) let the node
vanish from the map while the row survived, so re-adding the same name
quietly merged into the undeleted row.

Verification

  • backend suite green on this branch; ruff check clean
  • port guard tested both ways (occupied → SystemExit, free → returns)
  • tsc --noEmit clean on the identical Learn.tsx
  • vitest cannot start on the local Node 20 (rolldown styleText crash), so
    the new specs are CI-verified only

The backend was never at fault: add → delete → re-add of the same name
creates a fresh row every time — verified against the real project.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved concept creation by retaining the previously selected course when focus is cleared.
    • Added clearer error messages when saving concepts or resolving a course fails.
    • Failed concept deletions now restore the affected content and connections when appropriate.
    • Added safeguards to prevent starting the development server when its configured port is already in use.
  • Tests
    • Added coverage for course resolution after concepts are deleted and re-added.

Darkest-Teddyand others added 3 commits August 12, 2026 10:43
The backend was never the obstacle: add -> delete -> add of the same name
creates a fresh row every time. graph_nodes has no soft delete, delete_node
hard-deletes the row and its edges, and the 0023 UNIQUE has nothing left to
collide with. Verified against the real project.
The break is in the rail's add-concept composer:
cardCourseId = topicNode?.course_id || selectedCourseId || null
...
if (!label || !cardCourseId) return;
The course picker is "Course (optional)" and starts at "" ("No course"), so
unless the user arrived with ?course= or picked one, cardCourseId comes
ENTIRELY from the focused node. Deleting a concept clears the focus — so the
delete removes the one thing supplying the course id, and the add then
returns before doing anything: no request, no toast, no rollback, the
composer just sitting there with the name typed in.
addConcept now resolves through resolveAddConceptCourseId, which falls back
to the last course that did resolve, and says so when there has genuinely
never been one instead of no-op'ing. Exported, like resolveCardCourseId, so
the test exercises the resolution addConcept actually calls rather than a
mirror of it.
Also stops removeConcept swallowing its own failures. `.catch(() => {})` is
not best-effort, it's a lie: the node vanishes from the map while the row
survives, so re-adding the same name quietly MERGES into the undeleted row
and toasts "Merged into your existing X" for a concept the user watched
themselves delete. It now restores the node and says the delete failed.
Verified with tsc --noEmit. vitest cannot start on the local Node 20
(rolldown's styleText crash), so the new specs are CI-verified only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The toast said "Couldn't save the concept — it was removed" for a 401, a
500, a 422 and an unreachable backend alike. Four different problems, four
different fixes, none of them guessable from the message — the catch
discarded the ApiError it was handed.
Chasing a real report of this cost several rounds of inference that the
status code would have ended immediately: the route itself returns 200 and
creates the row when called with the ids the client actually holds, so
whatever the browser hit is above add_node, and the toast was the only
witness. It now carries the status and the start of the body (the 500 path
includes a request_id that ties it to a log), and both handlers log the
full error to the console.
Not instrumentation to be removed later: an error message that names a
cause is what these two toasts should always have said.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The add-concept 404 was never a graph bug. A backend left running from the
MAIN working tree — older code, no create_node — was still answering :5000,
so POST /api/graph/{user_id}/nodes 404'd in the browser while returning 200
through TestClient in this worktree.
Windows lets a second process bind a port another process is already
listening on, and the FIRST binder keeps the traffic. Both restarts during
this session bound a second socket, logged "Application startup complete",
passed /api/health, and served nobody. Get-NetTCPConnection listed two
owning PIDs; the stale parent looked dead in Win32_Process while its uvicorn
reload child held the socket, so killing the parent alone did nothing.
`python main.py` now probes the port first and exits with the kill command
instead of starting a server that cannot receive a request. Dev entrypoint
only — containers and Railway import main:app and never reach it.
The tell, for next time: if a route you can read in routes/ is missing from
curl -s localhost:5000/openapi.json
you are talking to a different build, not looking at a routing bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 12, 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 Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:8 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: dcf757ba-3e52-4f90-b96f-af7d357a7767

📥 Commits

Reviewing files that changed from the base of the PR and between 76c7538 and b5c7bc9.

📒 Files selected for processing (5)
  • backend/main.py
  • backend/tests/test_port_guard.py
  • frontend/src/components/screens/Learn.addConcept.test.tsx
  • frontend/src/components/screens/Learn.graph.test.ts
  • frontend/src/components/screens/Learn.tsx
📝 Walkthrough

Walkthrough

The backend now checks development port occupancy before starting Uvicorn. The Learn screen retains course resolution after focus loss and improves concept save and deletion error handling.

Changes

Backend startup

Layer / File(s)Summary
Development port check
backend/main.py
The development entrypoint probes the configured localhost port and exits with platform-specific termination instructions when another server responds. Imported application startup remains unchanged.

Learn graph operations

Layer / File(s)Summary
Course resolution for concept creation
frontend/src/components/screens/Learn.tsx, frontend/src/components/screens/Learn.graph.test.ts
Concept creation uses the current course or the last resolved course. Tests cover fallback, current-course precedence, and the null case.
Graph error reporting and recovery
frontend/src/components/screens/Learn.tsx
Save failures include structured status or message details. Failed persisted deletions restore the concept and its edges, while temporary nodes remain removed.

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

Suggested reviewers:andresl230, jose-gael-cruz-lopez

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies both primary changes: Learn concept re-addition and stale backend process detection.
Description check✅ PassedThe description explains the bug, implementation, testing, and review context in detail, 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/learn-add-concept-and-port-guard

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 Aug 12, 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-stagingb5c7bc9Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:07 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: 2

🤖 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 `@frontend/src/components/screens/Learn.tsx`:
- Around line 1294-1295: Update the deletion error handling in Learn.tsx around
the ApiError check to include a truncated err.message alongside err.status in
the toast. Preserve the existing non-ApiError behavior and match the add failure
path’s response-detail formatting.
- Around line 1203-1206: Update Learn around resolveAddConceptCourseId and the
Add concept control so a single resolved add-course value is computed and reused
by both addConcept and the render condition, rather than gating the control
directly on cardCourseId. Preserve the fallback behavior when the picker has no
selection, and add a component test covering deletion of the focused concept
with no picker selection while confirming Add concept remains available.
🪄 Autofix

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: c58cf75a-76c6-43a2-9876-c9c6074f4f35

📥 Commits

Reviewing files that changed from the base of the PR and between 7681c48 and 76c7538.

📒 Files selected for processing (3)
  • backend/main.py
  • frontend/src/components/screens/Learn.graph.test.ts
  • frontend/src/components/screens/Learn.tsx

Comment threadfrontend/src/components/screens/Learn.tsx Outdated
Comment threadfrontend/src/components/screens/Learn.tsx Outdated
The tutor page had no quiz entry point at all — Learn.tsx did not mention
quiz anywhere. The only routes in were the nav's Quiz item, the dashboard,
the knowledge map and the notetaker, and every one of them drops the
session's context: the tutor knows exactly which concept the student is
working on, then hands them a page that asks them to pick it again.
Two buttons, one resolver:
focus card ("Quiz me") -> the rail's anchored concept
session toolbar ("Quiz me") -> the focused concept, else the session topic
`quizHref` prefers a node id, which screens/Quiz.tsx uses directly as
initialConceptId, and falls back to the topic NAME, which it resolves
against the concept list.
The placeholder guard is the part worth reading: an optimistically-added
concept carries a client-side `node-new-<ts>` id and a streamed one carries
`stream-<name>`, neither of which exists server-side. Passing either as
?concept= would preselect an id the quiz page cannot resolve — silently, as
an empty picker. Those fall through to the name, which resolves as soon as
the real row lands. Exported and tested like the other resolvers, so the
test covers what the buttons actually call.
Verified with tsc --noEmit. vitest cannot start on the local Node 20
(rolldown styleText crash), so the new specs are CI-verified only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — learn concept re-add + port guard

The diagnosis here is excellent — the "stale process on :5000" write-up is the most useful thing in the PR, and _refuse_if_port_taken is correctly scoped (dev __main__ only; backend/Dockerfile:37 runs uvicorn main:app, so containers never reach it). Replacing the delete path's .catch(() => {}) with a real restore is the right instinct. But the headline fix does not reach the reported flow: addConcept's new fallback is unreachable, because the composer that calls it is gated on the very value the fallback exists to work around. There is also a new failure mode in the delete restore (a 404 resurrects the node), a hand-rolled error formatter that duplicates lib/errorMessage.ts and prints raw JSON bodies into toasts, and ~70 lines of undocumented quiz deep-link feature in a PR whose description says it was split out to exclude quiz work.

Findings

P0

[P0] addConcept's new fallback is unreachable, and the reported bug survivesfrontend/src/components/screens/Learn.tsx:1754

{/* Add concept */}{cardCourseId&&(<divstyle={{padding: "4px 22px 24px"}}>

Both addConcept call sites live inside that block — :1763 (onKeyDown Enter) and :1780 (the Add button) — and there is no other caller in the file. So cardCourseId is always truthy when addConcept can run, which means resolveAddConceptCourseId(cardCourseId, lastCourseIdRef.current) at :1226 always returns cardCourseId. The lastCourseIdRef + effect (:474-477) and the new toast.error("Pick a course first…") branch (:1227-1230) are dead code.

Worse, the reported repro still fails. removeConcept is only reachable from the focus card — :1611 gates the button row on focusConcept, Remove is :1663-1683 — so the deleted node is always the focused one. After removal, activeFocusId = focusedNodeId ?? highlightId (:452) and highlightId is graphNodes.find(n => n.name.toLowerCase() === topic.trim().toLowerCase())?.id (:446) — the node just deleted. With selectedCourseId at its default "" (:392/:397, searchParams.get("course") ?? ""), resolveCardCourseId (:269-274) returns null and the whole "+ Add concept" affordance unmounts (block :1754-1815). The student cannot type the name back in at all, which is exactly "when deleting a concept node, i should be able to add it back of the same name by add concept".

The new specs pass because they call the exported resolver directly and never render the gate. The gate is where the fix belongs: resolve the add-course once and gate the composer on that, showing which course the concept will land in. (CodeRabbit raised the gate; this adds the proof that the fallback branch is unreachable and that the original repro is unchanged.)

P1

[P1] A 404 on delete resurrects the node and toasts a false statementfrontend/src/components/screens/Learn.tsx:1304-1318

deleteGraphNode(userId,nodeId).catch((err: unknown)=>{console.error("[removeConcept] failed",{ nodeId, err });if(!removedNode||/^(node-new-|stream-)/.test(nodeId))return;setGraphNodes(prev=>(prev.some(n=>n.id===nodeId) ? prev : [...prev,removedNode]));
...
constreason=errinstanceofApiError ? ` (${err.status})` : "";toast.error(`Couldn't delete “${removedNode.name}${reason} — it's still on your map.`);

The restore fires on any rejection, and 404 is precisely the status the endpoint returns for "the row is already gone": backend/routes/graph.py:119-125 raises HTTPException(status_code=404, …) whenever delete_node returns an error, and backend/services/graph_service.py:486-492 returns {"error": "Node not found", "deleted": False} when the owner-scoped select finds no row. So a second tab, a client-side node that is already stale, or a delete that already succeeded server-side now puts a phantom concept back on the map and tells the user "it's still on your map" when it is not. The old .catch(() => {}) produced the correct end state in exactly this case.

It compounds: delete X → 404 → user re-adds X (fresh row, new id) → the catch appends the old node object under its dead id and the map shows two "X" nodes, one of which doesn't exist. A 404 should be treated as success — nothing to restore. (The non-404 case is already handled correctly: the prev.some(n => n.id === nodeId) guard stops a double-insert when a re-add merged back into the surviving row.)

P2

[P2] Hand-rolled error copy duplicates lib/errorMessage.ts and dumps the raw response body into a toastfrontend/src/components/screens/Learn.tsx:1279-1285

constreason=errinstanceofApiError
? `${err.status}${err.message ? ` — ${err.message.slice(0,160)}` : ""}`
: errinstanceofError&&err.message
? err.message.slice(0,160)
: "the request never completed";toast.error(`Couldn't save “${label}” (${reason}).`);

ApiError.message is documented as the raw body — frontend/src/lib/api.ts:25-26: "message stays the raw body for backward compatibility" — so the toast renders Couldn't save “Markov Chains” (404 — {"detail":"Not Found"})., or 160 characters of a proxy's HTML error page. frontend/src/lib/errorMessage.ts exists for exactly this and says so in its header: "Rendering that with String(err) dumps JSON into the UI, which is what these helpers exist to prevent." Its humanizeError(err, fallback) (:156-160 — "A short sentence safe to put in a toast. Never returns … a raw JSON body") is already used across Gradebook, Calendar, Social, Study, ProfileView, the notetaker, SignInModal and QuizPanel; Learn.tsx imports nothing from it. The Engineering Style Guide's shared-primitive rule applies here the same way it does to components/ui/. It also removes the inconsistency CodeRabbit flagged — the delete toast at :1317 surfaces only the status and never the detail; both paths would read the same through one helper.

[P2] An undocumented quiz deep-link feature ships in a PR that says it excludes quiz workfrontend/src/components/screens/Learn.tsx:288-297, :1449-1457, :1640-1662

exportfunctionquizHref(conceptId: string|null|undefined,topic: string|null|undefined,): string{

quizHref plus two new "Quiz me" buttons (session toolbar :1449, focus card :1640) plus a five-case spec block (Learn.graph.test.ts:171-206) are roughly a quarter of the diff, and neither the title, the description, nor the release-note summary mentions them. The description says the opposite: "Split out of #534, which was carrying these three alongside unrelated quiz work." The code itself checks out — ?concept=/?topic= match screens/Quiz.tsx:86-94, resolveInitialSelection (lib/quizSelection.ts:71-75) degrades to {null, null} on an id it can't resolve, Icon has flask (Icon.tsx:78), .btn--sm exists (globals.css:233), and #534 carries no duplicate — but a new user-facing entry point riding along in a bugfix PR makes this hard to review and impossible to revert cleanly. Either name it in the description or move it out.

P3

[P3] A failed delete restores the node but not the focusfrontend/src/components/screens/Learn.tsx:1302

setFocusedNodeId(cur=>(cur===nodeId ? null : cur));

The catch at :1304-1318 undoes the node and edge removals but never the focus clear, so after a failed delete the concept reappears on the map while the rail's focus card is gone — and, per the P0 above, so is the Add-concept composer.

[P3] The port probe only covers IPv4 loopbackbackend/main.py:338-341

withsocket.socket(socket.AF_INET, socket.SOCK_STREAM) asprobe:
probe.settimeout(0.4)
ifprobe.connect_ex(("127.0.0.1", port)) !=0:
return

uvicorn.run(..., host="0.0.0.0") at :359 binds the IPv4 wildcard so the common case is covered, but a stale server bound to ::1 or to one specific interface passes the probe silently. Given the whole point is "tell the next person", resolving both families would close the gap.

What's good

  • The root-cause write-up in the description (two owning PIDs, the uvicorn reload child holding the socket, "if a route you can read in routes/ is missing from /openapi.json, you are talking to a different build") is worth more than the code — it belongs in CLAUDE.md or docs/local-supabase.md so it outlives the PR.
  • The guard is correctly classified and scoped: it is a dev-entrypoint TCP probe under if __name__ == "__main__", not a production origin/port check. backend/Dockerfile:37 is CMD ["uvicorn", "main:app", …] and scripts/e2e-up.sh invokes uvicorn directly, so neither containers nor the e2e lane can trip it.
  • The backend claim in the description holds: delete_node (graph_service.py:481-509) hard-deletes edges in both directions and then the row, with no soft-delete column and no unique-constraint collision on re-add — so add → delete → re-add really does create a fresh row.

Verdict: request changes — the P0 leaves the reported bug reproducible on the default path, and the P1 introduces a new map/server divergence in the opposite direction.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…p a 404 resurrecting a node
The previous pass added `resolveAddConceptCourseId` so add-concept could
survive the delete that clears the focus — and then gated the composer that
calls it on `cardCourseId`, the exact value the fallback exists to work
around. Both `addConcept` call sites lived inside that gate, so the fallback
could only ever run while `cardCourseId` was already truthy: dead code, and
the reported bug ("delete a concept, add it back under the same name")
unchanged. Deleting the focused concept with the optional course picker at
its "" default unmounted the whole "+ Add concept" affordance, so the name
could not be typed back in at all.
The gate and `addConcept` now read ONE resolved value, `addCourseId`, so
they cannot disagree again. `lastCourseId` became state rather than a ref
because the gate reads it during render, and a ref mutated in an effect
produces no re-render. The composer names the course the concept will land
in, since `addCourseId` can be the remembered fallback rather than what the
focus card shows — otherwise the destination is a silent guess.
Also in removeConcept:
- A 404 is the endpoint's answer for "that row is already gone"
(routes/graph.py::remove_node, from graph_service.delete_node's
{"error": "Node not found"}), so restoring on it put a phantom concept
back on the map under a toast asserting it was "still on your map". After
the student re-added the same name it showed two, one of which existed
nowhere. `shouldRestoreFailedDelete` draws the line; the double-insert
guard stays for genuine failures.
- A restored node now gets its focus back. Restoring the node but not the
focus left the concept on the map with the rail's focus card — and so the
Remove button and the composer — belonging to a different concept.
- Both the add-failure and delete-failure toasts go through
lib/errorMessage's `humanizeError` instead of interpolating
`ApiError.message`, which is the RAW response body: the toast used to read
`Couldn't save "X" (404 - {"detail":"Not Found"})`, or 160 characters of a
proxy's HTML error page.
Learn.addConcept.test.tsx is a component test on purpose: the gap here was
in which value the render gate reads, which no resolver-level test could
see. Each of the four fixes was verified to fail the suite when reverted.
The guard probed AF_INET/127.0.0.1 only. `localhost` resolves to both
127.0.0.1 and ::1 on any dual-stack box, and uvicorn binds whatever its
--host resolved to, so a stale server on ::1 (or on one specific interface)
walked straight past the check and the guard reported "port free" for exactly
the situation it was written to name — while `localhost:8000` in the browser
still reached the old server.
Both literals are probed now, with getaddrinfo("localhost") folded in on top
so a host whose localhost maps somewhere unusual is still covered, and an
unsupported family is skipped rather than raising. The SystemExit message
names the address that answered; without it the next person is told "already
serving" and has to guess which family to go hunting on.
Still dev-entrypoint only. tests/test_port_guard.py covers occupied IPv4,
occupied IPv6 (skipped where there is no IPv6 loopback), free, and the
message contents; the IPv6 case fails against the AF_INET-only probe.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Blocker

  • The add-concept fallback was unreachable and the reported bug still reproduced. The composer was gated on cardCourseId — the very value the fallback exists to work around — and both addConcept call sites live inside that gate, so resolveAddConceptCourseId could never return anything else and the whole "+ Add concept" affordance unmounted after deleting the focused concept. One resolved addCourseId now drives both the gate and addConcept, and the destination course is surfaced in the UI instead of being applied silently.

Major

  • A 404 on delete resurrected the node. 404 is exactly what the endpoint returns for "already gone" (routes/graph.pygraph_service.delete_node), so the restore produced a phantom concept and a toast claiming "it's still on your map" when it was not — compounding to two visible "X" nodes after a re-add. New shouldRestoreFailedDelete treats 404 as success; the genuine-failure path keeps its double-insert guard.

Minor / nits

  • Both toasts now go through lib/errorMessage.ts::humanizeError instead of printing ApiError.message — the documented raw body — into the UI. Also resolves the status-only vs body asymmetry between the add and delete paths.
  • A failed delete restores focus, not just the node and edges.
  • The dev port guard probes ::1 and getaddrinfo results as well as 127.0.0.1, and names the answering address.

Note

The quizHref / "Quiz me" deep-link code was left in place — it is correct, but it is not mentioned in the PR description, which says quiz work was split out. Worth naming in the body or moving out.

Verificationruff check . clean · 1869 passed, 49 skipped · tsc clean · 638 frontend tests pass · eslint 0 errors

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

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.

2 participants

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

fix(learn): add a concept back after deleting it, and stop a stale backend answering - #536

Open
Darkest-Teddy wants to merge 7 commits into
mainfrom
fix/learn-add-concept-and-port-guard
Open

fix(learn): add a concept back after deleting it, and stop a stale backend answering#536
Darkest-Teddy wants to merge 7 commits into
mainfrom
fix/learn-add-concept-and-port-guard

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Split out of #534, which was carrying these three alongside unrelated quiz
work. Based on main; no dependency on #533/#534.

Reported as: "when deleting a concept node, i should be able to add it back
of the same name by add concept."

The reported bug was a stale server, not the graph

POST /api/graph/{user_id}/nodes returned 404 {"detail":"Not Found"} in
the browser while returning 200 through TestClient in the worktree. A backend
left running from a different working tree — older code, no create_node
was still answering :5000.

Windows lets a second process bind a port another process is already
listening on, and the first binder keeps the traffic. Restarts bound a
second socket, logged "Application startup complete", passed /api/health,
and served nobody. Get-NetTCPConnection listed two owning PIDs; the stale
parent looked dead in Win32_Process while its uvicorn reload child held
the socket, so killing the parent alone did nothing.

python main.py now probes the port first and exits with the kill command.
Dev entrypoint only — containers and Railway import main:app and never
reach it.

The tell, for next time: if a route you can read in routes/ is missing from
curl -s localhost:5000/openapi.json, you are talking to a different build.

Two real Learn bugs found on the way

Add-concept silently no-oped after a delete. The composer resolved its
course as topicNode?.course_id || selectedCourseId || null. The picker is
"Course (optional)" and starts at "", so for anyone who hasn't chosen one
the course id came entirely from the focused node — and deleting a concept
clears the focus. if (!label || !cardCourseId) return then bailed before
doing anything: no request, no toast, no rollback. resolveAddConceptCourseId
falls back to the last course that did resolve, and says so when there has
genuinely never been one. Exported, like resolveCardCourseId, so the test
exercises the resolution addConcept actually calls rather than a mirror.

Failures were unreadable..catch(() => toast.error("Couldn't save the concept — it was removed")) discarded the ApiError, giving a 401, a 404, a
500 and an unreachable backend one identical sentence. Surfacing the status
and body is what produced the 404 + request_id that ended the hunt. The
delete path had the same problem and worse: .catch(() => {}) let the node
vanish from the map while the row survived, so re-adding the same name
quietly merged into the undeleted row.

Verification

  • backend suite green on this branch; ruff check clean
  • port guard tested both ways (occupied → SystemExit, free → returns)
  • tsc --noEmit clean on the identical Learn.tsx
  • vitest cannot start on the local Node 20 (rolldown styleText crash), so
    the new specs are CI-verified only

The backend was never at fault: add → delete → re-add of the same name
creates a fresh row every time — verified against the real project.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved concept creation by retaining the previously selected course when focus is cleared.
    • Added clearer error messages when saving concepts or resolving a course fails.
    • Failed concept deletions now restore the affected content and connections when appropriate.
    • Added safeguards to prevent starting the development server when its configured port is already in use.
  • Tests
    • Added coverage for course resolution after concepts are deleted and re-added.

Darkest-Teddyand others added 3 commits August 12, 2026 10:43
The backend was never the obstacle: add -> delete -> add of the same name
creates a fresh row every time. graph_nodes has no soft delete, delete_node
hard-deletes the row and its edges, and the 0023 UNIQUE has nothing left to
collide with. Verified against the real project.
The break is in the rail's add-concept composer:
cardCourseId = topicNode?.course_id || selectedCourseId || null
...
if (!label || !cardCourseId) return;
The course picker is "Course (optional)" and starts at "" ("No course"), so
unless the user arrived with ?course= or picked one, cardCourseId comes
ENTIRELY from the focused node. Deleting a concept clears the focus — so the
delete removes the one thing supplying the course id, and the add then
returns before doing anything: no request, no toast, no rollback, the
composer just sitting there with the name typed in.
addConcept now resolves through resolveAddConceptCourseId, which falls back
to the last course that did resolve, and says so when there has genuinely
never been one instead of no-op'ing. Exported, like resolveCardCourseId, so
the test exercises the resolution addConcept actually calls rather than a
mirror of it.
Also stops removeConcept swallowing its own failures. `.catch(() => {})` is
not best-effort, it's a lie: the node vanishes from the map while the row
survives, so re-adding the same name quietly MERGES into the undeleted row
and toasts "Merged into your existing X" for a concept the user watched
themselves delete. It now restores the node and says the delete failed.
Verified with tsc --noEmit. vitest cannot start on the local Node 20
(rolldown's styleText crash), so the new specs are CI-verified only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The toast said "Couldn't save the concept — it was removed" for a 401, a
500, a 422 and an unreachable backend alike. Four different problems, four
different fixes, none of them guessable from the message — the catch
discarded the ApiError it was handed.
Chasing a real report of this cost several rounds of inference that the
status code would have ended immediately: the route itself returns 200 and
creates the row when called with the ids the client actually holds, so
whatever the browser hit is above add_node, and the toast was the only
witness. It now carries the status and the start of the body (the 500 path
includes a request_id that ties it to a log), and both handlers log the
full error to the console.
Not instrumentation to be removed later: an error message that names a
cause is what these two toasts should always have said.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The add-concept 404 was never a graph bug. A backend left running from the
MAIN working tree — older code, no create_node — was still answering :5000,
so POST /api/graph/{user_id}/nodes 404'd in the browser while returning 200
through TestClient in this worktree.
Windows lets a second process bind a port another process is already
listening on, and the FIRST binder keeps the traffic. Both restarts during
this session bound a second socket, logged "Application startup complete",
passed /api/health, and served nobody. Get-NetTCPConnection listed two
owning PIDs; the stale parent looked dead in Win32_Process while its uvicorn
reload child held the socket, so killing the parent alone did nothing.
`python main.py` now probes the port first and exits with the kill command
instead of starting a server that cannot receive a request. Dev entrypoint
only — containers and Railway import main:app and never reach it.
The tell, for next time: if a route you can read in routes/ is missing from
curl -s localhost:5000/openapi.json
you are talking to a different build, not looking at a routing bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 12, 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 Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:8 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: dcf757ba-3e52-4f90-b96f-af7d357a7767

📥 Commits

Reviewing files that changed from the base of the PR and between 76c7538 and b5c7bc9.

📒 Files selected for processing (5)
  • backend/main.py
  • backend/tests/test_port_guard.py
  • frontend/src/components/screens/Learn.addConcept.test.tsx
  • frontend/src/components/screens/Learn.graph.test.ts
  • frontend/src/components/screens/Learn.tsx
📝 Walkthrough

Walkthrough

The backend now checks development port occupancy before starting Uvicorn. The Learn screen retains course resolution after focus loss and improves concept save and deletion error handling.

Changes

Backend startup

Layer / File(s)Summary
Development port check
backend/main.py
The development entrypoint probes the configured localhost port and exits with platform-specific termination instructions when another server responds. Imported application startup remains unchanged.

Learn graph operations

Layer / File(s)Summary
Course resolution for concept creation
frontend/src/components/screens/Learn.tsx, frontend/src/components/screens/Learn.graph.test.ts
Concept creation uses the current course or the last resolved course. Tests cover fallback, current-course precedence, and the null case.
Graph error reporting and recovery
frontend/src/components/screens/Learn.tsx
Save failures include structured status or message details. Failed persisted deletions restore the concept and its edges, while temporary nodes remain removed.

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

Suggested reviewers:andresl230, jose-gael-cruz-lopez

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies both primary changes: Learn concept re-addition and stale backend process detection.
Description check✅ PassedThe description explains the bug, implementation, testing, and review context in detail, 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/learn-add-concept-and-port-guard

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 Aug 12, 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-stagingb5c7bc9Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:07 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: 2

🤖 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 `@frontend/src/components/screens/Learn.tsx`:
- Around line 1294-1295: Update the deletion error handling in Learn.tsx around
the ApiError check to include a truncated err.message alongside err.status in
the toast. Preserve the existing non-ApiError behavior and match the add failure
path’s response-detail formatting.
- Around line 1203-1206: Update Learn around resolveAddConceptCourseId and the
Add concept control so a single resolved add-course value is computed and reused
by both addConcept and the render condition, rather than gating the control
directly on cardCourseId. Preserve the fallback behavior when the picker has no
selection, and add a component test covering deletion of the focused concept
with no picker selection while confirming Add concept remains available.
🪄 Autofix

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: c58cf75a-76c6-43a2-9876-c9c6074f4f35

📥 Commits

Reviewing files that changed from the base of the PR and between 7681c48 and 76c7538.

📒 Files selected for processing (3)
  • backend/main.py
  • frontend/src/components/screens/Learn.graph.test.ts
  • frontend/src/components/screens/Learn.tsx

Comment threadfrontend/src/components/screens/Learn.tsx Outdated
Comment threadfrontend/src/components/screens/Learn.tsx Outdated
The tutor page had no quiz entry point at all — Learn.tsx did not mention
quiz anywhere. The only routes in were the nav's Quiz item, the dashboard,
the knowledge map and the notetaker, and every one of them drops the
session's context: the tutor knows exactly which concept the student is
working on, then hands them a page that asks them to pick it again.
Two buttons, one resolver:
focus card ("Quiz me") -> the rail's anchored concept
session toolbar ("Quiz me") -> the focused concept, else the session topic
`quizHref` prefers a node id, which screens/Quiz.tsx uses directly as
initialConceptId, and falls back to the topic NAME, which it resolves
against the concept list.
The placeholder guard is the part worth reading: an optimistically-added
concept carries a client-side `node-new-<ts>` id and a streamed one carries
`stream-<name>`, neither of which exists server-side. Passing either as
?concept= would preselect an id the quiz page cannot resolve — silently, as
an empty picker. Those fall through to the name, which resolves as soon as
the real row lands. Exported and tested like the other resolvers, so the
test covers what the buttons actually call.
Verified with tsc --noEmit. vitest cannot start on the local Node 20
(rolldown styleText crash), so the new specs are CI-verified only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — learn concept re-add + port guard

The diagnosis here is excellent — the "stale process on :5000" write-up is the most useful thing in the PR, and _refuse_if_port_taken is correctly scoped (dev __main__ only; backend/Dockerfile:37 runs uvicorn main:app, so containers never reach it). Replacing the delete path's .catch(() => {}) with a real restore is the right instinct. But the headline fix does not reach the reported flow: addConcept's new fallback is unreachable, because the composer that calls it is gated on the very value the fallback exists to work around. There is also a new failure mode in the delete restore (a 404 resurrects the node), a hand-rolled error formatter that duplicates lib/errorMessage.ts and prints raw JSON bodies into toasts, and ~70 lines of undocumented quiz deep-link feature in a PR whose description says it was split out to exclude quiz work.

Findings

P0

[P0] addConcept's new fallback is unreachable, and the reported bug survivesfrontend/src/components/screens/Learn.tsx:1754

{/* Add concept */}{cardCourseId&&(<divstyle={{padding: "4px 22px 24px"}}>

Both addConcept call sites live inside that block — :1763 (onKeyDown Enter) and :1780 (the Add button) — and there is no other caller in the file. So cardCourseId is always truthy when addConcept can run, which means resolveAddConceptCourseId(cardCourseId, lastCourseIdRef.current) at :1226 always returns cardCourseId. The lastCourseIdRef + effect (:474-477) and the new toast.error("Pick a course first…") branch (:1227-1230) are dead code.

Worse, the reported repro still fails. removeConcept is only reachable from the focus card — :1611 gates the button row on focusConcept, Remove is :1663-1683 — so the deleted node is always the focused one. After removal, activeFocusId = focusedNodeId ?? highlightId (:452) and highlightId is graphNodes.find(n => n.name.toLowerCase() === topic.trim().toLowerCase())?.id (:446) — the node just deleted. With selectedCourseId at its default "" (:392/:397, searchParams.get("course") ?? ""), resolveCardCourseId (:269-274) returns null and the whole "+ Add concept" affordance unmounts (block :1754-1815). The student cannot type the name back in at all, which is exactly "when deleting a concept node, i should be able to add it back of the same name by add concept".

The new specs pass because they call the exported resolver directly and never render the gate. The gate is where the fix belongs: resolve the add-course once and gate the composer on that, showing which course the concept will land in. (CodeRabbit raised the gate; this adds the proof that the fallback branch is unreachable and that the original repro is unchanged.)

P1

[P1] A 404 on delete resurrects the node and toasts a false statementfrontend/src/components/screens/Learn.tsx:1304-1318

deleteGraphNode(userId,nodeId).catch((err: unknown)=>{console.error("[removeConcept] failed",{ nodeId, err });if(!removedNode||/^(node-new-|stream-)/.test(nodeId))return;setGraphNodes(prev=>(prev.some(n=>n.id===nodeId) ? prev : [...prev,removedNode]));
...
constreason=errinstanceofApiError ? ` (${err.status})` : "";toast.error(`Couldn't delete “${removedNode.name}${reason} — it's still on your map.`);

The restore fires on any rejection, and 404 is precisely the status the endpoint returns for "the row is already gone": backend/routes/graph.py:119-125 raises HTTPException(status_code=404, …) whenever delete_node returns an error, and backend/services/graph_service.py:486-492 returns {"error": "Node not found", "deleted": False} when the owner-scoped select finds no row. So a second tab, a client-side node that is already stale, or a delete that already succeeded server-side now puts a phantom concept back on the map and tells the user "it's still on your map" when it is not. The old .catch(() => {}) produced the correct end state in exactly this case.

It compounds: delete X → 404 → user re-adds X (fresh row, new id) → the catch appends the old node object under its dead id and the map shows two "X" nodes, one of which doesn't exist. A 404 should be treated as success — nothing to restore. (The non-404 case is already handled correctly: the prev.some(n => n.id === nodeId) guard stops a double-insert when a re-add merged back into the surviving row.)

P2

[P2] Hand-rolled error copy duplicates lib/errorMessage.ts and dumps the raw response body into a toastfrontend/src/components/screens/Learn.tsx:1279-1285

constreason=errinstanceofApiError
? `${err.status}${err.message ? ` — ${err.message.slice(0,160)}` : ""}`
: errinstanceofError&&err.message
? err.message.slice(0,160)
: "the request never completed";toast.error(`Couldn't save “${label}” (${reason}).`);

ApiError.message is documented as the raw body — frontend/src/lib/api.ts:25-26: "message stays the raw body for backward compatibility" — so the toast renders Couldn't save “Markov Chains” (404 — {"detail":"Not Found"})., or 160 characters of a proxy's HTML error page. frontend/src/lib/errorMessage.ts exists for exactly this and says so in its header: "Rendering that with String(err) dumps JSON into the UI, which is what these helpers exist to prevent." Its humanizeError(err, fallback) (:156-160 — "A short sentence safe to put in a toast. Never returns … a raw JSON body") is already used across Gradebook, Calendar, Social, Study, ProfileView, the notetaker, SignInModal and QuizPanel; Learn.tsx imports nothing from it. The Engineering Style Guide's shared-primitive rule applies here the same way it does to components/ui/. It also removes the inconsistency CodeRabbit flagged — the delete toast at :1317 surfaces only the status and never the detail; both paths would read the same through one helper.

[P2] An undocumented quiz deep-link feature ships in a PR that says it excludes quiz workfrontend/src/components/screens/Learn.tsx:288-297, :1449-1457, :1640-1662

exportfunctionquizHref(conceptId: string|null|undefined,topic: string|null|undefined,): string{

quizHref plus two new "Quiz me" buttons (session toolbar :1449, focus card :1640) plus a five-case spec block (Learn.graph.test.ts:171-206) are roughly a quarter of the diff, and neither the title, the description, nor the release-note summary mentions them. The description says the opposite: "Split out of #534, which was carrying these three alongside unrelated quiz work." The code itself checks out — ?concept=/?topic= match screens/Quiz.tsx:86-94, resolveInitialSelection (lib/quizSelection.ts:71-75) degrades to {null, null} on an id it can't resolve, Icon has flask (Icon.tsx:78), .btn--sm exists (globals.css:233), and #534 carries no duplicate — but a new user-facing entry point riding along in a bugfix PR makes this hard to review and impossible to revert cleanly. Either name it in the description or move it out.

P3

[P3] A failed delete restores the node but not the focusfrontend/src/components/screens/Learn.tsx:1302

setFocusedNodeId(cur=>(cur===nodeId ? null : cur));

The catch at :1304-1318 undoes the node and edge removals but never the focus clear, so after a failed delete the concept reappears on the map while the rail's focus card is gone — and, per the P0 above, so is the Add-concept composer.

[P3] The port probe only covers IPv4 loopbackbackend/main.py:338-341

withsocket.socket(socket.AF_INET, socket.SOCK_STREAM) asprobe:
probe.settimeout(0.4)
ifprobe.connect_ex(("127.0.0.1", port)) !=0:
return

uvicorn.run(..., host="0.0.0.0") at :359 binds the IPv4 wildcard so the common case is covered, but a stale server bound to ::1 or to one specific interface passes the probe silently. Given the whole point is "tell the next person", resolving both families would close the gap.

What's good

  • The root-cause write-up in the description (two owning PIDs, the uvicorn reload child holding the socket, "if a route you can read in routes/ is missing from /openapi.json, you are talking to a different build") is worth more than the code — it belongs in CLAUDE.md or docs/local-supabase.md so it outlives the PR.
  • The guard is correctly classified and scoped: it is a dev-entrypoint TCP probe under if __name__ == "__main__", not a production origin/port check. backend/Dockerfile:37 is CMD ["uvicorn", "main:app", …] and scripts/e2e-up.sh invokes uvicorn directly, so neither containers nor the e2e lane can trip it.
  • The backend claim in the description holds: delete_node (graph_service.py:481-509) hard-deletes edges in both directions and then the row, with no soft-delete column and no unique-constraint collision on re-add — so add → delete → re-add really does create a fresh row.

Verdict: request changes — the P0 leaves the reported bug reproducible on the default path, and the P1 introduces a new map/server divergence in the opposite direction.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…p a 404 resurrecting a node
The previous pass added `resolveAddConceptCourseId` so add-concept could
survive the delete that clears the focus — and then gated the composer that
calls it on `cardCourseId`, the exact value the fallback exists to work
around. Both `addConcept` call sites lived inside that gate, so the fallback
could only ever run while `cardCourseId` was already truthy: dead code, and
the reported bug ("delete a concept, add it back under the same name")
unchanged. Deleting the focused concept with the optional course picker at
its "" default unmounted the whole "+ Add concept" affordance, so the name
could not be typed back in at all.
The gate and `addConcept` now read ONE resolved value, `addCourseId`, so
they cannot disagree again. `lastCourseId` became state rather than a ref
because the gate reads it during render, and a ref mutated in an effect
produces no re-render. The composer names the course the concept will land
in, since `addCourseId` can be the remembered fallback rather than what the
focus card shows — otherwise the destination is a silent guess.
Also in removeConcept:
- A 404 is the endpoint's answer for "that row is already gone"
(routes/graph.py::remove_node, from graph_service.delete_node's
{"error": "Node not found"}), so restoring on it put a phantom concept
back on the map under a toast asserting it was "still on your map". After
the student re-added the same name it showed two, one of which existed
nowhere. `shouldRestoreFailedDelete` draws the line; the double-insert
guard stays for genuine failures.
- A restored node now gets its focus back. Restoring the node but not the
focus left the concept on the map with the rail's focus card — and so the
Remove button and the composer — belonging to a different concept.
- Both the add-failure and delete-failure toasts go through
lib/errorMessage's `humanizeError` instead of interpolating
`ApiError.message`, which is the RAW response body: the toast used to read
`Couldn't save "X" (404 - {"detail":"Not Found"})`, or 160 characters of a
proxy's HTML error page.
Learn.addConcept.test.tsx is a component test on purpose: the gap here was
in which value the render gate reads, which no resolver-level test could
see. Each of the four fixes was verified to fail the suite when reverted.
The guard probed AF_INET/127.0.0.1 only. `localhost` resolves to both
127.0.0.1 and ::1 on any dual-stack box, and uvicorn binds whatever its
--host resolved to, so a stale server on ::1 (or on one specific interface)
walked straight past the check and the guard reported "port free" for exactly
the situation it was written to name — while `localhost:8000` in the browser
still reached the old server.
Both literals are probed now, with getaddrinfo("localhost") folded in on top
so a host whose localhost maps somewhere unusual is still covered, and an
unsupported family is skipped rather than raising. The SystemExit message
names the address that answered; without it the next person is told "already
serving" and has to guess which family to go hunting on.
Still dev-entrypoint only. tests/test_port_guard.py covers occupied IPv4,
occupied IPv6 (skipped where there is no IPv6 loopback), free, and the
message contents; the IPv6 case fails against the AF_INET-only probe.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Blocker

  • The add-concept fallback was unreachable and the reported bug still reproduced. The composer was gated on cardCourseId — the very value the fallback exists to work around — and both addConcept call sites live inside that gate, so resolveAddConceptCourseId could never return anything else and the whole "+ Add concept" affordance unmounted after deleting the focused concept. One resolved addCourseId now drives both the gate and addConcept, and the destination course is surfaced in the UI instead of being applied silently.

Major

  • A 404 on delete resurrected the node. 404 is exactly what the endpoint returns for "already gone" (routes/graph.pygraph_service.delete_node), so the restore produced a phantom concept and a toast claiming "it's still on your map" when it was not — compounding to two visible "X" nodes after a re-add. New shouldRestoreFailedDelete treats 404 as success; the genuine-failure path keeps its double-insert guard.

Minor / nits

  • Both toasts now go through lib/errorMessage.ts::humanizeError instead of printing ApiError.message — the documented raw body — into the UI. Also resolves the status-only vs body asymmetry between the add and delete paths.
  • A failed delete restores focus, not just the node and edges.
  • The dev port guard probes ::1 and getaddrinfo results as well as 127.0.0.1, and names the answering address.

Note

The quizHref / "Quiz me" deep-link code was left in place — it is correct, but it is not mentioned in the PR description, which says quiz work was split out. Worth naming in the body or moving out.

Verificationruff check . clean · 1869 passed, 49 skipped · tsc clean · 638 frontend tests pass · eslint 0 errors

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

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.

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez