Conversation
goToSliderValue carried both mappings inline: slider value to history position at the top, and the inverse in the branch that recovers from a playback failure. Pull them out as named functions so the two directions sit next to each other and can't drift apart. No behavior change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r thumb sliderValue was write-only local state, initialised to the end of the history and updated only by goToSliderValue. A link that opens a document at a history entry seeks with goToHistoryEntry, which moves the document without going through the slider, so the thumb stayed pinned at the end while the canvas showed an earlier point. Researchers following a deep link from a report read the right content under a scrubber that claimed they were at the end of the history. Sync sliderValue from numHistoryEventsApplied instead. Several slider values can share a history position, because a comment sits at the same position as the entry before it, so a value that already represents the position is left alone rather than snapped onto the history entry. That keeps comment markers selectable and makes the effect a no-op for ordinary drags and for auto-play. The effect also re-anchors the thumb when sliderEntries shifts underneath it, which happens when a comment arrives while the document is open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
moveToHistoryEntryAfterLoad had three ways to give up — the history load timing out, the history not loading, and an id that resolves to nothing — and all three only reached console.warn. The document then sat at the end of its history looking entirely normal, so a researcher following a link from a report read a different moment than the one the link named, with nothing to tell them apart. Record the reason on the history manager and show it under the playback controls. It is a role="alert" because it appears once the document has loaded, after the reader's attention has moved on. The unresolved-id message carries the id, since the reader is the person holding the link that named it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
A replay failure can still silently leave a deep link at the wrong history position because seek completion is not awaited or validated.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Synchronizes playback controls with deep-linked history positions and surfaces failed history requests.
Changes:
- Maps slider values to document history positions bidirectionally.
- Displays accessible history-request errors.
- Adds playback and history-manager tests.
File summaries
| File | Description |
|---|---|
src/models/history/firestore-history-manager.ts |
Tracks history-request failures. |
src/models/history/firestore-history-manager.test.ts |
Tests request outcomes and timeouts. |
src/components/playback/playback.tsx |
Renders history-request alerts. |
src/components/playback/playback.test.tsx |
Tests alert rendering. |
src/components/playback/playback.scss |
Styles request alerts. |
src/components/playback/playback-control.tsx |
Synchronizes slider and history positions. |
src/components/playback/playback-control.test.tsx |
Tests programmatic seeking and comment selection. |
Review details
Suppressed comments (1)
src/models/history/firestore-history-manager.ts:337
- This final sentence becomes false as soon as the user moves the scrubber after the failed deep link: the request error persists, but the document is no longer showing the end. Keep the persistent alert limited to the failed request rather than asserting the current playback position.
this.setHistoryEntryRequestError(
`Could not find the requested point in this document's history (id: ${historyId}). ` +
"The document is showing the end of its history.");
- Files reviewed: 7/7 changed files
- Comments generated: 2
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Resolving the requested id says where to go, not that the document got there. goToHistoryEntry is a flow and its replay can fail partway, in which case it stops at the last position it could apply. The request error was cleared before the seek was even started, so a link whose target sits behind an entry that will not replay reported success and showed some other moment. Await the seek, and clear the error only when the applied position is the one that was asked for. A rejected seek is caught for the same reason: unawaited, it surfaced as an unhandled rejection instead of a message, and took the test runner down with it. Three tests stubbed goToHistoryEntry with a function that returned undefined and left the history position alone, which is what a seek that stops short looks like. They now move the position the way a completed seek does. Also drop the claim that the document is showing the end of its history from the unresolved-id message. The message outlives the failed request and the reader can move the scrubber, so the sentence goes stale; where the document is is the scrubber's job to report. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot review addressedAll three findings, including the suppressed one, are fixed in Seek not awaited or validated — valid. "Seeked" in a test name — renamed. Thread reply. The suppressed comment on the unresolved-id message — this one was right, and I would rank it above the confidence it was filed with. Nothing clears Three tests had stubbed
One limit worth stating: the in-app verification in the description was run before these changes and I have not re-run it. The unresolved-id and stop-short paths are covered by tests; deliberately corrupting a history entry to exercise the replay failure in a browser is not something I set up. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #2992 +/- ##
==========================================
+ Coverage 86.38% 86.43% +0.05%
==========================================
Files 996 997 +1
Lines 56856 56949 +93
Branches 15060 15065 +5
==========================================
+ Hits 49113 49222 +109
+ Misses 7723 7707 -16
Partials 20 20
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
collaborative-learning
|
||||||||||||||||||||||||||||
| Project |
collaborative-learning
|
| Branch Review |
CLUE-613-history-entry-seek
|
| Run status |
|
| Run duration | 03m 39s |
| Commit |
|
| Committer | Scott Cytacki |
| View all properties for this run ↗︎ | |
| Test results | |
|---|---|
|
|
0
|
|
|
0
|
|
|
0
|
|
|
0
|
|
|
4
|
| View all changes introduced in this branch ↗︎ | |
dougmartin
left a comment
There was a problem hiding this comment.
The scrubber sync and the failure reporting are both solid work, but the seek underneath them lands one history entry short of the requested revision, and this PR adds the success signal that certifies that position as correct. Resolving the index-versus-position mapping is the one thing I'd like settled before merge.
Changes requested
src/models/history/firestore-history-manager.ts(lines 331, 346, 352):findHistoryEntryIndexreturns an array index, butgoToHistoryEntrytakes a position, where positionnmeans entries0..n-1have been applied.tree-manager.ts(lines 558-562) replays the half-open range[numHistoryEventsApplied, newHistoryPosition), and the repo's own test atsrc/models/history/tree-manager-multi-tree.test.ts(lines 288-291) pins it: with three entries,goToHistoryEntry(1)leavestreeAat"A1"(entry 0 applied) andtreeBundefined (entry 1 not applied). So seeking to indexishows the document before entryiwas applied, and the requested revision needs positioni + 1.src/models/document/log-document-event.ts(line 54) recordslatestDocumentHistoryEntry?.id, i.e.history[length - 1].id. The comment just above it (lines 47-50) is careful to say this may be either the entry that was current before the change or the one created by it, depending on where the log call sits relative to the edit, so I do not want to overstate it. It does not matter which: under both readings the id names an entry that had already been applied when the event was logged, so restoring that moment needs that entry included, which is positionindex + 1. Either way the researcher is currently shown the document without it. Two further signals thati + 1is the intended rule:README.md(line 179) documents the parameter as "move to the specified revision", and the"first"sentinel on this same line maps to position0, which isindex + 1for a conceptual index of-1and is inconsistent with the raw-index rule used by the other branch of the same expression. Suggested fix: resolve to a position rather than an index, e.g. keepconst index = this.treeManager.findHistoryEntryIndex(historyId), guard onindex < 0, and seek toindex + 1, leaving the"first"branch at0. Add a test infirestore-history-manager.test.tsasserting the argument for a resolvable id (moveToHistoryEntryAfterLoad("a2")over[{id:"a1"},{id:"a2"}]expectsgoToHistoryEntrycalled with2), since no current test pins that mapping. Worth knowing that the existing E2E coverage cannot settle this either:cypress/e2e/functional/teacher_tests/teacher_student_work_spec.js(lines 54-56, 117-125) deliberately types an extra space so the captured id comes from the following event, then asserts onlycontain initialTextandnot.contain additionalText. With a whole extra edit of slack between the checkpoint andadditionalText, it passes under either mapping, so its being green is not evidence the current one is right. Two notes in fairness: this mapping is byte-identical onmaster, so the PR does not introduce it, and the in-app verification in the description could not have caught it, because the new thumb sync derives the slider position from the samenumHistoryEventsAppliedthe seek set, so content and scrubber agree with each other while both sit one entry early. What is new here is line 352 clearinghistoryEntryRequestErroronnumHistoryEventsApplied === entry, which now affirmatively reports success for that position. If you conclude the raw-index convention is deliberate, then the two branches of line 331 still contradict each other andrenderTimeInfoinplayback-control.tsx(lines 264-270), which labels positioniwith entryi's timestamp, is what needs reconciling instead. Either way I'd like it decided in this PR rather than left as is.
Non-blocking
src/components/playback/playback.scss(lines 198-212) andsrc/components/playback/playback-control.scss(lines 574-587): the new.playback-history-request-errorand the existing.playback-failure-warningboth useposition: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); z-index: 10, and.playback-controlsdeclares noposition, so both resolve against the same containing block,.playback-component. When both are visible they are drawn on the exact same spot. That is reachable normally: a deep link whose seek stops short setshistoryEntryRequestError, and dragging the scrubber past the same bad entry then setsplaybackFailureWarning. Consider offsetting the request error (bottom: calc(100% + 28px)) or rendering both through one column container, with a test case that has both present.src/components/playback/playback.scss(lines 199-211) duplicatingsrc/components/playback/playback-control.scss(lines 575-586): eleven of thirteen declarations are identical, including three hardcoded hex colors (#f8d7da,#721c24,#f5c6cb) that appear nowhere else insrc/. Onlymax-width/text-alignversuswhite-space: nowrapdiffer. Worth extracting a shared placeholder or mixin intosrc/components/vars.scss(both files already import it) so the two alerts cannot drift, and promoting the colors to named variables while you are there.src/models/history/firestore-history-manager.ts(lines 304-359) andsrc/components/playback/playback.tsx(lines 45-50):historyEntryRequestErroris cleared only by a later successful seek, and there is no dismiss control. SincemoveToHistoryEntryAfterLoaddoes not reset it on entry, a stale message also survives the up-to-30-second load wait of a new request. Clearing it as the first statement ofmoveToHistoryEntryAfterLoadcosts one line; a dismiss button, or clearing when the user takes over the scrubber, would stop the message occupying the strip above the playback bar for the life of the view.src/components/playback/playback.tsx(lines 24-28) withsrc/components/chat/chat-panel.tsx(lines 76-80): a deep-link seek goes throughmoveToHistoryEntryAfterLoadtogoToHistoryEntryand never callssetPlaybackTime, which onlygoToSliderValuedoes. SoplaybackTimestays undefined and the chat panel shows every comment, including ones written after the linked moment, while the thumb now reports an earlier point. Pre-existing rather than a regression, but this PR is what makes the two visibly disagree, and it is adjacent to the stale-playbackTimebug you already flagged in the description.src/components/playback/playback.test.tsx(lines 47-54):makeHistoryManagerreturns a plain object cast toFirestoreHistoryManager, and the manager tests readhistoryEntryRequestErrordirectly, so nothing exercises the MobX wiring. DeletinghistoryEntryRequestError: observablefrom themakeObservablecall atfirestore-history-manager.ts(line 95) would break the alert in the real app, since nothing would trigger a re-render after the async seek fails, and the whole suite would stay green. A test that renders with a real manager and sets the error after mount would close that.src/components/playback/playback-control.tsx(lines 121-122, 142, 179): the comment sayssliderIndexForHistoryPosition"Returns -1 if the position has no entry in the slider", butsliderEntriesis built by mapping over every history entry (lines 78-79), so every position belowhistory.lengthhas a match and positions at or above it return early.findIndexcan never return-1, making theindex >= 0 ? index : currentguard at line 142 and theactualSliderIndex >= 0guard at line 179 dead. Either drop the-1claim and simplify both call sites, or make the guard real.src/components/playback/playback-control.tsx(lines 55-56):allCommentsis rebuilt on every render, so thesliderEntriesmemo, both new callbacks,goToSliderValue, and the auto-play effect all get fresh identities every render. You noted the effect churn in the description; worth adding that the auto-play cleanup also tears down and restarts the 500 ms advance timer on every render during playback. AuseMemokeyed on[comments, simplePathComments]stabilizes all of it in one line, and would let the redundant re-sort at lines 84-86 go too.src/components/playback/playback-control.tsx(lines 379-382):sliderEntries.findIndex(e => e.kind === "history" && e.index === failure.historyIndex)is the body of the helper this PR just extracted, left as a third copy in the same file. Replacing it withsliderIndexForHistoryPosition(failure.historyIndex)finishes the refactor; note the helper returnssliderEntries.lengthrather than-1past the end, so the guard becomessliderIndex < 0 || sliderIndex >= sliderEntries.length.src/components/playback/playback.test.tsx(lines 57-67): the comment says "The message appears after the document has loaded, so it has to announce itself", but the test renders with the error already set, so therole="alert"node exists at mount, which is the case screen readers do not announce. The assertion only proves the attribute is present. Setting the error after the initial render would make the test match its comment.src/components/playback/playback.test.tsx(lines 69-74): "shows no message when the requested history entry was found" overstates it.moveToHistoryEntryAfterLoadis a barejest.fn(), sorequestedHistoryId="entry-0"does nothing and the test only asserts that an undefined error renders nothing. Either rename to something like "renders nothing when there is no request error", or addexpect(historyManager.moveToHistoryEntryAfterLoad).toHaveBeenCalledWith("entry-0")so the mount effect is actually covered.- Comment audit, four spots where the prose narrates the change rather than the code, which will read oddly once there is no diff to sit beside:
src/models/history/firestore-history-manager.ts(lines 334-336): "Without this the document silently shows some other moment..." argues for the diff, and the second sentence records a resolved review discussion about wording.src/models/history/firestore-history-manager.ts(lines 342-344): three lines to state one non-obvious constraint, and the same paragraph is repeated atfirestore-history-manager.test.ts(lines 389-390). One line would do: "goToHistoryEntry stops at the last position it could apply, so the applied position must be checked."src/components/playback/playback-control.tsx(lines 132-136): five lines where only the last clause carries information the code cannot, namely that a comment and the entry before it share one history position.src/models/history/firestore-history-manager.test.ts(lines 338-342): "Callers now check that, so a stub that leaves the position alone reads as a seek that stopped short" is a before/after framing about this PR; after merge there is no "now". The first sentence of the block stands on its own.
For the record, I re-ran everything on fbbce3b8c: npx jest src/components/playback src/models/history gives 137 passed across 11 suites, npm run check:types is clean, and the only ESLint warnings in the touched files are the two pre-existing ones you named. The refactor in the first commit is behavior-preserving in every branch, the Copilot findings are genuinely addressed, and playback-control.test.tsx is the kind of test that actually fails when the effect is removed.
A history position counts applied entries: position p is the document with entries 0..p-1 applied. findHistoryEntryIndex returns an index, and moveToHistoryEntryAfterLoad passed it straight to the seek, so a link landed on the document as it stood before the entry it named, not after. An id always names an entry that has been applied. currentHistoryEntry is history[numHistoryEventsApplied - 1], logCurrentHistoryEvent pairs an id with the position one past its index, and revisionId names the last entry the saved content includes. So the moment an id names is index + 1. The playback slider carried the same off-by-one, and it is the older half of the bug. Before sliderEntries was introduced the slider value was the history position itself and the readout came from currentHistoryEntry; adding the array re-pointed each stop at the entry it had not applied yet. Stops are document states again: sliderStops[0] is the initial document and a history stop is the document once its own entry has been applied. That fixes the time readout, which labelled each stop with the timestamp of the change the reader had not seen yet, and comment markers, which showed the document one change before the comment was written -- the thing the marker code always claimed to do. Renamed, because the confusion lived in the names: - goToHistoryEntry -> goToHistoryEntryPosition - sliderEntries -> sliderStops, with an explicit "initial" stop at index 0 so a stop's array index is the slider value that selects it - goToSliderValue -> goToSliderStop - sliderValue -> currentStopIndex, historyPositionForSliderValue and sliderIndexForHistoryPosition to match. "Slider value" now survives only at the rc-slider boundary. Three tests cover the behavior: the seek resolves an id to index + 1, a clicked comment marker shows the document including the entry before it, and the readout labels the position with the entry that has been applied. docs/history-framework.md gains a section on positions versus indexes, including why a log event's id can name the entry before the change it describes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.playback-history-request-error and .playback-failure-warning were both absolutely positioned at bottom:100%, left:50%, translateX(-50%), z-index:10, and .playback-controls declares no position, so both resolved against .playback-component and were drawn on top of each other. Both can be visible at once: a deep link whose seek stops short sets the request error, and dragging the scrubber past the same unplayable entry then sets the failure warning. The request error now stacks above the warning. The offset is the height of a one-line alert, which is what the warning is -- it sets white-space: nowrap. Eleven of their thirteen declarations were identical, including three hex colors that appear nowhere else in src/. Those are now named variables in vars.scss behind a playback-alert mixin, so the two cannot drift apart. Also clear historyEntryRequestError when a request starts rather than only when one succeeds. Nothing else clears it, and the wait for the history to load runs up to 30 seconds, so a message describing a request the reader had already replaced could sit on screen for all of it. The overlap itself is not unit-testable: jsdom loads no stylesheets, so a test that renders both alerts would assert they exist, not that they are apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
playback.test.tsx built its manager as a plain object cast to FirestoreHistoryManager, so nothing exercised the observable. Deleting historyEntryRequestError from makeObservable breaks the alert in the app -- nothing re-renders when the seek fails -- and left the suite green. The new test builds a real manager and sets the error after mount; with the declaration removed it fails and the stubbed test still passes, which is the gap. Setting the error after mount also makes the accessibility claim true. The existing test rendered the role="alert" node already populated, which is the one case screen readers do not announce, so it only proved the attribute was there. "shows no message when the requested history entry was found" claimed more than it tested: moveToHistoryEntryAfterLoad is a bare jest.fn(), so the id did nothing. Renamed to say what it checks, and it now asserts the mount effect passed the id along. allComments was rebuilt on every render, so the sliderStops memo recomputed every render and goToSliderStop got a new identity every render, which tore down and restarted auto-play's 500ms advance timer every render. Memoizing it on the two comment queries settles all of that, and lets the re-sort of an already sorted array inside the memo go. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Memoizing allComments broke history_playback_spec's "verify table tile history": after an undo recorded a new entry with playback open, the play button stayed disabled, because playbackDisabled compares the thumb against the last stop and the stop list had not grown. The sliderStops memo lists [history, allComments] as its dependencies, but history is an MST array and keeps its identity when entries are appended, so it can never invalidate anything. The memo was only ever kept fresh by accident, by allComments being rebuilt on every render. Memoizing allComments removed the accident and exposed the real defect. The entry count is now an input: historyLength is read in the component body, which also makes the observer track it, and the entries are walked by index rather than mapped over an array whose identity never changes. The history is append-only, so its length notices every change to it. Both comment hooks were mocked as returning a fresh [] on every call, which made the component's memos recompute every render in tests and is exactly why no unit test caught this. They now return stable arrays, as the real query hooks do. Against that mock the new test fails on the unfixed code with Expected: "4", Received: "3" -- the slider max not growing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The component had grown to 473 lines of interleaved state, memos and effects. PlaybackControlModel now owns where the reader is in a document's history, what the slider offers them, and the auto-play that walks them through it; the component keeps the refs, the marker state and the rendering. Two React workarounds go with it. sliderStops no longer threads the history length through a memo to notice appends, because a MobX computed tracks the MST array directly. And the effect that followed the document's history position is now a computed: currentStopIndex derives from numHistoryEventsApplied rather than being pushed at it. Fixes found while moving the code: - The chat panel's playback time was only set when the slider was dragged, so a link into a document's history left the panel showing every comment, a blocked seek left it on a moment that was never reached, and closing playback left it filtered. It is now derived from the stop the thumb is on. - Dragging the slider started a fresh history replay on every mouse move, all of them running at once. goToSliderStop now keeps one seek in flight and coalesces the rest, so a drag runs one replay to wherever the reader ended up. - The thumb snapped back to the start of a seek and crawled after the cursor, because rc-slider discards a dragged position that disagrees with the controlled value. sliderValue shows where a running seek is headed. - A reader at the end of the slider followed new comments but was left behind by new history entries. Both now advance them, through the same request to be at the end. - stopIndexForHistoryPosition answered the end of the history with the last stop, which is a trailing comment rather than the entry on screen. The (N) readout only names history stops, so a link landing at the end stopped naming the change it had just applied. The model is covered by playback-control-model.test.ts. The component tests gained the requestedHistoryId prop, a setPlaybackTime mock stable across renders like the real one, and two tests for the chat panel. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A playback view sitting at the end of a document's history now advances to entries recorded while the reader watches, the same way it already advanced to comments posted past them. The spec asserted the opposite: that an undo in the primary document left the view a stop behind, showing the row the undo had just removed. The section it asserted that in was built on the view staying put -- undo, play forward to reach the new entry, redo, play forward again -- so it is restructured rather than adjusted. It now checks both halves of the rule: a view at the end follows the entry an undo records, and a view scrubbed back to earlier in the history stays where it was left and has to be played forward to reach an entry recorded since. The playback step no longer waits a fixed two seconds. Playing from early in the history takes a stop every 500ms, so the assertions simply retry until the row appears. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Disposing the playback model during an active seek can allow autoplay to continue after unmount.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 18/18 changed files
- Comments generated: 2
- Review effort level: Balanced
advance() clears its timer before awaiting the seek, so disposing during that window left nothing for clearAdvanceTimer to cancel, and the guard after the seek only checked sliderPlaying. Playback carried on stepping an unmounted control. dispose() now clears sliderPlaying so that guard catches disposal too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It moved to playback-control-model.ts with the rest of the extracted model. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment described what callers do "now", which reads oddly once the change it was written beside is history. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@dougmartin Everything from your review is addressed. Walking it in order. Blocking: index versus positionYou were right, and the fix is in "open a linked document with the requested entry applied".
Your reasoning from Two tests pin it, since as you say nothing did: The slider carried the same off-by-one, so rather than pick one of the two branches of your "either way" I fixed both. Renamed where the confusion lived: Your note that Non-blockingAlerts on the same spot. Fixed. The request error stacks above the warning by the height of a one-line alert, which is what the warning is. I did not add the test you suggested: jsdom loads no stylesheets, so a test rendering both would assert they exist, not that they are apart. Duplicated SCSS. Fixed. A Stale request error. Fixed, with a test. Cleared as the first statement of Chat panel disagreeing with the thumb. Fixed. The panel's filter is now derived from the stop the thumb is on, so it follows every way the document can move, including a deep-link seek the slider never started. Test drives MobX wiring untested. Fixed. Dead
Third copy of the findIndex. Fixed — the failure markers call the shared mapping.
"shows no message when the requested history entry was found". Renamed to say what it checks, and it now asserts the mount effect passed the id along. Comment audit. All four. The last one — "Callers now check that" — went in a commit just now; the others went with their sections above. Since your reviewThe component you reviewed has been reshaped: its state, memos and callbacks moved into a
|
dougmartin
left a comment
There was a problem hiding this comment.
Looks good 👍
The blocking item from my last review is settled, and settled the right way. moveToHistoryEntryAfterLoad now resolves an id to a position rather than passing an index through, "first" stays at 0, and firestore-history-manager.test.ts pins the mapping with an assertion that actually discriminates. I verified the convention independently rather than taking the description's word for it: tree-manager.ts (lines 558-562) replays the half-open range [numHistoryEventsApplied, newHistoryPosition), currentHistoryEntry reads numHistoryEventsApplied - 1, and setNumHistoryEntriesAppliedFromFirestore (line 370) sets lastHistoryEntry.index + 1, so index + 1 is the position that includes the named entry. Writing the rule down in docs/history-framework.md was the right call, and the rename to goToHistoryEntryPosition is complete: no reference to any of the old names survives anywhere in the tree, including the comment headers in tree.ts and tree-monitor.ts. Every other item from my last review is addressed too, and several of them (the sliderStops computed, the failure-marker helper, the playbackTime derivation) came back better than what I asked for.
On the head commit: npx jest src/components/playback src/models/history gives 188 passed across 12 suites, npm run check:types is clean, and npx eslint on the touched directories reports 0 errors with 4 warnings, all of which I confirmed pre-exist on master. The one real CI failure, regression (09), is document_tests/tiles_copy_test_spec.js timing out on a cell count (found 22, expected 32) and is unrelated to playback; codecov/project is comparing against a report 56 commits behind master, and cypress/flake is reporting flakes rather than failures.
A few non-blocking notes (take or leave):
-
src/components/playback/playback-control-model.ts(lines 334-357):runSeekLoopawaitsgoToHistoryEntryPositionwith notry, so a rejected flow leavesqueuedStopIndexset forever and escapes as an unhandled rejection throughvoid model.goToSliderStop(value)atplayback-control.tsx:88(orvoid this.advance()at line 366). BecausesliderValueisqueuedStopIndex ?? currentStopIndex, the thumb, the time readout and the(N)readout would all keep reporting a stop the document never reached, with no warning, which is precisely the silent-wrong-moment failure this PR exists to remove; during auto-play it would also leavesliderPlayingtrue with no timer, so the button shows Pause forever. This is the same hazard Copilot found inmoveToHistoryEntryAfterLoad, which you guarded atfirestore-history-manager.ts:354-360, and the model is the one funnel left unguarded. I am not blocking on it because the ordinary replay failure is handled inside the flow and surfaces asblocked: only an internal throw (thetreePatches missing entry for known treecase, or a rejection from the unguardedPromise.alloverstartApplyingPatchesFromManager) reaches it. Suggested fix: wrap the await in try/catch, treat a rejection the same as a blocked seek (setplaybackFailureWarning, clearrequestedStop), and move theif (this.queuedStopIndex === target) this.queuedStopIndex = undefined;into afinallyso it clears on both paths, with a test that stubs the seek to reject and assertssliderValuefalls back tocurrentStopIndex. -
src/components/document/canvas.tsx(lines 108-125, 258):checkForHistoryRequestruns only fromcomponentDidMountand never clearsrequestedHistoryId, butsortedDocuments.getDocumentHistoryViewRequest(src/models/stores/sorted-documents.ts, lines 103-111) deliberately deletes the request once read, so the store is one-shot while the component state that mirrors it is not.sort-work-document-area.tsx(lines 151-160) rendersEditableDocumentContentwithout a key, so switching the open document reuses the sameCanvasand the stale id survives. This PR makes that visible in two new ways: the effect inplayback.tsx(lines 24-28) re-fires whenhistoryManagerchanges and now rendersCould not find the requested point in this document's history (id: ...)as an alert on the second document, and the newkey={document?.key}rebuilds the model withrequestedHistoryId ? undefined : "end", so the second document silently loses follow-the-end. Pre-existing rather than introduced here, but this is the PR that surfaces it. Suggested fix: clear the state when the document has no request (anelsebranch settingrequestedHistoryId: undefined), or move the read so it re-runs per document. -
src/components/playback/playback.tsx(lines 48-53): therole="alert"container and its text enter the DOM in the same commit, and assistive technology announces a live region reliably only when the region already exists and its contents change; a region inserted together with its text is announced inconsistently across screen reader and browser combinations. The new test atplayback.test.tsx(lines 98-113) asserts the node is present after the state change, which does not distinguish the two cases. Since the whole point is that a researcher following a report link learns the moment was not reached, a missed announcement returns exactly those users to the silent failure. Suggested fix: render the container unconditionally withhidden={!historyEntryRequestError}and only the message inside, then assert in the test that the alert node exists before the error is set and gains its text afterwards. -
src/components/playback/playback-control-model.ts(line 351) with the test atsrc/components/playback/playback-control-model.test.ts(lines 354-366):if (blocked) this.requestedStop = undefined;has no test that can fail. "goes straight to a new destination after a blocked seek" assertssliderValue === 1right aftergoToSliderStop(1), and that call setsqueuedStopIndex = 1synchronously, so the assertion is true by construction and stays green with line 351 deleted. The other four blocked-seek tests do not cover it either: in each,historyPositionForStopIndex(requestedStopIndex)already fails to match the landed position, socurrentStopIndexfalls through tostopIndexForHistoryPositionand answers the same either way. The line's real job is to stop a reader whoserequestedStopwas"end"being dragged back to the end by the follow-end reaction on the next new entry, retrying the entry that just failed. Suggested fix: in the follow-the-end describe, run a blockedgoToSliderStop(4)onsetupModel(4, { appliedPosition: 0, failingEntryIndex: 2 }), thenaddEntry(treeManager, 4)and flush, assertingcurrentStopIndexstays at 2. -
src/components/playback/playback-control-model.ts(line 186) with the test atsrc/components/playback/playback-control-model.test.ts(lines 409-421): thehistoryPosition === undefinedbranch is never exercised. The test whose comment claims to cover it passesappliedPosition: 0, which is a defined position, andsetupTreeManager(line 65) can never produceundefinedbecause it always callssetNumHistoryEntriesApplied. With line 186 deleted,stopIndexForHistoryPosition(undefined)falls through both guards,findIndexreturns -1,currentStopIndexbecomes -1 and the thumb leaves the rail, and nothing in the suite notices. The comment also promises coverage the test does not deliver. Suggested fix: letappliedPositionbe explicitly absent ("appliedPosition" in optionsrather than??), then assertcurrentStopIndex === lastStopIndexandplaybackDisabledwhile the position is unknown, and that it follows the document once the position lands. -
src/components/playback/playback-control-model.ts(lines 275-279) with the test atsrc/components/playback/playback-control-model.test.ts(lines 607-620): theseenIndicesdedup is never exercised.TreeManageralready dedupes by(historyIndex, direction)(tree-manager.ts, lines 773-796) and the backward seek in the test never touches entry 2, sohistoryPlaybackFailuresholds exactly one element by the time the assertion runs; deleting the wholeseenIndicesset leaves the suite green. The model's dedup exists precisely because the manager's key includes direction and the model's does not, so the same entry failing on both redo and undo would draw two markers on one spot, and that is the case the test does not create. Suggested fix: push two failures with the samehistoryIndexand differentdirectionvalues directly ontotreeManager.historyPlaybackFailures, then assertuniqueFailures.lengthis 1. -
src/models/history/firestore-history-manager.ts(lines 328-332): the branch that reportshistoryNotLoadedMessagewhenwhen()resolves onHISTORY_ERRORrather thanHISTORY_LOADEDis new here and uncovered; the "reports a history that never loads" test (firestore-history-manager.test.ts, lines 445-456) exercises the 30-second timeout at lines 322-327 instead. It is one of the three give-up paths this ticket is about, and the one a real Firestore permission failure takes. Suggested fix: drive the manager toHISTORY_ERRORviasetHistoryError, callmoveToHistoryEntryAfterLoad("first"), and assert the error is set andgoToHistoryEntryPositionwas never called. -
src/models/history/firestore-history-manager.ts(lines 361-367): the success branch is dead. Line 307 clears the error as the first statement of the method and nothing between 307 and 361 sets it on the success path, sosetHistoryEntryRequestError(undefined)at line 362 can never change anything, and the test covering it (firestore-history-manager.test.ts, lines 408-416) passes with the line deleted. This is what the move of the clear to the top of the method orphaned. Suggested fix: drop theif/elseand keep only the failure path. -
src/components/playback/playback-control-model.test.ts(lines 320-324): "shows where the document is once nothing is moving it" assertsmodel.sliderValue === model.currentStopIndex, butsliderValueisqueuedStopIndex ?? currentStopIndexand no seek is queued, so the two sides are the same expression and the test cannot fail for any implementation that falls back tocurrentStopIndex. Suggested fix: assert a concrete number instead, for examplesetupModel(5, { appliedPosition: 2 })thenexpect(model.sliderValue).toBe(2), which fails ifsliderValuefalls back torequestedStopIndexorlastStopIndex. -
src/models/history/log-history-event.ts(line 12):historyIndex?: number; // Index into history array. Used for start, stop, seek.contradicts the convention this PR documents. The value assigned on line 53 isnumHistoryEventsApplied, a position, anddocs/history-framework.md(line 99) now says so explicitly. This is the exact conflation the PR set out to end, still sitting one line above the field a reader reaches for, and the new doc and this comment now disagree in writing. Suggested fix:// History position (count of applied entries), not an array index. Used for start, stop, seek.A rename would be nicer but it is a logged wire field, so a follow-up. -
src/components/playback/playback-control-model.ts(line 359): "Percentage along the rail, which spans the stops after the initial one" describes the pre-initial-stop arrangement. The slider rendersmin={0} max={model.lastStopIndex}(playback-control.tsx, lines 161-163), so the rail's left edge is the initial stop and100 * (stopIndex / lastStopIndex)puts stop 0 at 0%. It is the one line telling a reader what the percentage is relative to, and it names the wrong origin, which is how the marker off-by-one you just fixed got in. Suggested fix:// Percentage along the rail, which runs from the initial stop at 0 to the last stop. -
src/components/playback/playback-control-model.test.ts(lines 433-434): "rc-slider reports every mouse move of a drag, and each one used to start its own replay through the trees on top of the ones already running" is a before/after framing about this PR. After merge there is no "used to". Suggested fix: state it as the reason the coalescing exists, for example "rc-slider reports every mouse move of a drag, so without coalescing each one starts its own replay through the trees on top of the ones already running." -
src/models/history/firestore-history-manager.ts(lines 343-344): "The message says only what failed. It outlives the request, and the reader can move the scrubber, so where the document sits is the scrubber's to report" explains why a sentence that no longer exists was removed from the message. A reader a year from now sees a one-linesetHistoryEntryRequestErrorcall and a paragraph defending an absence, and the PR conversation already records the decision. Suggested fix: delete both lines. -
src/models/history/firestore-history-manager.ts(lines 352-353) withsrc/models/history/firestore-history-manager.test.ts(lines 337-341 and 418-419): the same constraint, thatgoToHistoryEntryPositionstops at the last position it could apply so callers must check where it landed, is written out three times. This is the item that came down from three lines to two but kept the duplication. Suggested fix: keep the source comment as one line, cut themockCompletedSeekblock to its first sentence, and delete the comment above "reports a seek that stops short", where the test name already says it. -
src/components/playback/playback-control-model.ts(lines 16-20, 181-185, 325-328): three comments longer than the point they make. Lines 16-20 spend five lines on the initial stop, of which only "it describes no change, so it has no date" is not already indocs/history-framework.md; lines 181-185 spend five lines where the load-ordering fact is the whole content; lines 325-328 spend four lines ondispose(), where only "set directly rather than through togglePlay, which would log a pause the reader never performed" is not readable from the two statements below. Suggested fix: one line each. -
cypress/e2e/functional/teacher_tests/history_playback_spec.js(lines 145-146): "so it follows the entry the undo recorded rather than being left one stop behind it" describes the behavior this PR removed, which will not exist for anyone reading the file after merge, and thecy.logon line 143 already states the rule. Suggested fix: cut the trailing clause, or delete the comment. -
PR description, the Testing section: it says
playback-control-model.test.tscovers 43 tests, but on3cbe04b4bthe file runs 44. Small, but it is the kind of number that goes stale after a rebase and is worth re-measuring before merge.
For the record on what I checked rather than assumed: the restructured history_playback_spec.js does assert something real (without the follow-end reaction the playback pane would still show '2' after the undo, so the assertion distinguishes the two behaviors, and the removed cy.wait(2000) is covered by the 30-second defaultCommandTimeout in CI), the new uniqueFailures: false annotation is correct because MST volatile is created shallow so pushes onto the array are not observable, and I confirmed the mutations that the key new tests catch, including that deleting historyEntryRequestError: observable fails the real-manager alert test while the stubbed one stays green. Nice piece of work: the model extraction earns itself several times over in what it made visible.
runSeekLoop awaited goToHistoryEntryPosition with no guard. A rejection left queuedStopIndex set, so the thumb, the time and the (N) readout all went on naming a stop the document never reached, with nothing said -- the silent wrong moment this ticket is about. It also escaped as an unhandled rejection through the void calls that start a seek, and during playback left sliderPlaying true with no timer, so the button offered Pause forever. Writing the test for it took the jest process down, which is what that escape looks like. A throw is now treated as a seek that stopped short: the failure warning is set, the request is dropped, and the queue is cleared so the thumb returns to the document. The ordinary replay failure is still handled inside the flow; this covers an internal throw, such as a tree with no patches recorded for it. Also closes four gaps where a test could not fail: - Clearing requestedStop after a blocked seek had no cover. The suggested assertion on currentStopIndex does not discriminate, because a retry is blocked at the same place; the thumb jumping to the new end does. - The unknown-position branch of currentStopIndex was never reached: the test that claimed it passed a position of 0. setupTreeManager can now leave the position unset, which needed setNumHistoryEntriesApplied to admit the undefined its own property already allows and the Firestore lookup sets. - The failure-marker dedup was never reached, because the tree manager keys its own dedup on direction and one seek only fails in one of them. An entry fails both ways when the position arrives from Firestore past a bad entry. - "shows where the document is once nothing is moving it" compared sliderValue with currentStopIndex, which is the expression under test. It asserts a number now. Each was checked by deleting the line it covers and watching only the new test fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
getDocumentHistoryViewRequest deletes the request once it is read, so the store is one-shot, but the canvas state mirroring it is not: checkForHistoryRequest only ever sets the id and nothing clears it. Switching the open document in the sort-work view reuses the same canvas, so the second document was asked to show an entry belonging to the first -- which, now that a failed request says so on screen, means an alert about an id that document never had. Cleared when the document changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A live region announces reliably only when it was already there and its contents changed; one inserted together with its text is announced inconsistently across screen reader and browser combinations. The seek this reports resolves long after the reader's attention has moved on, so a missed announcement returns exactly the readers who need it to a silent failure. The region is now rendered from the start and the message put into it. It is absolutely positioned either way, so it takes up no room in the bar, and its padding, border and background are dropped while it is empty so it paints nothing. The test asserts the region is there and empty before the error is set, which the previous one could not distinguish. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clearing the request error at the top of moveToHistoryEntryAfterLoad left the success branch with nothing to do: nothing between the two sets a message, so clearing it again could not change anything, and the test for it passed with the line deleted. Only the failure is reported now. Also covers the give-up path a real Firestore permission failure takes, where the load ends in HISTORY_ERROR rather than never finishing. The existing test exercised the 30-second timeout instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ILogHistory.historyIndex is assigned numHistoryEventsApplied, which is a position -- the count of applied entries -- and docs/history-framework.md now says so. The comment one line above the field said the opposite, which is the conflation this branch set out to end. The cypress comment described the behavior the branch replaced, and the cy.log above it already states the rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@dougmartin Thanks — all fifteen are implemented. Five commits, The unguarded seekFixed, and it behaves exactly as you predicted. Writing the test took the jest process down with an unhandled rejection, which is what that escape looks like from outside. A throw is now treated as a seek that stopped short: failure warning set, The four dead-line findingsAll four were real, and in two of them the suggested assertion would not have caught the deletion. Every one is now checked by deleting the line it covers and confirming only the new test goes red.
The unknown-position branch. Confirmed unreachable, including via your suggested fix: The failure-marker dedup. Real, but not reachable the way you suggested, and finding that out was worth the detour: a seek can only ever fail in one direction. A blocked undo stops at The The restHISTORY_ERROR give-up path. Covered, via Dead success branch. Removed. You are right that moving the clear to the top orphaned it. The live region. Fixed, though not with Canvas holding a stale request. Fixed. One correction to the diagnosis: Comment audit. All seven: PR description. Re-measured: 50 tests, not 43. The Testing section now says so and names the five coverage gaps this pass closed. State on the head commit
Agreed on |
CLUE-613
Why
A link carrying both
studentDocumentandstudentDocumentHistoryId— every answer cell in the Researcher Reports CLUE Student Answers report emits one — is supposed to open a student document at the moment that answer was logged. Three things stopped it from doing that:findHistoryEntryIndexreturns an array index;goToHistoryEntrytook a position, which counts applied entries. Passing one straight to the other showed the document as it stood before the entry the link named.moveToHistoryEntryAfterLoadgave up in three places, each only reachingconsole.warn, leaving the document at the end of its history looking entirely normal.Fixing 2 meant reaching into
PlaybackControlComponent, 439 lines of interleaved state, memos and effects. Rather than add a fourth way to move the thumb, the state moved into aPlaybackControlModel, and several further defects surfaced once the rules were written in one place. Those are listed below; each is small on its own, but together they are most of the diff.The index-versus-position decision
Doug's review asked for this to be settled here rather than left, and it is: an id resolves to
index + 1.A history position counts applied entries — position
pis the document with entries0 … p-1applied — and everywhere the codebase records "which entry is this document at", it records the last entry included, never the next one to come.currentHistoryEntryishistory[numHistoryEventsApplied - 1];logCurrentHistoryEventlogs an id alongside a position one past that id's index;revisionIdand the envelope'slastHistoryEntryIdname the last entry the saved content includes. So the moment an id names is the position that includes it.Worth knowing for anyone reading a link later: a log event's
documentHistoryIdis the last completed entry when the event was written, and entries complete asynchronously. For a tile logging fromonTileAction— which MST calls before the action mutates state — that names the entry before the change being logged; for one logging after its edits settle (the text tile logs on blur) it names the entry the change created.index + 1restores the document as it stood when the event was logged in both cases. It cannot recover a change whose entry did not exist yet, which is a logging question, not a seek one.The convention is now written down in docs/history-framework.md, since its absence is what allowed the two to be conflated.
What changed
The seek and the stops
The seek resolves an id to a position rather than passing an index through, and
"first"— the sentinel for a change made before the document had any entry — stays at position0.The slider carried the same off-by-one, and it is the older half of the bug. Before
sliderEntrieswas introduced the slider value was the history position and the time readout came fromcurrentHistoryEntry; adding the array re-pointed each stop at the entry it had not applied yet. Stops are document states again:sliderStops[0]is an explicit"initial"stop, a stop's array index is the slider value that selects it, and a history stop is the document once its own entry has been applied. That fixes the time readout, which labelled each stop with the timestamp of a change the reader had not seen, and comment markers, which showed the document one change before the comment was written.Failure is reported.
FirestoreHistoryManagergains an observablehistoryEntryRequestError; all three give-up paths set it, the seek is awaited and its landing position checked before a request counts as met, andPlaybackComponentrenders it as arole="alert". It is cleared when a request starts, not only when one succeeds, so a stale message does not sit through the next request's 30-second load wait.A model for the playback control
PlaybackControlModelowns where the reader is in a document's history, what the slider offers them, and the auto-play that walks them through it. The component keeps its refs, the marker state and the rendering, and shrinks from 439 lines to 279.Two React workarounds go with it.
sliderStopsno longer threads the history length through a memo to notice appends, because a MobX computed tracks the MST array directly. And the effect that synced the thumb fromnumHistoryEventsAppliedis now a computed —currentStopIndexderives from the document's position rather than being pushed at it, which is what makes a seek nobody asked for still move the thumb.Comments cannot move into the model: they come from Firestore through React Query hooks, so the component pushes them in with
setComments.Where the thumb belongs
currentStopIndexis where the document is.sliderValueis where the thumb belongs, and the two differ while a seek of the model's own is running.This is a real defect, not tidiness. rc-slider renders a dragged position only while it still equals the controlled value (useDrag.js), and
numHistoryEventsAppliedis assigned once, at the very end of the seek flow. So during a drag the thumb snapped back to where the seek started and crawled after the cursor.sliderValuereports the destination until the document settles there.It matters which one each reader uses, so they are separated by rule: labels follow the thumb, actions follow the document. The time readout, the
(N)entry readout and the chat panel's filter all readsliderStop;playbackDisabledstays oncurrentStopIndex, becauseadvance()steps from there and a play button that disagrees with what pressing it does is worse than one that lags.One seek at a time
rc-slider reports every mouse move of a drag, and each one started its own replay through the trees on top of the ones already running — the hazard
goToHistoryEntryPositionhas a standing TODO about.goToSliderStopnow keeps one seek in flight; a request arriving mid-seek updates the target, and the loop picks it up when the current one finishes. A drag runs one replay to wherever the reader ended up rather than one per intermediate stop.Following the end
A reader who has not picked a stop is following the end of the slider. That is now explicit:
requestedStopisnumber | "end" | undefined— one specific stop, or wherever the end is now, or no standing request. The states are mutually exclusive by construction, and"end"is what an untouched control starts with.It fixes an asymmetry. A reader at the end already followed new comments (the end of the slider moved under them) but was left a stop behind by new history entries, because those move the end without moving the document. Both now advance them, through one reaction on
lastStopIndex.A deep link is the case that must not follow: the reader asked for one entry, not for the end.
PlaybackControlModeltakes that as a constructor argument —requestedHistoryId ? undefined : "end"— rather than trying to detect it later. Detection was tried and does not work: by the time a new stop appears, a reader following along and a reader a link sent into the middle are both at a position that is not the end of the history, and are indistinguishable.PlaybackControlComponentalso gains akeyon the document, since the model is built once per mount and a document swap has to give it a new one.The chat panel follows the thumb (CLUE-665, in part)
The chat panel filters comments to
createdAt <= playbackTime, and onlygoToSliderStopever set that. So a deep link left the panel listing every comment on the document, a blocked seek left it on a moment that was never reached, and closing playback left it filtered by whatever moment was last shown.playbackTimeis now derived fromsliderStopTime— the moment the thumb is on — and cleared when the control unmounts. Verified in the app: a link to entry 0 of a commented document shows the marker on the rail and an empty panel; clicking the marker shows the comment.The initial stop is deliberately left as it was. It has no timestamp, and
undefinedalready means "not filtering" — the value the panel needs before playback opens and after it closes. Expressing "before everything" needs a signal other than a bareDate, so that half of CLUE-665 stays open rather than being smuggled in behind a sentinel.Smaller things the model surfaced
stopIndexForHistoryPositionanswered the end of the history with the last stop, which is a trailing comment rather than the entry on screen. The(N)readout only names history stops, so a link landing at the end stopped naming the change it had just applied. Now every position within the history maps to its own history stop."end", not as the index that happens to be last, so a reader who drags to the end is not silently pinned to a stop that new comments then arrive past.uniqueFailuresis annotatedfalserather than left as a computed: a TreeManager's volatile state is shallow, sohistoryPlaybackFailures.pushis not observable and could never invalidate a cached value.Names
Since the confusion lived in them:
goToHistoryEntry→goToHistoryEntryPosition,sliderEntries→sliderStops,goToSliderValue→goToSliderStop, and the two mapping helpers to match. Within the model,eventCreatedTime→sliderStopTimeandcurrentHistoryIndex→sliderStopHistoryIndex, so the getters that describe the thumb read as one family and are not mistaken for ones that describe the document.The two alerts no longer overlap
.playback-history-request-errorand.playback-failure-warningwere both absolutely positioned at the same spot against.playback-component, and both can be visible at once — a deep link whose seek stops short, then a drag past the same unplayable entry. The request error stacks above the warning, and eleven near-identical declarations plus three one-off hex colors moved behind aplayback-alertmixin invars.scss.Testing
npx jest src/components src/models— 2095 passed, 174 suites. Full suite: 359 suites, 4060 passed.npm run check:typesclean,npm run lint:build0 errors.playback-control-model.test.tscovers the model directly — 50 tests over stop ordering, the position ↔ stop-index mapping, blocked seeks, coalescing, follow-the-end and auto-play. Every behavioral test was written before its code and watched failing, including the ones added late: the thumb reporting stop 0 while a seek to stop 4 was running; three rapid requests producing twogoToHistoryEntryPositioncalls instead of three; a reader at the end left behind by a new entry; a reader a link sent into the middle being dragged to the end.The MobX wiring test earns its place by construction — deleting
historyEntryRequestError: observablemakes it fail while the stub-based test alongside it still passes, which was the gap Doug identified.history_playback_spec.jswas restructured, and it is worth a reviewer's attention. It asserted that an undo in the primary document leaves the playback view a stop behind, showing the row the undo had just removed — the opposite of the follow-the-end rule above. The section it asserted that in was built on the view staying put, so it now checks both halves of the rule instead: a view at the end follows the entry an undo records, and a view scrubbed back stays where it was left and has to be played forward. Verified to still fail when the follow-end reaction is disabled, so it guards the behavior rather than tolerating it. Noitblocks were added, given the per-month budget.A later review pass found five places where a test could not fail — the request cleared after a blocked seek, the unknown-position branch, the failure-marker dedup, the HISTORY_ERROR give-up path, and a
sliderValueassertion that compared the expression with itself. Each is now covered by a test checked the same way: delete the line it covers, watch only that test go red.Two things I did not test. The alert overlap is CSS-only and jsdom loads no stylesheets, so a test rendering both alerts would assert they exist, not that they are apart. And
teacher_student_work_spec.jspasses under either index-versus-position mapping — it types an extra space between the checkpoint andadditionalText— so its being green is not evidence about this fix either way.Follow-ups, not in this PR
studentDocumentlink —fetchFullDocumentlogged "Could not find metadata doc with key" for one whose metadata carriesunit: null. Noticed while building test links, unrelated to this PR, and not diagnosed. Recorded on CLUE-631, which covers personal documents in the history viewer from the launch-parameter side; this looks like a second blocker on its first acceptance criterion.🤖 Generated with Claude Code