diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d969114..2e52c9fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [0.2.41.0] - 2026-10-25 (development candidate) + +Cycle 0052 pt2 ([Issue #124](https://github.com/laststance/codebook/issues/124)), the second of three pull requests of a bug-fix cycle: a test replay's moves, notices and summary page. + +**Moves show no stop you moved away from.** + +- **The recorded-source tab draws no CodeLens.** The editor redraws a lens only after its own delay, so a lens could still name the stop you left beside the next moment. The stop's whole label stays at the end of the stopped line, and its hover carries it where a narrow group cuts the line. +- **A move from a stop to an output or the result** shows the placeholder from its first line, with no sticky-scroll header of the stop's source above it. +- **A move that fails is worded by its cause.** When the replay debugger cannot start, the line keeps its restart advice; when the moment fails for another cause, it reads `The recorded moment could not be shown: . The last moment is still shown. Select this stop again to retry.` The line says the last moment is still shown only when it is: when it cannot be shown again either, the recorded-source row says why. +- **A refused or failed move shows the storage folder as ``**, on the transport's status line and in the Study pane's line, also after the runtime is released; the causes Codebook words after a release name the storage, the workspace and the home folder as ``, `` and `~`, as before it. + +**Notices and the summary page.** + +- **A one-replay-at-a-time notice you leave unanswered never holds up your next Open replay**, which asks its own notice at once; a later answer to the earlier notice still closes nothing. +- **Each open of a test replay starts with its question, summary and capture reason collapsed**, also when you open the same replay again; a move inside the replay keeps the text you expanded. +- **The diagram marks the selected step on that step's own arrow and label**, also in a drawing whose labels are not in step order. + +**Compatibility.** The MCP API stays 1.8, the bundled skill is unchanged and no stored format changes. Tasks 5, 7 to 10, 22, 23, 26, 27 and 47 of Issue #124 follow in the cycle's third pull request. + ## [0.2.40.0] - 2026-10-25 (development candidate) Cycle 0052 pt1 ([Issue #124](https://github.com/laststance/codebook/issues/124)), the first of three pull requests of a bug-fix cycle: replays leave, open and reload without stale frames, and the entries and exits behave as you chose. diff --git a/README.md b/README.md index eb073cc5..cb16eb3c 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Codebook contributes `debug.focusEditorOnBreak: false` as the native debugger de ## Implementation status -This is a preview, not a Marketplace or Open VSX release. The delivered version is **0.2.40.0** once the first pull request of [Cycle 0052](https://github.com/laststance/codebook/issues/124) is merged; until then 0.2.39.0 ([PR #123](https://github.com/laststance/codebook/pull/123)) is the delivered one, and the active cycle Issue says where the delivery stands. Cycle 0051 opened a test replay in the learning-replay composition a browser replay has: the Learning summary in the chat's place, one transport for both replay families, and a Learning summary button that shows a hidden summary again. Cycle 0052 is a bug-fix cycle that closes every defect Cycle 0051 carried, in three pull requests: the first makes replays leave, open and reload without stale frames and makes their entries and exits behave as the learner chose; the second fixes a test replay's moves and its summary page; the third the live runtime's unreadable facts, a browser run's refusals and the tests' hygiene. The MCP API is 1.8. What each earlier cycle delivered, with its tests, trials and limits, is in the [change history](CHANGELOG.md) and in that cycle's record, kept under the tag [`docs-archive-2026-10-20`](https://github.com/laststance/codebook/tree/docs-archive-2026-10-20/docs/cycles); from Cycle 0048 on, the cycle's Issue holds its tasks and state, and its pull requests the results. +This is a preview, not a Marketplace or Open VSX release. The delivered version is **0.2.41.0** once the second pull request of [Cycle 0052](https://github.com/laststance/codebook/issues/124) is merged; until then 0.2.40.0 ([PR #125](https://github.com/laststance/codebook/pull/125)) is the delivered one, and the active cycle Issue says where the delivery stands. Cycle 0051 opened a test replay in the learning-replay composition a browser replay has: the Learning summary in the chat's place, one transport for both replay families, and a Learning summary button that shows a hidden summary again. Cycle 0052 is a bug-fix cycle that closes every defect Cycle 0051 carried, in three pull requests: the first makes replays leave, open and reload without stale frames and makes their entries and exits behave as the learner chose; the second fixes a test replay's moves and its summary page; the third the live runtime's unreadable facts, a browser run's refusals and the tests' hygiene. The MCP API is 1.8. What each earlier cycle delivered, with its tests, trials and limits, is in the [change history](CHANGELOG.md) and in that cycle's record, kept under the tag [`docs-archive-2026-10-20`](https://github.com/laststance/codebook/tree/docs-archive-2026-10-20/docs/cycles); from Cycle 0048 on, the cycle's Issue holds its tasks and state, and its pull requests the results. The specification is [goal version 10](PRODUCT_GOAL.md), and the implementation targets external repositories. Historical fixture or coupled-Wiki success is not proof of that destination. diff --git a/VERSION b/VERSION index d92eef30..3df83732 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.40.0 +0.2.41.0 diff --git a/extension/README.md b/extension/README.md index 9fe1b3c5..99983e20 100644 --- a/extension/README.md +++ b/extension/README.md @@ -73,13 +73,13 @@ A Vitest test run that the chat explained (the `node-test` profile) is saved the - A stop before the first stop reads stopped, not refused. A run stopped before its history began, or whose history sealed with no entry, offers no `read-history`. After a stop whose release failed, the run offers only `stop`, and a continue is refused with `STALE_PAUSE`. - **Learning save failed · Retry** when stopping the run did not finish (Retry stops it again) or the sealed history could not be read back (Retry reads it again; the test does not run again), and **Learning save failed · Open retained history** when the history could not be sealed (the recorded part opens read-only). Each tooltip names the cause; the details are in **Output › Codebook**. -**Open replay**, or the test replay's row in Saved studies, opens it in the learning-replay composition a browser replay has: the Study pane closes, the Application pane shows the recorded test output above the recorded-source tab, Run and Debug shows the recorded stop, and the **Learning summary** stands in the chat's place, at the replay's saved reader position, or on its first stop whose recorded source verifies. Opening a replay while a live run is shown leaves the Live arrangement for this composition, and **Codebook: Focus Explanation** or **Codebook: Show Learning Summary** shows a hidden summary. An agent cannot open a test replay: `codebook_playback` refuses it, and the chat asks you to open it from **Open replay** or Saved studies. After a window reload no test replay opens by itself; reopen it from Saved studies and it returns at its saved reader position. **Codebook: Open Study Workspace** takes an Application pane that left its group where it stands instead of refusing. A test replay's open leaves the Study's article source closed (no `Captured-overview` tab beside the replay's recorded-source tab, and the captured-source tabs of the browser replay it replaces close once, at that open, unless you pinned or edited them, so no two tabs of one title show different recordings' source; a captured-source tab of another study, and any captured-source tab you open yourself after leaving the browser replay, stay; a tab you close by hand, or move to another editor group, after leaving the browser replay is yours and stays when you reopen it at the same address, also in an editor that rebuilds its tab objects, and a close that the editor refuses is told and retried at the next stop) and shows the Learning summary from its top once. When a preview tab of your own blocks the open, a modal dialog names the file and offers **Keep Tab Open** or **Close Tab**; either one then opens the replay, except for a tab that cannot be kept (below). Keep Tab Open never closes the tab: a text file is pinned, an image or custom editor is kept with the editor's own Keep Open command (its group takes the keyboard focus), and a tab that cannot be kept that way, such as one behind another tab of its group, stays a preview, the replay does not open and a notice says to double-click the tab and one entry in **Output › Codebook** records the refusal. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first, and a browser recording that loads while a test replay is shown replaces the replay, so the Application pane shows the recording's own pages and the Learning summary shows the recording's loading, failure and recovery. When you press **Open replay** twice, or choose a test replay while another one is opening, the replay you chose last opens, and an answer to an earlier one-replay-at-a-time notice closes nothing. Close and Return to Live cancel an Open replay that is still waiting for its turn or for the work window's focus, so it neither asks a second time, holds up your next Open replay nor reopens the reader, and a newer Open replay ends an earlier one that waits for the focus or for the Learning summary to show at once; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left, and an entry overtaken while it closes earlier tabs asks no notice. A superseded Saved studies open gives the Study pane back, and an earlier replay's failed or superseded open leaves the summary composition to a newer Open replay that is entering it. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden, and waits for the work window's focus as the first open does. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view, nor a departing state of the replay you leave, and the transport never shows the other replay family's controls, and a browser replay's open shows nothing of the Study in the Learning summary before the replay's own summary; after a browser replay the Application tab takes back its name `codebook · Application`, and while you leave the browser replay the Application pane never shows the recording's `Loading recorded position…` line. Reloading a browser replay keeps its Learning summary while the recording reloads, with the reload's loading or failure on the replay's status line, and the Application pane keeps the recording through refreshes, a runtime release and Focus source. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, and Saved studies keeps your choice while that recording loads, and a step you select before the recording has loaded again is refused until it has. **Reload saved recording** and **Retry storage / refresh** load the recording again also while a reload is still loading or after it failed (one refused because a layout change is still pending changes nothing, so a failed reload keeps its reason), and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted, and an agent reads the reader as showing the recording, so it returns to Live before it starts a run. A browser study you choose in the bottom panel's Saved studies over a test replay while the panel is maximized waits for the editor area: the panel stays maximized, and the Study pane and the study's source show once you restore the panel; the test replay's recorded-source tab closes then, and a close the editor refuses is told and tried again at your next display. If the replay debugger cannot start at that first open (a declined workspace trust, for example), the replay still opens on that moment and the Learning summary says on its replay-debugger line why the debugger is missing; **Retry storage / refresh** or Inspect retries it. A debugger you stopped stays stopped when you move to an output or the result. A failed open shows its cause with the storage folder replaced by ``. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. When the open is refused (the replay's composition could not be arranged), Codebook tells it with the load-failure notification and one entry in **Output › Codebook**, like a replay that failed to load; a failed open brings the Study pane back with the failure on its line, and a replay that was shown before stays on its last moment, paused. When **Restore** of a test replay's layout fails (the editor refuses a layout command, does not keep the layout, or a pane stands outside its Study destination), the layout stays unconfirmed and the Learning summary shows the failure as its reason, in the failure's own words. +**Open replay**, or the test replay's row in Saved studies, opens it in the learning-replay composition a browser replay has: the Study pane closes, the Application pane shows the recorded test output above the recorded-source tab, Run and Debug shows the recorded stop, and the **Learning summary** stands in the chat's place, at the replay's saved reader position, or on its first stop whose recorded source verifies. Opening a replay while a live run is shown leaves the Live arrangement for this composition, and **Codebook: Focus Explanation** or **Codebook: Show Learning Summary** shows a hidden summary. An agent cannot open a test replay: `codebook_playback` refuses it, and the chat asks you to open it from **Open replay** or Saved studies. After a window reload no test replay opens by itself; reopen it from Saved studies and it returns at its saved reader position. **Codebook: Open Study Workspace** takes an Application pane that left its group where it stands instead of refusing. A test replay's open leaves the Study's article source closed (no `Captured-overview` tab beside the replay's recorded-source tab, and the captured-source tabs of the browser replay it replaces close once, at that open, unless you pinned or edited them, so no two tabs of one title show different recordings' source; a captured-source tab of another study, and any captured-source tab you open yourself after leaving the browser replay, stay; a tab you close by hand, or move to another editor group, after leaving the browser replay is yours and stays when you reopen it at the same address, also in an editor that rebuilds its tab objects, and a close that the editor refuses is told and retried at the next stop) and shows the Learning summary from its top once. When a preview tab of your own blocks the open, a modal dialog names the file and offers **Keep Tab Open** or **Close Tab**; either one then opens the replay, except for a tab that cannot be kept (below). Keep Tab Open never closes the tab: a text file is pinned, an image or custom editor is kept with the editor's own Keep Open command (its group takes the keyboard focus), and a tab that cannot be kept that way, such as one behind another tab of its group, stays a preview, the replay does not open and a notice says to double-click the tab and one entry in **Output › Codebook** records the refusal. One replay shows at a time: while a browser replay is open, Codebook asks you to close it first, and a browser recording that loads while a test replay is shown replaces the replay, so the Application pane shows the recording's own pages and the Learning summary shows the recording's loading, failure and recovery. When you press **Open replay** twice, or choose a test replay while another one is opening, the replay you chose last opens, and an answer to an earlier one-replay-at-a-time notice closes nothing; a notice you leave unanswered never holds up your next Open replay, which asks its own notice at once. Close and Return to Live cancel an Open replay that is still waiting for its turn or for the work window's focus, so it neither asks a second time, holds up your next Open replay nor reopens the reader, and a newer Open replay ends an earlier one that waits for the focus or for the Learning summary to show at once; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left, and an entry overtaken while it closes earlier tabs asks no notice. A superseded Saved studies open gives the Study pane back, and an earlier replay's failed or superseded open leaves the summary composition to a newer Open replay that is entering it. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden, and waits for the work window's focus as the first open does. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view, nor a departing state of the replay you leave, and the transport never shows the other replay family's controls, and a browser replay's open shows nothing of the Study in the Learning summary before the replay's own summary; after a browser replay the Application tab takes back its name `codebook · Application`, and while you leave the browser replay the Application pane never shows the recording's `Loading recorded position…` line. Reloading a browser replay keeps its Learning summary while the recording reloads, with the reload's loading or failure on the replay's status line, and the Application pane keeps the recording through refreshes, a runtime release and Focus source. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, and Saved studies keeps your choice while that recording loads, and a step you select before the recording has loaded again is refused until it has. **Reload saved recording** and **Retry storage / refresh** load the recording again also while a reload is still loading or after it failed (one refused because a layout change is still pending changes nothing, so a failed reload keeps its reason), and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted, and an agent reads the reader as showing the recording, so it returns to Live before it starts a run. A browser study you choose in the bottom panel's Saved studies over a test replay while the panel is maximized waits for the editor area: the panel stays maximized, and the Study pane and the study's source show once you restore the panel; the test replay's recorded-source tab closes then, and a close the editor refuses is told and tried again at your next display. If the replay debugger cannot start at that first open (a declined workspace trust, for example), the replay still opens on that moment and the Learning summary says on its replay-debugger line why the debugger is missing; **Retry storage / refresh** or Inspect retries it. A debugger you stopped stays stopped when you move to an output or the result. A failed open shows its cause with the storage folder replaced by ``. Saved studies list each test replay with its result, its capture and its explained stops, for example `Test replay · Test passed · exit code 0 · capture complete · 2 of 2 stops explained`. When the open is refused (the replay's composition could not be arranged), Codebook tells it with the load-failure notification and one entry in **Output › Codebook**, like a replay that failed to load; a failed open brings the Study pane back with the failure on its line, and a replay that was shown before stays on its last moment, paused. When **Restore** of a test replay's layout fails (the editor refuses a layout command, does not keep the layout, or a pane stands outside its Study destination), the layout stays unconfirmed and the Learning summary shows the failure as its reason, in the failure's own words. The **Application pane** shows a test replay's recorded test output up to the selected moment and nothing printed after it. Its header reads `Recorded test output · s` over the test's file and title path (`` counts from the run's start, paused time included; while the replay loads and for a replay without entries the line reads `Recorded test output` alone, and while another replay opens, the one shown keeps its output untouched, and the replay being opened reads `Loading recorded test output…` under its own name). Lines the test wrote to standard error start with `[stderr]` and a space; terminal colors, cursor moves, window titles and other control characters are removed, also when a sequence is split across two output entries, and the same removal applies to every recorded text the replay shows (the Learning summary's events with their file paths, frame names and value names, the line that says why a capture ended, the result's output tail, the captured values and the test titles in the header); the Node inspector's own lines fold into one muted line such as `2 Node inspector lines hidden`; and the pane keeps the last 65,536 characters, with a line such as `… 1200 earlier characters not shown` before them (`… 1 earlier character not shown` and `1 Node inspector line hidden` in the singular; each emoji counts as one character). At the result the output ends with the verdict (`Test passed · exit code 0`, `Test failed · exit code ` or `Ended by signal `); a replay that ended before its result says `Recording ended before the test result.`, and one this window is still recording says nothing of an end. The output region is a tab stop named `Recorded test output up to s`, so the keyboard can scroll it. Moving through the replay updates the same page in place, so your scroll position, text selection and keyboard focus stay; the pane follows the latest output until you scroll up, and **Jump to latest** follows it again; moving back to a shorter moment does not turn following back on. While you read above the end, the line you read stays where it stood when a later moment drops the earliest kept lines. The extension keeps your follow choice and your scroll position in each test replay while it is selected, so a page the editor loads again (the pane moved to another window, webviews reloaded) or a pane it replaces (a layout change, Retry) shows the latest moment and opens where you were reading; moments that arrive faster than the screen redraws (Play) are drawn once per frame. The saved place is kept when such moments are joined, and it never overrides a scroll you made after the pane opened. Moving through a long output costs about the 65,536 characters the pane keeps, however many lines it holds or however long a line printed without line feeds is. -A test replay's **Learning summary** reads, top to bottom: the test's file and title path, the learning title, the result, the capture and the explained stops (for an interrupted or partial capture, on a line of its own, why it ended), the question and the processing summary. The question, the summary and the capture reason show at most three lines with **Show more**, which shows the whole text in place; **Show less** takes it back. Then comes the sequence diagram, whose names and step labels wrap at word boundaries inside their boxes (a Japanese, Chinese or Korean label has no spaces and wraps between its characters, measured in the diagram's font, so it is shown whole and clear of its neighbours) and which scrolls inside its own region when it is taller or wider than its place. The selected step's arrow and label are marked in the link color, bold and underlined, and the mark moves with the replay without redrawing the diagram, so its scroll position stays. Its numbered steps follow under the collapsed **Step details**, where the selected step is marked and the other steps of the same moment read `Same recorded moment`; a step's link selects its moment and that step. Then the moment: a heading `Step · ` when the moment has a step, and the position (`Stop of · s`, `Output · s` or `Result · · s`) with the explanation the chat saved for it, or `No saved explanation for this stop.`; an output or a result that no step references shows no step heading and marks no step. While a moment is still loading, its step heading, its explanation and the diagram's mark wait for it. At an output, at the result and at a stop whose recorded source does not verify, the moment adds `Source and debugger not recorded at this moment.`; at a stop whose values are not loaded, **Inspect in native debugger** stands beside the hint and **Show Variables**. While the debugger shows the selected stop and its values are not loaded, that button reads `Debugger stop selected`; once the debugger no longer shows the stop (you ended its session, or it could not start), it reads **Inspect in native debugger** again and starts it. The button follows the selected stop, also while the event list shows another page. When either button goes away while the keyboard is on it, the focus moves to the moment's step heading, or to the summary's title when the moment has none. Then come **I understand this test history** and the events under the collapsed **Events ()**. The learning title, like the Application header's test line, is a tab stop that shows the whole name while it has the focus when it is cut at two lines. When the Application pane is closed, the summary says `The Application pane is closed.`, and **Show recorded output** opens the pane again at the selected moment. The reopened pane gets a group of its own above the recorded-source tab's group, and no other pane moves; when your editor kept the emptied group (`workbench.editor.closeEmptyGroups` off), the pane returns into it. +A test replay's **Learning summary** reads, top to bottom: the test's file and title path, the learning title, the result, the capture and the explained stops (for an interrupted or partial capture, on a line of its own, why it ended), the question and the processing summary. The question, the summary and the capture reason show at most three lines with **Show more**, which shows the whole text in place; **Show less** takes it back. Every open of a test replay, also the same one opened again, starts with these texts collapsed; a move inside the replay keeps the one you expanded. Then comes the sequence diagram, whose names and step labels wrap at word boundaries inside their boxes (a Japanese, Chinese or Korean label has no spaces and wraps between its characters, measured in the diagram's font, so it is shown whole and clear of its neighbours) and which scrolls inside its own region when it is taller or wider than its place. The selected step's own arrow and label are marked in the link color, bold and underlined, also in a drawing whose labels are not in step order, and the mark moves with the replay without redrawing the diagram, so its scroll position stays. Its numbered steps follow under the collapsed **Step details**, where the selected step is marked and the other steps of the same moment read `Same recorded moment`; a step's link selects its moment and that step. Then the moment: a heading `Step · ` when the moment has a step, and the position (`Stop of · s`, `Output · s` or `Result · · s`) with the explanation the chat saved for it, or `No saved explanation for this stop.`; an output or a result that no step references shows no step heading and marks no step. While a moment is still loading, its step heading, its explanation and the diagram's mark wait for it. At an output, at the result and at a stop whose recorded source does not verify, the moment adds `Source and debugger not recorded at this moment.`; at a stop whose values are not loaded, **Inspect in native debugger** stands beside the hint and **Show Variables**. While the debugger shows the selected stop and its values are not loaded, that button reads `Debugger stop selected`; once the debugger no longer shows the stop (you ended its session, or it could not start), it reads **Inspect in native debugger** again and starts it. The button follows the selected stop, also while the event list shows another page. When either button goes away while the keyboard is on it, the focus moves to the moment's step heading, or to the summary's title when the moment has none. Then come **I understand this test history** and the events under the collapsed **Events ()**. The learning title, like the Application header's test line, is a tab stop that shows the whole name while it has the focus when it is cut at two lines. When the Application pane is closed, the summary says `The Application pane is closed.`, and **Show recorded output** opens the pane again at the selected moment. The reopened pane gets a group of its own above the recorded-source tab's group, and no other pane moves; when your editor kept the emptied group (`workbench.editor.closeEmptyGroups` off), the pane returns into it. -One transport serves both replay families: in the bottom panel, which reads **Learning replay** during a replay and shows the transport alone, or at the foot of the Learning summary while the bottom panel is hidden. Left to right it holds **?** (Keyboard shortcuts), **Replay from start**, **Previous stop**, **Play**, **Next stop**, the time slider with its coverage marks and the readout (` s / s` for a test replay), and for a test replay **Previous event** and **Next event**, which step through outputs and the result as well. Replay from start selects the first event by its place in the history, also when several events share its time, and Home and End reach the first and last event from any page of the event list. The status line under the controls reads `Test replay playing` or `Test replay paused`, and names a refused or failed move in the host's words, for example `There is no next stop in this test replay.`; the moment shown stays, and the line is read also while the summary is hidden. When the replay debugger does not start on a move, the line says so, adds `The last moment is still shown. Select this stop or use Inspect in native debugger to restart it.`, and selecting that stop again reaches it. Two quick presses of **Next stop** move two stops, and moves from the summary, the bottom panel and a held key end at your latest one; a move that fails, or that a Close, a reopen, Return to Live or a browser recording cuts off, gives its place back, so the next move starts from the moment shown; at the last stop it is refused with `There is no next stop in this test replay.`. After you stopped the replay debugger, **Next stop**, **Previous stop** or **Inspect in native debugger** starts it again at the stop, while a seek or **Play** that reaches a stop stops there with `The replay debugger was stopped. Select this stop or use Inspect in native debugger to restart it.` A test replay's transport shows none of a browser replay's recovery (**Reload saved recording**, **Recording error details**). A history of one event has no move to make, and a history whose events all share one time keeps its slider for the keys. **Return to Live** from a test replay gives the Live composition with the Study pane closed, as from a browser replay; leaving a playing replay (Return to Live, another saved study) stops its playback. +One transport serves both replay families: in the bottom panel, which reads **Learning replay** during a replay and shows the transport alone, or at the foot of the Learning summary while the bottom panel is hidden. Left to right it holds **?** (Keyboard shortcuts), **Replay from start**, **Previous stop**, **Play**, **Next stop**, the time slider with its coverage marks and the readout (` s / s` for a test replay), and for a test replay **Previous event** and **Next event**, which step through outputs and the result as well. Replay from start selects the first event by its place in the history, also when several events share its time, and Home and End reach the first and last event from any page of the event list. The status line under the controls reads `Test replay playing` or `Test replay paused`, and names a refused or failed move in the host's words, for example `There is no next stop in this test replay.`; the moment shown stays, and the line is read also while the summary is hidden. When the replay debugger does not start on a move, the line says so, adds `The last moment is still shown. Select this stop or use Inspect in native debugger to restart it.`, and selecting that stop again reaches it; when the moment fails to show for another cause, the line reads `The recorded moment could not be shown: . The last moment is still shown. Select this stop again to retry.` When the last moment cannot be shown again either, the line leaves out `The last moment is still shown.` and the recorded-source row says why. A refused or failed move's cause shows the storage folder as `` on this line and in the Study pane's line, also after the runtime is released. Two quick presses of **Next stop** move two stops, and moves from the summary, the bottom panel and a held key end at your latest one; a move that fails, or that a Close, a reopen, Return to Live or a browser recording cuts off, gives its place back, so the next move starts from the moment shown; at the last stop it is refused with `There is no next stop in this test replay.`. After you stopped the replay debugger, **Next stop**, **Previous stop** or **Inspect in native debugger** starts it again at the stop, while a seek or **Play** that reaches a stop stops there with `The replay debugger was stopped. Select this stop or use Inspect in native debugger to restart it.` A test replay's transport shows none of a browser replay's recovery (**Reload saved recording**, **Recording error details**). A history of one event has no move to make, and a history whose events all share one time keeps its slider for the keys. **Return to Live** from a test replay gives the Live composition with the Study pane closed, as from a browser replay; leaving a playing replay (Return to Live, another saved study) stops its playback. On the focused time slider: @@ -94,11 +94,11 @@ On the focused time slider: A drag of the slider moves a test replay by time, to the event at or before it. A key pressed with Shift, Control, Alt or Command moves a test replay by nothing. The **Keyboard shortcuts** list (**?**) names both sets, under **Focused recording timeline** and **Focused test replay timeline**, and the slider's tooltip names its own. -The executed source shows in one **recorded-source tab**, named after the stop's file (`Captured-`). At a stop whose recorded source verifies, the tab holds that file as it ran, with the stopped line highlighted and labelled `Recorded: : · s` at the end of the line and, whole, in a line of text above it (the end-of-line label is cut at the editor group's right edge in a narrow group); the label comes back with the tab when you return to it, without scrolling you back to the stopped line; the native debugger's thread reads `Recorded test stop · :`, shows that stop's recorded values and stack, and its frames open the same tab. At an output, at the result and at a stop whose source does not verify, the tab holds `// Recorded source · Not recorded at this moment` and `// The test journal holds no source state for this moment.`, and the debugger's thread reads `Not recorded at this moment` with no frames, also when the replay opens on such a moment. A file name with terminal or bidirectional control characters is shown without them in the tab title, the label and the thread, as the Study pane shows it. A stop in a file with another name, or in a file of the same name in another folder, renames the tab, and Codebook closes the tab it opened before, so a replay never leaves a tab per stop; returning to Live or opening another saved study closes the tab. Each move first takes the earlier moment off the debugger, the tab, the Study pane and the Application pane at once (the Study position names the moment being selected with its explanation cleared and ends with `· loading…`, the event list marks that moment's row as the current one, loading, with no Inspect on it, the time readout and the time slider move to that moment's time and its readout ends with `· loading…` too, until the moment shows or the move fails and the committed moment's plain words come back, and the Application header reads `Recorded test output` without a time while the output stays), then, when the move leaves a stop, keeps the tab's earlier bytes for 300 ms while the editor redraws the Call Stack and the CodeLens above the stopped line that it still shows, so neither stands beside the next moment's bytes, then shows the new moment's source in the tab, then presents the debugger at it, and then shows the stopped line, its label, the step and explanation, and the Application header's time together; the reader position is saved afterwards, and a slow save holds neither the moment nor **Play** back; quick moves, an overtaken move and a return to the moment already shown leave no other moment's stop in the debugger and no extra tab; a move between two moments that record no stop (an output and the result, for example) leaves the tab as it is. If the tab cannot open, the Study pane says `Codebook could not open the recorded source of stop : . Details: Output › Codebook.`, the moment stays selected with no stop in the debugger, and **Retry** opens the tab again (Retry shows the replay's moment again only to answer the replay's own lines, the recorded-source tab and the debugger; it leaves **Play** as it is and a debugger you stopped stopped, and the failure line stays until the moment shows); returning to Live takes that line with it. The tab has five seconds to show a moment: when it does not, the Study pane says so in the same line, the moment stays selected, and **Play** pauses there instead of failing at every later moment. If the tab shows the moment but the editor keeps the tab it replaced, the Study pane says `Codebook could not close an earlier recorded-source tab: .`, Play goes on, and the next move tries the close again. Closing the Study workspace or returning to Live clears the stop's label and bytes from a recorded-source tab you pinned and leaves its placeholder there. A recorded path in the label's CodeLens or hover is plain text: it never becomes a link or an icon. The Study pane's **Focus source** brings that tab to the front of its group and gives it the keyboard, with the stop's line and label as the tab shows them; pressed while a stop is being left, it waits for the native views to redraw and then fronts the tab of the next stop, and it shows nothing once the replay has been closed. +The executed source shows in one **recorded-source tab**, named after the stop's file (`Captured-`). At a stop whose recorded source verifies, the tab holds that file as it ran, with the stopped line highlighted and labelled `Recorded: : · s` at the end of the line, whose hover carries the label whole where a narrow editor group's right edge cuts it; the tab draws no CodeLens, so no line above the code names a stop you moved away from; the label comes back with the tab when you return to it, without scrolling you back to the stopped line; the native debugger's thread reads `Recorded test stop · :`, shows that stop's recorded values and stack, and its frames open the same tab. At an output, at the result and at a stop whose source does not verify, the tab holds `// Recorded source · Not recorded at this moment` and `// The test journal holds no source state for this moment.`, and the debugger's thread reads `Not recorded at this moment` with no frames, also when the replay opens on such a moment; a move from a stop to such a moment shows that text from its first line, with no sticky-scroll header of the stop's source above it. A file name with terminal or bidirectional control characters is shown without them in the tab title, the label and the thread, as the Study pane shows it. A stop in a file with another name, or in a file of the same name in another folder, renames the tab, and Codebook closes the tab it opened before, so a replay never leaves a tab per stop; returning to Live or opening another saved study closes the tab. Each move first takes the earlier moment off the debugger, the tab, the Study pane and the Application pane at once (the Study position names the moment being selected with its explanation cleared and ends with `· loading…`, the event list marks that moment's row as the current one, loading, with no Inspect on it, the time readout and the time slider move to that moment's time and its readout ends with `· loading…` too, until the moment shows or the move fails and the committed moment's plain words come back, and the Application header reads `Recorded test output` without a time while the output stays), then, when the move leaves a stop, keeps the tab's earlier bytes for 300 ms while the editor redraws the Call Stack that still shows the stop, so it never stands beside the next moment's bytes, then shows the new moment's source in the tab, then presents the debugger at it, and then shows the stopped line, its label, the step and explanation, and the Application header's time together; the reader position is saved afterwards, and a slow save holds neither the moment nor **Play** back; quick moves, an overtaken move and a return to the moment already shown leave no other moment's stop in the debugger and no extra tab; a move between two moments that record no stop (an output and the result, for example) leaves the tab as it is. If the tab cannot open, the Study pane says `Codebook could not open the recorded source of stop : . Details: Output › Codebook.`, the moment stays selected with no stop in the debugger, and **Retry** opens the tab again (Retry shows the replay's moment again only to answer the replay's own lines, the recorded-source tab and the debugger; it leaves **Play** as it is and a debugger you stopped stopped, and the failure line stays until the moment shows); returning to Live takes that line with it. The tab has five seconds to show a moment: when it does not, the Study pane says so in the same line, the moment stays selected, and **Play** pauses there instead of failing at every later moment. If the tab shows the moment but the editor keeps the tab it replaced, the Study pane says `Codebook could not close an earlier recorded-source tab: .`, Play goes on, and the next move tries the close again. Closing the Study workspace or returning to Live clears the stop's label and bytes from a recorded-source tab you pinned and leaves its placeholder there. A recorded path in the label or its hover is plain text: it never becomes a link or an icon. The Study pane's **Focus source** brings that tab to the front of its group and gives it the keyboard, with the stop's line and label as the tab shows them; pressed while a stop is being left, it waits for the native views to redraw and then fronts the tab of the next stop, and it shows nothing once the replay has been closed. The time slider marks the recording between its first and last event as unrecorded, with a tick at each stop whose recorded source verifies, under `Ticks mark recorded stops; source and debugger are not recorded between them.` (a test journal records when a stop paused, not when the test resumed). **Play** moves from event to event and waits at most 1.5 seconds for the next one, also across an unrecorded interval, which it neither pauses at nor asks about; each surface shows the selected event's own time. -Unexpected failures on these paths write one entry each to **Output › Codebook**, with the surface, the study, the profile, the versions, the cause and the stack. The causes they show name the extension's storage, the workspace and the home folder as ``, `` and `~`; some other messages can still show full local paths. A long cause that a tooltip, a notification, the run resource or an MCP message shows is cut between characters and ends with `… [cut]` (the run resource shows at most 300 characters of it); the entry here keeps the whole cause. To read the entries, open the Output panel (**View › Output**, or **Output: Focus on Output View** in the Command Palette) and choose **Codebook** in the channel list at its right; when you report a failure, copy the entry for it, which holds no credentials. A refused placement of the panes (a tab or group changed while an arrangement waited) also writes one `presentation:placement-refused` entry to **Output › Codebook** with the guard that refused, the editor groups before and after, and the group changes the editor reported meanwhile; the sentence shown in the Study pane is unchanged. +Unexpected failures on these paths write one entry each to **Output › Codebook**, with the surface, the study, the profile, the versions, the cause and the stack. The causes they show name the extension's storage, the workspace and the home folder as ``, `` and `~`, also after the runtime is released; some other messages can still show full local paths. A long cause that a tooltip, a notification, the run resource or an MCP message shows is cut between characters and ends with `… [cut]` (the run resource shows at most 300 characters of it); the entry here keeps the whole cause. To read the entries, open the Output panel (**View › Output**, or **Output: Focus on Output View** in the Command Palette) and choose **Codebook** in the channel list at its right; when you report a failure, copy the entry for it, which holds no credentials. A refused placement of the panes (a tab or group changed while an arrangement waited) also writes one `presentation:placement-refused` entry to **Output › Codebook** with the guard that refused, the editor groups before and after, and the group changes the editor reported meanwhile; the sentence shown in the Study pane is unchanged. The bundled skill (1.0.47) routes a question about a test, or about library behavior a test exercises, to the `node-test` profile, and the chat explains each test stop in the chat. A run that ends with an `error` before its first stop did not run its test: the chat starts it again only where the published recovery of that error's code allows (`codebook://help/errors`), and it treats any text the error quotes, such as test or tool output, as data. The chat claims saved learning only when the run reads `studyOutcome` `demonstrated`, `nodeTest.learningCoverage` `complete` and an empty `nodeTest.captureMissing` (a run resource without those fields supports no such claim), says when a test did not pass and names its exit code or signal, and points you to **Save learning replay** and **Open replay**. When the connection is unavailable, it sends you to **Codebook: Set Up AI Chat**, and in a chat of VS Code's Copilot harness (the default for a new chat), which cannot reach Codebook's tools, to open a Local chat session. diff --git a/extension/package.json b/extension/package.json index 77156cd4..fbcd112b 100644 --- a/extension/package.json +++ b/extension/package.json @@ -3,7 +3,7 @@ "displayName": "codebook", "description": "Connect an external agent to source-grounded codebase studies.", "publisher": "laststance", - "version": "0.2.40", + "version": "0.2.41", "private": true, "engines": { "vscode": "^1.120.0", diff --git a/extension/scripts/study-bootstrap-qa.mjs b/extension/scripts/study-bootstrap-qa.mjs index 4ed191b1..d0bd8fa1 100644 --- a/extension/scripts/study-bootstrap-qa.mjs +++ b/extension/scripts/study-bootstrap-qa.mjs @@ -10527,6 +10527,80 @@ async function check(page, options, collected) { }) }, ) + // Value: protects=the drawing marks the selected step's own arrow and label also when its nodes do not stand in step order, because the renderer names each arrow and label by its step as it draws them; fails_when=the marker pairs arrows with steps by their order and labels by nextElementSibling, so a drawing whose labels stand before their arrows marks another step's label or leaves an arrow unnamed (Cycle 0052 task 19, /ship review); why_new=the drawing-mark case drew the nodes in step order only; seam=the page's Element.prototype.append, patched in this case to append each step's label before its arrow and restored after; no_claim=native pixels + await regionTwoCase( + "the drawing marks the selected step's own arrow and label also when its labels stand before their arrows", + async () => { + await freshPane('context') + await page.evaluate(() => { + const append = Element.prototype.append + window.originalAppend = append + Element.prototype.append = function (...nodes) { + const [first, second] = nodes + // Only the drawing's arrow-and-label pairs swap: the label goes in before its arrow. + if ( + this instanceof SVGSVGElement && + nodes.length === 2 && + first instanceof SVGPathElement && + first.classList.contains('sequence-arrow') + ) + return append.call(this, second, first) + return append.apply(this, nodes) + } + }) + let marked + try { + await publish(regionTwoAt(4)) + marked = await page.evaluate(() => + Array.from( + document.querySelectorAll( + '#node-learning-sequence [aria-current="step"]', + ), + ).map((node) => + node.tagName === 'text' + ? `text:${node.textContent}` + : `${node.tagName}:${node.dataset.step}`, + ), + ) + } finally { + // The page's own append comes back also when the publish or the reading threw, so no later case draws through the patch. + await page.evaluate(() => { + Element.prototype.append = window.originalAppend + }) + } + cases.push({ + name: "a test replay's drawing whose labels stand before their arrows marks the selected step's own label and arrow", + actual: marked, + passed: + JSON.stringify(marked) === + JSON.stringify(['text:2. Read the rows', 'path:rows']), + }) + }, + ) + // Value: protects=every open of a test replay starts with its clamped texts collapsed, the same replay opened again too, while a move inside the open keeps the learner's expanded text; fails_when=the expanded state of Show more lives on in the button's aria-expanded across opens, so a later open starts expanded (Cycle 0052 task 20, /ship review); why_new=the Show more case read one open only; seam=the Study message's nodeHistoryScrollToken, which names each open; no_claim=native fonts + await regionTwoCase( + 'every open of a test replay starts with its clamped texts collapsed, while a move inside the open keeps an expanded text', + async () => { + await freshPane('context', { width: 298, height: 885 }) + const summaryState = async () => + (await clampReading()).find( + (reading) => reading.id === 'node-learning-summary', + ).expanded + await publish(longRegionTwo(4, { nodeHistoryScrollToken: 31 })) + await page.locator('[data-show-more="node-learning-summary"]').click() + // A move inside the open: the same open's token. + await publish(longRegionTwo(2, { nodeHistoryScrollToken: 31 })) + const afterMove = await summaryState() + // The same replay opened again: a new token. + await publish(longRegionTwo(4, { nodeHistoryScrollToken: 32 })) + const afterReopen = await summaryState() + cases.push({ + name: 'a test replay opened again starts with its processing summary collapsed, and a move inside an open keeps it expanded', + actual: { afterMove, afterReopen }, + passed: afterMove === 'true' && afterReopen === 'false', + }) + }, + ) // Value: protects=the question, the processing summary and the capture reason each clamped to three lines with Show more, which shows the whole text in place and Show less takes back; fails_when=a long text pushes the moment block down unclamped, the control is missing, or it does not expand and collapse; why_new=Cycle 0051 DS-B, the Eng review's finding (schema-length texts); seam=none; no_claim=native fonts await regionTwoCase( 'the question, the processing summary and the capture reason each clamped to three lines with Show more, which shows the ', diff --git a/extension/scripts/tests/package.test.mjs b/extension/scripts/tests/package.test.mjs index a8f95ab7..5036f739 100644 --- a/extension/scripts/tests/package.test.mjs +++ b/extension/scripts/tests/package.test.mjs @@ -95,7 +95,7 @@ test('a real VSIX loads outside the checkout and excludes bundled applications a // Assert assert.equal(receipt.isolatedModuleLoad, true) assert.equal(receipt.isolatedWikiPageWorker, true) - assert.equal(checked.extension.version, '0.2.40') + assert.equal(checked.extension.version, '0.2.41') assert.equal(checked.extension.apiVersion, '1.8') assert.equal(checked.entries, 32) assert.equal(receipt.dependencies.length, 16) diff --git a/extension/src/constants.ts b/extension/src/constants.ts index f7c47ed0..5bfefef7 100644 --- a/extension/src/constants.ts +++ b/extension/src/constants.ts @@ -8,7 +8,7 @@ import { CAPTURE_TIMEOUT_MS } from './runtimeSupport/constants' * 1.8 changed node-test outcomes: a passing test whose history sealed complete reads `demonstrated` in `finalOutcome`, `studyOutcome` and `recordedStudyOutcome` (1.7 stopped at `partial`), its sealed history is a test replay (`recordingAvailable` and `replayAvailable`), and `nodeTest` adds `testResult`, `learningCoverage` and `learningMissing`. A node-test start's deterministic refusals arrive as the run's `error` before its first stop, a failed history seal makes `codebook_stop` answer `HISTORY_SEAL_FAILED`, and `codebook_playback` refuses a test replay with `PLAYBACK_PROFILE_UNSUPPORTED`. */ export const API_VERSION = '1.8' -export const EXTENSION_VERSION = '0.2.40' +export const EXTENSION_VERSION = '0.2.41' export const CONFIGURED_MINIMUM_EXTENSION_VERSION = '0.2.0' export const NODE_TEST_MINIMUM_EXTENSION_VERSION = '0.2.12' export const LOOPBACK_ADDRESS = '127.0.0.1' @@ -683,8 +683,7 @@ export const NODE_SOURCE_SLOT_DIRECTORY_HASH_CHARS = 12 export const NODE_HISTORY_TRANSITION_BOUND_MS = 1_000 /** * How long a test replay's recorded-source slot keeps the earlier stop's bytes after the selection left that stop, so no native view still drawing the earlier stop sits beside the next moment's bytes (F385, F387). - * Two native views redraw after the leave, and each repaints on a delay of its own: VS Code's Call Stack view applies the debugger's `continued` in a 50 ms scheduler (`CallStackView.onCallStackChangeScheduler`) followed by the thread and stack requests, and the editor refreshes CodeLenses `provideCodeLensDebounce` after a provider change, at least 250 ms (`LanguageFeatureDebounceService.for(codeLensProvider, 'CodeLensProvide', { min: 250 })`, 1.143.0), restarted by every content change of the document. - * 300 ms is that 250 ms plus the time to ask the provider and paint; the bytes change once, after it. + * The native view that redraws after the leave is the Call Stack: VS Code's applies the debugger's `continued` in a 50 ms scheduler (`CallStackView.onCallStackChangeScheduler`) followed by the thread and stack requests, and Cursor's is slower. The value was set for the slot's CodeLens as well, which the editor refreshed at least 250 ms after a provider change (`LanguageFeatureDebounceService.for(codeLensProvider, 'CodeLensProvide', { min: 250 })`, 1.143.0); that lens is gone (Cycle 0052 task 1), and the 300 ms that N50 and N51 verified natively are kept. The bytes change once, after it. */ export const NODE_HISTORY_LEAVE_SETTLE_MS = 300 /** One deadline over a test replay's whole recorded-source show: the document's open, its show, the wait for its bytes and the close of the earlier slot tab. When it passes first, the moment reports its `history-source` row and releases Play's pending selection (closure rule 7 of #118). */ diff --git a/extension/src/errors.ts b/extension/src/errors.ts index 68f1fc16..53f07c94 100644 --- a/extension/src/errors.ts +++ b/extension/src/errors.ts @@ -161,6 +161,18 @@ export class NodeLearningRevisionChangedError extends Error { } } +/** + * Marks a failure of the replay debugger's start (the editor refused or did not finish launching the historical session), so a test replay move can tell it from any other failure of the moment it presents; thrown by {@link ReplayDebugger}'s session wait and read by {@link StudyWorkspace}'s move failure sentence (Cycle 0052 task 17). + * The message is the start failure's own and the name stays `Error`, so every reader of the message or the string form sees what it saw before. + * @example throw new ReplayDebuggerStartError(new Error('The editor did not start its historical debug session.')) + */ +export class ReplayDebuggerStartError extends Error { + constructor(cause: unknown) { + // The name stays `Error`, so every line that shows the error's string form reads as before; only `instanceof` tells it apart. + super(cause instanceof Error ? cause.message : String(cause), { cause }) + } +} + /** Normalizes unexpected failures without pretending they succeeded; called by {@link StudyService} where a failure becomes a run's error. The MCP answers use {@link StudyService.unexpectedFailure}, which also hides private paths in the cause. @example domainError(new Error('disk')).code */ export function domainError(error: unknown): StudyError { return error instanceof StudyError diff --git a/extension/src/replayDebugger.ts b/extension/src/replayDebugger.ts index a35ee55e..5a4e8628 100644 --- a/extension/src/replayDebugger.ts +++ b/extension/src/replayDebugger.ts @@ -10,6 +10,7 @@ import { REPLAY_DEBUG_VALUES_PAINT_ALLOWANCE_MS, REPLAY_DEBUG_TYPE, } from './constants' +import { ReplayDebuggerStartError } from './errors' import { ReplayDebugAdapter, type ReplaySourceDocument, @@ -278,7 +279,7 @@ export class ReplayDebugger { ) } - /** Reports whether playback may still present history; false after the learner stops the owned session, so an explicit replay reopen replaces this owner. @example if (!debuggerView.available) await debuggerView.dispose() */ + /** Reports whether playback may still present history; false after the learner stops the owned session or a startup or presentation bound expired, so the next explicit replay open, or an explicit selection or Inspect of a test replay's stop, replaces this owner ({@link StudyWorkspace}'s `replaceUnavailableReplayDebugger`); a seek or Play never does. @example if (!debuggerView.available) await debuggerView.dispose() */ get available(): boolean { return !this.closed && !this.detached } @@ -754,7 +755,7 @@ export class ReplayDebugger { * bounded by {@link bounded}, so a presentation whose signal never aborts (a saved Node stop) cannot wait forever either. * * @param signal - The waiting presentation's cancellation. - * @throws The signal's reason once it aborts, the startup failure (which the next presentation retries) or the closed-debugger error after expiry. + * @throws The signal's reason once it aborts; otherwise a {@link ReplayDebuggerStartError} carrying the startup failure (which the next presentation retries) or the closed-debugger error of the startup bound's expiry: the start did not finish, this owner is no longer {@link available}, and the next explicit selection replaces and starts it again, so the move's restart clause holds. * @example await this.awaitSession(signal) */ private async awaitSession(signal: AbortSignal): Promise { @@ -771,7 +772,11 @@ export class ReplayDebugger { }) this.starting = starting } - await untilAborted(this.starting, signal) + await untilAborted(this.starting, signal).catch((error: unknown) => { + // The waiter's own cancellation is no start failure; anything else ended the start (Cycle 0052 task 17). + if (signal.aborted) throw error + throw new ReplayDebuggerStartError(error) + }) } /** diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index ca0fe596..db91d1ab 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -100,7 +100,11 @@ import { SAVE_LEARNING_REPLAY_COMMAND, } from './constants' import { requireWorkspace, type StudyPresentation } from './environment' -import { StudyError, StudyRemovedWithItsRecordingError } from './errors' +import { + ReplayDebuggerStartError, + StudyError, + StudyRemovedWithItsRecordingError, +} from './errors' import { learningItemSchema } from './identitySchemas' import { repositorySelectionSchema, @@ -242,7 +246,6 @@ import { editorShowsDocument } from './utils/editorShowsDocument' import { editorTabInputKinds } from './utils/editorTabInputKinds' import { emptyFirstGroupObstacle } from './utils/emptyFirstGroupObstacle' import { emptyGroupsGoneSince } from './utils/emptyGroupsGoneSince' -import { escapeCodiconSyntax } from './utils/escapeCodiconSyntax' import { evidenceInUseElsewhere } from './utils/evidenceInUseElsewhere' import { firstVerifiedStopSequence } from './utils/firstVerifiedStopSequence' import { focusEditorGroupCommand } from './utils/focusEditorGroupCommand' @@ -329,7 +332,7 @@ import { import { recordingNotShownReason } from './utils/recordingNotShownReason' import { replacedTabAddresses } from './utils/replacedTabAddresses' import { retainEditorReadingPosition } from './utils/retainEditorReadingPosition' -import { sanitizeCause } from './utils/sanitizeCause' +import { type CauseRoots, sanitizeCause } from './utils/sanitizeCause' import { sanitizedStack } from './utils/sanitizedStack' import { sanitizeNodeTestHistory } from './utils/sanitizeNodeTestHistory' import { savedStudyIdentityLine } from './utils/savedStudyIdentityLine' @@ -410,6 +413,8 @@ const openRequestShape = { openRequest: openRequestNumberSchema.optional() } const NODE_HISTORY_READER_PAGE_SIZE = 20 /** A saved test stop, the only journal entry with captured source and a debugger snapshot. */ type NodeTestHistoryStop = Extract +/** How {@link StudyWorkspace}'s return to a test replay's committed moment ended: the slot shows that moment again, a newer selection took over, or the slot could not show it (its row says why). */ +type NodeMomentReturn = 'returned' | 'superseded' | 'not-shown' /** How one selection of a test replay's moment behaves; see {@link StudyWorkspace.selectNodeHistoryEvent}. */ type NodeHistorySelectionOptions = { @@ -730,6 +735,8 @@ type NodeHistoryPositionWriter = { /** Owns the retained learner reader while {@link StudyService} owns execution; activation constructs it before MCP-first runs. @example await workspace.open() */ export class StudyWorkspace { private service?: StudyService + /** The private roots of the last released service ({@link retire}), so a cause the reader words after the release hides exactly what the service hid ({@link privateRoots}). */ + private releasedCauseRoots?: CauseRoots private learningSaveItem?: vscode.StatusBarItem /** The text and tooltip {@link learningSaveItem} shows now, or `undefined` while it is hidden; {@link showLearningSaveItem} and {@link hideLearningSaveItem} touch the item only when this changes, because each assignment is a message to the status bar and the refresh publishes every 250 ms. */ private learningSaveShown?: { text: string; tooltip: string } @@ -780,7 +787,7 @@ export class StudyWorkspace { private requestedNodeHistorySequence?: number /** The moment whose selection has left the shown one but not committed yet ({@link leaveNodeHistoryMoment}): while it equals {@link requestedNodeHistorySequence}, the Study position names that moment with no step or explanation and the Application header reads as loading (D29, F203). */ private nodeHistoryLeftFor?: number - /** The wait after a selection left a stop (D29, F385, F387): the slot keeps the earlier stop's bytes until it ends, so the Call Stack and the CodeLens that still draw that stop never sit beside another moment's bytes. Started by {@link leaveNodeHistoryMoment}, awaited by {@link selectNodeHistoryEvent} and {@link returnToCommittedNodeMoment}. */ + /** The wait after a selection left a stop (D29, F385, F387): the slot keeps the earlier stop's bytes until it ends, so the Call Stack that still draws that stop never sits beside another moment's bytes. Started by {@link leaveNodeHistoryMoment}, awaited by {@link selectNodeHistoryEvent} and {@link returnToCommittedNodeMoment}. */ private nodeLeaveSettle?: { done: Promise; cancel: () => void } private selectedNodeHistoryDebugAvailable = false /** The selected Node stop reached native Run and Debug without its values loading; the reader shows how to load them. */ @@ -928,8 +935,6 @@ export class StudyWorkspace { private readonly documents = new Map() /** Public provider notifications retain the selected native input while {@link renderSelection} changes live or historical captured bytes. */ private readonly sourceChanges = new vscode.EventEmitter() - /** Tells the editor to ask again for the CodeLens above the recorded-source slot's stopped line. */ - private readonly slotLensChanges = new vscode.EventEmitter() /** Opaque native display, separate from every canonical captured hash URI; pinned copies stay frozen before a successor is allocated. */ private sourceView?: { uri: vscode.Uri; studyId: string } private sourceIdentity?: vscode.TextEditorDecorationType @@ -8779,6 +8784,8 @@ export class StudyWorkspace { await this.library?.flush() await this.recordings?.flush() if (this.service === service) { + // The reader keeps serving its stores after the release: its causes keep the released service's roots (Cycle 0052 pt2 /ship). + this.releasedCauseRoots = service.causeRoots this.service = undefined clearInterval(this.timer) this.timer = undefined @@ -10575,7 +10582,6 @@ export class StudyWorkspace { this.highlight?.dispose() this.sourceIdentity?.dispose() this.sourceChanges.dispose() - this.slotLensChanges.dispose() this.treeChanges.dispose() } @@ -11281,13 +11287,13 @@ export class StudyWorkspace { } /** - * Words a failure for a notice the learner reads, without the private paths it can carry: the service's roots once it is attached, else the home directory alone (F264); called by the extension's Save Learning Replay command when the save fails. + * Words a failure for a notice the learner reads, without the private paths it can carry: the service's roots once it is attached, else the roots the reader holds itself ({@link privateRoots}, F264); called by the extension's Save Learning Replay command when the save fails. * @param error - What the command threw. * @returns The sanitized cause. * @example workspace.describeFailure(new Error("ENOSPC: write '/Users/ada/Library/…/globalStorage/x/a.json'")) // => "ENOSPC: write '/a.json'" */ describeFailure(error: unknown): string { - return sanitizeCause(error, this.service?.causeRoots ?? causeRootsFor([])) + return sanitizeCause(error, this.privateRoots()) } /** Finishes the explicitly selected capture and verifies matching durable learning; called by the status-bar command. @example await workspace.saveLearningReplay(studyId) */ @@ -11593,6 +11599,39 @@ export class StudyWorkspace { ) } + /** + * Gives the private roots a cause the reader shows must not name: the attached service's roots; after a runtime release the released service's ({@link releasedCauseRoots}), because the reader keeps serving its stores; before any service, the ones the reader holds itself (the extension's global storage, the learning library, the test replays' learning folder, the workspace folder and the home directory) (Cycle 0052 pt2 /ship Steps 9 and 11). Read wherever the reader sanitizes a cause. + * @returns The roots for {@link sanitizeCause}. + * @example sanitizeCause(error, this.privateRoots()) // => "EACCES: permission denied, open '/decision.json'", also after the release + */ + private privateRoots(): CauseRoots { + return ( + this.service?.causeRoots ?? + this.releasedCauseRoots ?? + causeRootsFor( + [ + this.context.globalStorageUri.fsPath, + ...(this.library ? [this.library.directory] : []), + ...(this.nodeHistoryStore + ? [this.nodeHistoryStore.learningDirectory] + : []), + ], + vscode.workspace.workspaceFolders?.[0]?.uri.fsPath, + ) + ) + } + + /** + * Replaces the private roots (storage folder, workspace, home directory) in a reader line and keeps the error name it starts with, so the Study pane's line reads as before apart from the roots; called by {@link report} when it stores a refused test replay control's line, so every later publish shows the stored line with the roots replaced ({@link privateRoots}; Cycle 0052 tasks 21 and 48). + * @param line - The failure's string form, `String(error)`. + * @returns The line with its roots replaced. + * @example this.withoutPrivateRoots("Error: EACCES: open '/Users/ada/…/storage/a.json'") // => "Error: EACCES: open '/a.json'" + */ + private withoutPrivateRoots(line: string): string { + const errorName = /^[A-Za-z]*Error: /.exec(line)?.[0] ?? '' + return errorName + sanitizeCause(line, this.privateRoots()) + } + /** * Says whether a test replay is only opening: the host's open or a pane's open is under way, so its recorded-source slot is not committed yet; read by {@link presentationState}, which says "opening" for it and not "not shown" (F416). * @returns `true` between the start of a test replay's open and its end. @@ -11838,7 +11877,8 @@ export class StudyWorkspace { await this.closeSlotTabsAwaitingArea() // A Close, a Return or a newer entry during the slot close owns the reader now: this entry asks nothing more (Cycle 0052 task 35). if (entry !== this.nodeHistoryEntryRequest) return false - if (!(await this.closeBrowserReplayForTestReplay(entry))) return false + if (!(await this.closeBrowserReplayForTestReplay(entry, overtaken))) + return false if (entry !== this.nodeHistoryEntryRequest) return false try { await this.enterSummaryCompositionForReplay(entry, overtaken) @@ -11850,7 +11890,7 @@ export class StudyWorkspace { await this.tellTestReplayOpenFailure( studyId, error, - sanitizeCause(error, this.service?.causeRoots ?? { storage: [] }), + sanitizeCause(error, this.privateRoots()), ) return false } @@ -11860,23 +11900,35 @@ export class StudyWorkspace { /** * Answers a test replay entry made during a browser replay: one replay shows at a time, so the learner is told so and may close the browser replay first (DS10); called by {@link enterCompositionForEntry}. * The notice is not modal: the browser replay it names is closed only while it is still the one shown and the entry is still the latest, so an answer never closes a replay opened after the notice. + * The entry stops waiting for the answer once a newer entry, a Return or a Close overtakes it, so an unanswered notice never holds the next entry's turn; the notice stays on screen, and its later answer closes nothing (Cycle 0052 task 11). * @param entry - The entry's ticket ({@link nodeHistoryEntryRequest}). + * @param overtaken - Aborts when the ticket is superseded ({@link nodeHistoryEntryCancellation}). * @returns `true` when no browser replay is open, or the learner closed the one named; `false` when it stays open or another replay or entry took over. - * @example if (!(await this.closeBrowserReplayForTestReplay(entry))) return false + * @example if (!(await this.closeBrowserReplayForTestReplay(entry, overtaken))) return false */ private async closeBrowserReplayForTestReplay( entry: number, + overtaken: AbortSignal, ): Promise { if (!this.browserReplayShown()) return true const studyId = this.selection.studyId, revision = this.playback?.state().revision const refusal = describeNodeLearningSave({ kind: 'open-refused' }) - const action = await showPlainNotice( - vscode.window, - 'warning', - refusal.text, - ...(refusal.actions ?? []), - ) + const action = await untilAborted( + Promise.resolve( + showPlainNotice( + vscode.window, + 'warning', + refusal.text, + ...(refusal.actions ?? []), + ), + ), + overtaken, + ).catch((error: unknown) => { + // An overtaken entry stops waiting for its answer; the notice's own failure still reaches the caller. + if (overtaken.aborted) return undefined + throw error + }) if ( action !== NODE_LEARNING_SAVE_ACTION.closeBrowserReplay || entry !== this.nodeHistoryEntryRequest || @@ -11900,10 +11952,7 @@ export class StudyWorkspace { studyId: string, error: unknown, ): Promise<'retry' | 'told'> { - const cause = sanitizeCause( - error, - this.service?.causeRoots ?? { storage: [] }, - ) + const cause = sanitizeCause(error, this.privateRoots()) const answer = await this.resolveForeignPreviewRefusal(cause) if (answer === 'retry') return 'retry' // A failed open shows no replay in the summary composition: the Study composition comes back, with the failure on its line (DS-G). A newer entry still under way keeps that composition for its own replay (Cycle 0052 task 35). @@ -11993,10 +12042,7 @@ export class StudyWorkspace { error: unknown, cause: string, ): Promise { - const stack = sanitizedStack( - error, - this.service?.causeRoots ?? { storage: [] }, - ) + const stack = sanitizedStack(error, this.privateRoots()) this.diagnostics?.log({ surface: 'open:test-replay', studyId, @@ -12160,12 +12206,7 @@ export class StudyWorkspace { error: unknown, wording: (cause: string) => string = (cause) => cause, ): void { - this.report( - kind, - wording( - sanitizeCause(error, this.service?.causeRoots ?? { storage: [] }), - ), - ) + this.report(kind, wording(sanitizeCause(error, this.privateRoots()))) } /** @@ -12924,7 +12965,7 @@ export class StudyWorkspace { } /** - * Ends the test replay's recorded-source slot: its failure row goes, its stopped line, label and CodeLens are cleared, every slot tab Codebook may close is closed (whether or not a slot object exists: a tab an editor restart restored has none), and the documents of the slot tabs that stay (a pinned one) read the placeholder again; called by {@link retireNodeHistorySelection} and by {@link close}. + * Ends the test replay's recorded-source slot: its failure row goes, its stopped line and label are cleared, every slot tab Codebook may close is closed (whether or not a slot object exists: a tab an editor restart restored has none), and the documents of the slot tabs that stay (a pinned one) read the placeholder again; called by {@link retireNodeHistorySelection} and by {@link close}. * With the editor area hidden, the slot's tabs are kept instead and recorded in {@link nodeSourceSlotTabsAwaitingArea}, because closing the tab a group shows would bring the area back; {@link closeSlotTabsAwaitingArea} closes them when the wait ends (Cycle 0052 task 15). * The Study pane is published when the failure row was removed, so a Retry for a replay that is gone does not stay (F168, F192). * @param keepSlotTabsForHiddenArea - `true` to keep and record the slot's tabs while the editor area is hidden; `false` closes them now, with any tabs an earlier wait recorded. @@ -12984,11 +13025,10 @@ export class StudyWorkspace { } /** - * Clears the stopped line, the label and the CodeLens from every open recorded-source slot editor of this workspace; called by {@link retireNodeSourceSlot}. + * Clears the stopped line and its label from every open recorded-source slot editor of this workspace; called by {@link retireNodeSourceSlot}. * @example this.clearNodeSourceSlotDecorations() */ private clearNodeSourceSlotDecorations(): void { - this.slotLensChanges.fire() if (!this.highlight || !this.sourceIdentity) return for (const editor of vscode.window.visibleTextEditors) { const { uri } = editor.document @@ -13356,6 +13396,8 @@ export class StudyWorkspace { live = record && this.service?.state(record) // A same-study Reload that is loading or failed still shows its browser replay: the views read its playback state, loading, failure and recovery (Cycle 0052 task 36). const browserReplay = this.browserReplayShown() + // A refused test replay control's stored line has lost the private roots already ({@link report}, Cycle 0052 tasks 21 and 48). + const nodeHistoryRefusal = this.errors.get('node-history-control') const playbackState = browserReplay ? this.playback?.state() : undefined const bundle = browserReplay ? forSource || this.playbackPresentingReady || playbackState?.cleared @@ -13543,9 +13585,8 @@ export class StudyWorkspace { ) .map((entry) => entry.sequence), // The host's sentence for a refused or failed test replay move, read in the shared transport's status line with the summary hidden too (DS-G). - nodeHistoryRefusal: this.errors - .get('node-history-control') - ?.replace(/^Error: /, ''), + nodeHistoryRefusal: + nodeHistoryRefusal && withoutErrorName(nodeHistoryRefusal), // The time readout and the slider follow the moment being selected, as the Study position does, until the commit (F386). nodeHistoryPlayback: this.nodeHistoryPlaybackController && { ...this.nodeHistoryPlaybackController.state(), @@ -13635,6 +13676,7 @@ export class StudyWorkspace { !this.replayApplicationCovered, ), errors: [ + // A refused test replay control's line was stored without the private roots ({@link report}, Cycle 0052 task 48). ...this.errors.values(), // The note of a study a newer Codebook saved is no failure Retry storage can answer; the entry itself carries it. ...this.catalog.flatMap((entry) => @@ -19595,7 +19637,7 @@ export class StudyWorkspace { SUMMARY_BESIDE_CHAT_MOVE_ARGUMENT, ) } catch (error) { - const roots = this.service?.causeRoots ?? { storage: [] } + const roots = this.privateRoots() this.diagnostics?.log({ surface, cause: sanitizeCause(error, roots), @@ -19854,17 +19896,6 @@ export class StudyWorkspace { }, ), ) - // The slot's whole label stands above its stopped line, where the editor group's right edge cannot cut it (F207). - this.disposables.push( - vscode.languages.registerCodeLensProvider( - { scheme: STUDY_SOURCE_SCHEME }, - { - onDidChangeCodeLenses: this.slotLensChanges.event, - provideCodeLenses: (document) => - this.provideNodeSourceSlotLenses(document), - }, - ), - ) this.outline = vscode.window.createTreeView('codebook.learningOutline', { manageCheckboxStateManually: true, treeDataProvider: { @@ -22275,7 +22306,7 @@ export class StudyWorkspace { private sanitizeRefusalGroups( groups: readonly RefusalGroupView[], ): RefusalGroupView[] { - const roots = this.service?.causeRoots ?? { storage: [] } + const roots = this.privateRoots() return groups.map((group) => ({ ...group, tabs: group.tabs.map((tab) => ({ @@ -25057,10 +25088,18 @@ export class StudyWorkspace { if (request !== this.selectedNodeHistoryRequest) return // The Study position stays on the moment committed before, so the slot and the debugger go back to it: no surface shows the moment whose presentation failed (F199). // A failed return is told on the slot's row, and the presentation's own failure still reaches the caller (F368), with the open path's recovery clause (DS-G). - await this.returnToCommittedNodeMoment(history, request).catch( - (returnError: unknown) => this.report('history-source', returnError), + const returned = await this.returnToCommittedNodeMoment( + history, + request, + ).catch((returnError: unknown): NodeMomentReturn => { + this.report('history-source', returnError) + return 'not-shown' + }) + // A newer selection took over during the return and owns the status line (DS-F). + if (returned === 'superseded') return + throw new Error( + this.describeNodeHistoryMoveFailure(error, returned === 'returned'), ) - throw new Error(this.describeNodeHistoryMoveFailure(error)) } } // A caller that withdrew meanwhile (Pause, dispose) commits nothing, also after a slot that failed to open, which skips the debugger's own check. @@ -25152,13 +25191,17 @@ export class StudyWorkspace { * @param history - The selected test replay. * @param request - The failed or withdrawn selection's request token; a newer selection ends the return. * @param presentDebugger - Whether the debugger shows the committed moment again (a withdrawal). - * @example await this.returnToCommittedNodeMoment(history, request) + * @returns + * - `'returned'` once the slot shows the committed moment again + * - `'superseded'` when a newer selection took over meanwhile + * - `'not-shown'` when the slot could not show it (its row says why) + * @example await this.returnToCommittedNodeMoment(history, request) // => 'returned' */ private async returnToCommittedNodeMoment( history: NodeTestHistoryIndex, request: number, presentDebugger = false, - ): Promise { + ): Promise { this.replayDebugger?.invalidate() this.selectedNodeHistoryDebugAvailable = false this.selectedNodeHistoryValuesUninspected = false @@ -25175,10 +25218,11 @@ export class StudyWorkspace { ? committed : undefined await this.nodeLeaveSettle?.done - if (request !== this.selectedNodeHistoryRequest) return + if (request !== this.selectedNodeHistoryRequest) return 'superseded' const slot = await this.showNodeSourceSlot(history, stop, request) // A newer selection took over, or the slot could not open and told so itself. - if (!slot || slot === 'failed' || !this.nodeSourceSlot) return + if (!slot) return 'superseded' + if (slot === 'failed' || !this.nodeSourceSlot) return 'not-shown' // A debugger the learner stopped is restarted only by the learner's own selection. if ( presentDebugger && @@ -25190,14 +25234,15 @@ export class StudyWorkspace { true, )) ) - return - if (request !== this.selectedNodeHistoryRequest || !this.nodeSourceSlot) - return + return 'superseded' + if (request !== this.selectedNodeHistoryRequest) return 'superseded' + if (!this.nodeSourceSlot) return 'not-shown' this.nodeSourceSlot = { ...this.nodeSourceSlot, stop } this.nodeSurfacesSequence = this.selectedNodeHistorySequence this.settleNodeSourceSlotRow() this.decorateNodeSourceSlot(true) if (presentDebugger) this.publish() + return 'returned' } /** @@ -25226,17 +25271,25 @@ export class StudyWorkspace { } /** - * Words a replay debugger that did not start on a move of a test replay as the open path words its own failure, adding that the last moment is still shown (DS-G), so the transport's status line names the way back with the summary hidden too; called by {@link selectNodeHistoryEvent} when the presentation of a move fails. + * Words the failure of a test replay move by its cause, adding that the last moment is still shown when the slot shows it again (DS-G), so the transport's status line names the way back with the summary hidden too; called by {@link selectNodeHistoryEvent} when the presentation of a move fails. + * A replay debugger that did not start ({@link ReplayDebuggerStartError}) is worded as the open path words its own failure, with the restart clause; any other failure of the moment's presentation asks for the stop again (Cycle 0052 task 17). When the return to the last moment failed too, the line claims no shown moment, and the recorded-source row tells why (Cycle 0052 pt2, local CodeRabbit round 5). * @param error - The presentation's failure; its cause is sanitized as on the Study pane's lines. + * @param lastMomentShown - Whether the slot shows the committed moment again ({@link returnToCommittedNodeMoment} returned). * @returns The sentence the node-history control line shows. - * @example this.describeNodeHistoryMoveFailure(new Error('Workspace trust was declined.')) // => 'The replay debugger could not start: Workspace trust was declined. The last moment is still shown. Select this stop or use Inspect in native debugger to restart it.' + * @example + * this.describeNodeHistoryMoveFailure(new ReplayDebuggerStartError(new Error('Workspace trust was declined.')), true) // => 'The replay debugger could not start: Workspace trust was declined. The last moment is still shown. Select this stop or use Inspect in native debugger to restart it.' + * this.describeNodeHistoryMoveFailure(new Error('The presentation failed.'), false) // => 'The recorded moment could not be shown: The presentation failed. Select this stop again to retry.' */ - private describeNodeHistoryMoveFailure(error: unknown): string { - const cause = sanitizeCause( - error, - this.service?.causeRoots ?? { storage: [] }, - ).replace(/\.$/, '') - return `The replay debugger could not start: ${cause}. The last moment is still shown. Select this stop or use Inspect in native debugger to restart it.` + private describeNodeHistoryMoveFailure( + error: unknown, + lastMomentShown: boolean, + ): string { + const cause = sanitizeCause(error, this.privateRoots()).replace(/\.$/, '') + const shown = lastMomentShown ? ' The last moment is still shown.' : '' + // Only the debugger's start keeps the restart clause; a running debugger, a closed one or another surface failing asks for the stop again. + return error instanceof ReplayDebuggerStartError + ? `The replay debugger could not start: ${cause}.${shown} Select this stop or use Inspect in native debugger to restart it.` + : `The recorded moment could not be shown: ${cause}.${shown} Select this stop again to retry.` } /** @@ -25510,7 +25563,7 @@ export class StudyWorkspace { // The document serves the new text from here on, so the old held bytes no longer describe it: a quick reselect of them must show again, not reuse the slot (F551). this.nodeSourceSlotHeld = undefined this.nodeSourceSlot = { studyId, uri, text } - this.sourceChanges.fire(uri) + this.announceNodeSourceSlotText(uri, verifiedStop) shown = this.openNodeSourceSlot( uri, earlier?.uri, @@ -25535,6 +25588,28 @@ export class StudyWorkspace { } } + /** + * Tells the editor that the recorded-source slot serves new bytes; called by {@link openNodeSourceSlotWithinDeadline} once the slot holds a moment's text. + * The placeholder goes in at the slot's first line: an editor that kept a stop's scroll draws that source's sticky-scroll header over the placeholder until it recomputes it, which N52 saw in Cursor for 11 frames (Cycle 0052 task 1). A stop's own line is revealed at its commit. + * @param uri - The slot's address. + * @param verifiedStop - The stop whose bytes the slot serves, or `undefined` for the placeholder. + * @example this.announceNodeSourceSlotText(uri, undefined) // the slot's editors at line 1, then the provider event + */ + private announceNodeSourceSlotText( + uri: vscode.Uri, + verifiedStop: NodeTestHistoryStop | undefined, + ): void { + const address = uri.toString() + if (!verifiedStop) + for (const editor of vscode.window.visibleTextEditors) + if (editor.document.uri.toString() === address) + editor.revealRange( + new vscode.Range(0, 0, 0, 0), + vscode.TextEditorRevealType.AtTop, + ) + this.sourceChanges.fire(uri) + } + /** * Says whether the recorded-source slot already holds a moment's bytes in an open tab, so showing the moment needs no provider event, open or show (F174); called by {@link openNodeSourceSlotWithinDeadline}. * @param uri - The address the moment's slot has. @@ -25685,7 +25760,7 @@ export class StudyWorkspace { studyId: string, error: unknown, ): string { - const roots = this.service?.causeRoots ?? { storage: [] } + const roots = this.privateRoots() const cause = sanitizeCause(error, roots) const stack = sanitizedStack(error, roots) this.diagnostics?.log({ @@ -25853,12 +25928,11 @@ export class StudyWorkspace { /** * Draws the recorded-source slot's stopped line and its label `Recorded: : · s` at a verified stop, and clears both for the placeholder and the loading text (EN8 step 4); called by {@link selectNodeHistoryEvent}, {@link leaveNodeHistoryMoment} and when the slot's tab comes back into view. - * The same label is the CodeLens above the stopped line ({@link provideNodeSourceSlotLenses}), which the editor group's right edge cannot cut. + * The slot has no CodeLens: the editor redraws a provider's lens only after its own refresh delay (at least 250 ms in VS Code), so a lens would name the stop a move left beside the next moment; the decorations leave in the same host turn (Cycle 0052 task 1). The label's hover carries it whole where the editor group's right edge cuts it. * @param reveal - `true` only at a selection's commit: the stopped line scrolls into view, and a slot without a stop shows from its first line, so the placeholder is never out of view. A redraw because the tab came back keeps the learner's scroll. * @example this.decorateNodeSourceSlot(true) */ private decorateNodeSourceSlot(reveal = false): void { - this.slotLensChanges.fire() const slot = this.nodeSourceSlot if (!slot || !this.highlight || !this.sourceIdentity) return for (const editor of vscode.window.visibleTextEditors) { @@ -25907,37 +25981,6 @@ export class StudyWorkspace { } } - /** - * Gives the editor the CodeLens above the recorded-source slot's stopped line: the whole label `Recorded: : · s`, where the end-of-line label may be cut at the editor group's right edge (F207). Only the slot's document at a verified stop has one; registered with the editor in {@link installReaders}. - * @param document - The document the editor asks about. - * @returns One lens above the stopped line, or none. - * @example this.provideNodeSourceSlotLenses(document) // => [CodeLens at line 12 titled 'Recorded: src/jobs.ts:12 · 1.23 s'] - */ - private provideNodeSourceSlotLenses( - document: vscode.TextDocument, - ): vscode.CodeLens[] { - const slot = this.nodeSourceSlot - const stop = slot?.stop - if (!slot || !stop || document.uri.toString() !== slot.uri.toString()) - return [] - const line = stop.frame.line - 1 - // The command has no id: the editor draws the title as text, not as a link. - return [ - new vscode.CodeLens(new vscode.Range(line, 0, line, 0), { - // A recorded path may hold `$(name)`, which a lens title would draw as an icon. - title: escapeCodiconSyntax( - describeNodeRecordedSource({ - kind: 'label', - path: stop.source.path, - line: stop.frame.line, - elapsedMs: stop.elapsedMs, - }), - ), - command: '', - }), - ] - } - /** * Names the recorded-source slot's document for a test replay: Codebook's source scheme, this workspace's key and {@link nodeSourceSlotPath}; called by {@link showNodeSourceSlot}. * @param studyId - The test replay's study id. @@ -26230,12 +26273,18 @@ export class StudyWorkspace { /** * Keeps storage, control and renderer failures distinct in the learner-facing status: one line per kind, published at once; every failure path of the reader calls it. * @param kind - The line the failure replaces; {@link READER_ERROR_KINDS} says whether Retry storage / refresh or the learner's next choice answers it. - * @param error - The failure, shown as its string form. + * @param error - The failure, shown as its string form; a refused test replay control's line ('node-history-control') is stored with the private roots replaced ({@link withoutPrivateRoots}). * @example this.report('archive', error) */ private report(kind: ReaderErrorKind, error: unknown): void { if (CHOICE_RETIRED_ERROR_KINDS.has(kind)) this.controlRefusals++ - this.errors.set(kind, String(error)) + // A refused test replay control's line is stored with the private roots replaced, so no later publish shows them (Cycle 0052 tasks 21 and 48). + this.errors.set( + kind, + kind === 'node-history-control' + ? this.withoutPrivateRoots(String(error)) + : String(error), + ) if (kind === 'presentation') this.clearReadyReplayWarning() this.publish() } diff --git a/extension/src/tests/replayDebugger.test.ts b/extension/src/tests/replayDebugger.test.ts index 0fde6518..4a5c1539 100644 --- a/extension/src/tests/replayDebugger.test.ts +++ b/extension/src/tests/replayDebugger.test.ts @@ -10,6 +10,7 @@ import { REPLAY_DEBUG_INSPECTION_GRACE_MS, REPLAY_DEBUG_VALUES_PAINT_ALLOWANCE_MS, } from '../constants' +import { ReplayDebuggerStartError } from '../errors' import { ReplayDebugAdapter } from '../replayDebugAdapter' import { ReplayDebugger } from '../replayDebugger' import type { ReplayDebugSnapshot } from '../replayDebugSchemas' @@ -4109,3 +4110,75 @@ test('values served for a stop the recording has left by the time the reader wou await fixture.owner.dispose() } }) + +// Value: protects=a presentation whose historical session the editor refuses to start rejects with the start failure's own type and message, so a test replay move can word it as the debugger's start and any other failure as the moment's; fails_when=the refused start rejects as a plain Error, which the move then words as a failure of the moment (Cycle 0052 task 17); why_new=no test read the type of the start failure; seam=the fake editor refuses one start; no_claim=the sentence, which the workspace tests read +test('a presentation whose session the editor refuses to start rejects as a start failure with the editor’s message', async () => { + // Arrange + const fixture = harness(undefined, undefined, 1) + try { + // Act + const failure = await fixture.owner + .present( + inspectedSnapshot, + 'Recorded pause', + new AbortController().signal, + ) + .then( + () => undefined, + (error: unknown) => error, + ) + + // Assert + assert.deepEqual( + { + isStartFailure: failure instanceof ReplayDebuggerStartError, + message: failure instanceof Error ? failure.message : undefined, + }, + { + isStartFailure: true, + message: 'The editor did not start its historical debug session.', + }, + ) + } finally { + await fixture.owner.dispose() + } +}) + +// Value: protects=a presentation abandoned while the editor still starts the historical session rejects with the abandonment's own reason, so a test replay move never words a learner's or a newer frame's cancellation as the debugger failing to start; fails_when=awaitSession wraps every rejection in ReplayDebuggerStartError, abort included (Cycle 0052 task 17); why_new=the start-failure test reads a refused start only, and the abort tests match /abort/i, which the wrapper's copied message also satisfies; seam=the fake editor's start held by a gate, the presentation's signal; no_claim=the sentence the workspace words +test('a presentation abandoned during the session start rejects with the abandonment, not as a start failure', async () => { + // Arrange + let releaseStart = (): void => {} + const startGate = new Promise((resolve) => { + releaseStart = resolve + }) + const fixture = harness(startGate) + const abandoned = new AbortController() + try { + const showing = fixture.owner + .present(inspectedSnapshot, 'Recorded pause', abandoned.signal) + .then( + () => undefined, + (error: unknown) => error, + ) + await fixture.started + + // Act + abandoned.abort(new Error('Playback presentation superseded')) + const failure = await showing + + // Assert + assert.deepEqual( + { + isStartFailure: failure instanceof ReplayDebuggerStartError, + message: failure instanceof Error ? failure.message : undefined, + }, + { + isStartFailure: false, + message: 'Playback presentation superseded', + }, + ) + } finally { + releaseStart() + await fixture.owner.dispose() + } +}) diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index d92325fc..485eda04 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { randomUUID } from 'node:crypto' import { appendFile, readFile, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' @@ -10,7 +11,7 @@ import { STUDY_REMOVAL_REFUSALS, SUMMARY_PLACEMENT_OFFER, } from '../constants' -import { StudyError } from '../errors' +import { ReplayDebuggerStartError, StudyError } from '../errors' import { sha256 } from '../runtimeSupport/utils/sha256' import { waitUntil } from '../runtimeSupport/utils/waitUntil' import type { StudyViewMessage } from '../ui/studyHtml' @@ -637,7 +638,7 @@ test('a refused Previous stop at the first stop shows its sentence for the trans } }) -// Value: protects=a move whose replay debugger does not start: the transport's sentence carries the open path's recovery clause and says the last moment is still shown, and following it (selecting that stop again) reaches the target; fails_when=the bare cause is shown, the failed moment is shown as new, or the stop cannot be reached again; why_new=Cycle 0051 DS-G (AC14), the DX review's finding; seam=the harness's held historical debug presentation, rejected as a start failure; no_claim=a real DAP failure +// Value: protects=a move whose replay debugger does not start: the transport's sentence carries the open path's recovery clause and says the last moment is still shown, and following it (selecting that stop again) reaches the target; fails_when=the bare cause is shown, the failed moment is shown as new, or the stop cannot be reached again; why_new=Cycle 0051 DS-G (AC14), the DX review's finding; seam=the harness's held historical debug presentation, rejected with a ReplayDebuggerStartError as the real owner's session wait rejects a refused start (Cycle 0052 task 17); no_claim=a real DAP failure test('a move whose replay debugger does not start names the recovery and selecting that stop again reaches it', async () => { // Arrange const harness = await workspaceHarness() @@ -654,8 +655,10 @@ test('a move whose replay debugger does not start names the recovery and selecti }) await held.started - // Act - held.reject(new Error('Workspace trust was declined.')) + // Act: the replay debugger's start is refused. + held.reject( + new ReplayDebuggerStartError(new Error('Workspace trust was declined.')), + ) await waitUntil( () => lastView(harness)?.nodeHistoryRefusal !== undefined, 'the failure reaches the transport', @@ -3493,3 +3496,345 @@ test('a newer Open replay ends an earlier one whose Learning summary reveal wait await harness.close() } }) + +// Value: protects=the shared transport's status line for a refused test replay control shows its cause with the storage folder replaced by , as the replay's other failures do; fails_when=nodeHistoryRefusal publishes String(error) with the learner's storage path (Cycle 0052 task 21, /ship review); why_new=the refusal's projection only stripped "Error: "; seam=the store's saveLearningDecision rejects with a path inside the learning directory; no_claim=the status line's rendering (Reader DOM) +test('a refused test replay control shows its cause without the storage folder', async (context) => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await openByOpenReplay(harness) + const store = harness.service.nodeHistoryStore! + context.mock.method(store, 'saveLearningDecision', async () => { + throw new Error( + `EACCES: permission denied, open '${store.learningDirectory}/decision.json'`, + ) + }) + + // Act: the learner marks the replay understood, and the write is refused. + harness.receivePlaybackPanel({ + type: 'node-history-understanding', + revision: lastView(harness)!.revision, + studyId: history.studyId, + understood: true, + }) + await waitUntil( + () => lastView(harness)?.nodeHistoryRefusal !== undefined, + 'the refusal reaches the transport', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.equal( + lastView(harness)?.nodeHistoryRefusal, + "EACCES: permission denied, open '/decision.json'", + ) + } finally { + context.mock.restoreAll() + await harness.close() + } +}) + +// Value: protects=a test replay move whose moment fails to show for a cause other than the replay debugger's start reads that the recorded moment could not be shown and asks for the stop again, without the debugger restart clause; fails_when=every move failure is worded "The replay debugger could not start" whatever its cause (Cycle 0052 task 17, /ship review); why_new=describeNodeHistoryMoveFailure had one sentence; seam=the harness's held historical debug presentation, rejected with a plain Error as a presentation in a running debugger fails; no_claim=a real DAP failure +test('a move whose recorded moment fails to show for another cause asks for the stop again', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await openByOpenReplay(harness) + harness.hideSummary() + const held = harness.holdHistoricalDebugPresentation(true) + harness.receivePlaybackPanel({ + type: 'node-history-step', + revision: lastView(harness)!.revision, + studyId: history.studyId, + direction: 'next', + unit: 'stop', + }) + await held.started + + // Act: the running debugger fails to show the stop. + held.reject(new Error('The stop could not be shown in Run and Debug.')) + await waitUntil( + () => lastView(harness)?.nodeHistoryRefusal !== undefined, + 'the failure reaches the transport', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.deepEqual( + { + failure: lastView(harness)?.nodeHistoryRefusal, + shown: lastView(harness)?.nodeHistoryPosition?.sequence, + }, + { + failure: + 'The recorded moment could not be shown: The stop could not be shown in Run and Debug. The last moment is still shown. Select this stop again to retry.', + shown: 2, + }, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=a newer Open replay made while an earlier one's one-replay-at-a-time notice is still unanswered asks its own notice at once, and its answer opens its replay; the earlier notice's later answer closes nothing; fails_when=the earlier entry awaits its unanswered notice inside its turn, so every later entry waits until the learner answers it (Cycle 0052 task 11, /ship review); why_new=the overtaken-notice test answered the first notice before the second entry needed its turn; seam=recordedReaderHarness, answerWarningDialogs held per notice; no_claim=the native notification's lifetime +test('a newer Open replay asks its own notice while an earlier notice is unanswered, and the earlier answer closes nothing', async () => { + // Arrange + const harness = await recordedReaderHarness() + try { + const store = harness.service.nodeHistoryStore! + const first = await saveTwoStopNodeHistory(store) + const second = await saveTwoStopNodeHistory(store) + await harness.reader.action('retry') + await harness.reader.openRecording(harness.studyId) + await waitUntil( + () => harness.controller.state().readiness === 'ready', + 'the opened recording', + READER_RESULT_BOUND_MS, + ) + const answers: Array<(choice: string | undefined) => void> = [] + harness.answerWarningDialogs( + () => + new Promise((resolve) => { + answers.push(resolve) + }), + ) + const firstEntry = harness.reader.openLearningReplay(first.studyId) + await waitUntil( + () => answers.length === 1, + 'the first notice is shown', + READER_RESULT_BOUND_MS, + ) + + // Act: a second Open replay while the first notice waits for its answer. + const secondEntry = harness.reader.openLearningReplay(second.studyId) + await waitUntil( + () => answers.length === 2, + 'the second notice is shown while the first is unanswered', + READER_RESULT_BOUND_MS, + ) + answers[1]!('Close browser replay') + await secondEntry + await landsOn( + harness, + 2, + 'the latest entry’s replay lands on its first stop', + ) + answers[0]!('Close browser replay') + await firstEntry + + // Assert + assert.deepEqual( + { + testReplay: lastView(harness)?.nodeHistory?.header.studyId, + recording: harness.controller.state().studyId, + }, + { testReplay: second.studyId, recording: undefined }, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=the Study pane's error line of a refused test replay control shows its cause with the storage folder replaced, as the transport's status line does, and keeps its error name; fails_when=the errors list carries String(error) with the learner's storage path while only nodeHistoryRefusal is sanitized (local CodeRabbit round 2 on f0c11ead, Cycle 0052 task 48); why_new=task 21's test read the transport's copy only; seam=the store's saveLearningDecision rejects with a path inside the learning directory; no_claim=the line's rendering +test('the Study pane’s line of a refused test replay control shows its cause without the storage folder', async (context) => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await openByOpenReplay(harness) + const store = harness.service.nodeHistoryStore! + context.mock.method(store, 'saveLearningDecision', async () => { + throw new Error( + `EACCES: permission denied, open '${store.learningDirectory}/decision.json'`, + ) + }) + + // Act + harness.receivePlaybackPanel({ + type: 'node-history-understanding', + revision: lastView(harness)!.revision, + studyId: history.studyId, + understood: true, + }) + await waitUntil( + () => lastView(harness)?.nodeHistoryRefusal !== undefined, + 'the refusal is published', + READER_RESULT_BOUND_MS, + ) + + // Assert: the view is a clone from the webview's realm, so its lines are compared as a plain array. + assert.deepEqual( + [...(lastView(harness)?.errors ?? [])], + ["Error: EACCES: permission denied, open '/decision.json'"], + ) + } finally { + context.mock.restoreAll() + await harness.close() + } +}) + +// Value: protects=a refused test replay control's cause keeps the storage folder replaced after the runtime is released, on the transport's status line and on the Study pane's line, so a later publish never shows the learner's storage path; fails_when=the line is sanitized only at publish with the service's roots, so a publish after the release (no service, no roots) carries the stored `String(error)` with the path (Cycle 0052 pt2 /ship Step 11, Claude adversarial finding 2); why_new=tasks 21 and 48 published while the service was attached; seam=the store's saveLearningDecision rejects with a path inside the learning directory, then the reader's own runtime release and a direct publish standing for any later view change; no_claim=the line's rendering +test('a refused test replay control’s cause stays without the storage folder after the runtime is released', async (context) => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await openByOpenReplay(harness) + const store = harness.service.nodeHistoryStore! + context.mock.method(store, 'saveLearningDecision', async () => { + throw new Error( + `EACCES: permission denied, open '${store.learningDirectory}/decision.json'`, + ) + }) + harness.receivePlaybackPanel({ + type: 'node-history-understanding', + revision: lastView(harness)!.revision, + studyId: history.studyId, + understood: true, + }) + await waitUntil( + () => lastView(harness)?.nodeHistoryRefusal !== undefined, + 'the refusal is published', + READER_RESULT_BOUND_MS, + ) + + // Act: the runtime is released, and the reader publishes again, as any later view change makes it. + await harness.reader.retire(harness.service) + harness.reader['publish']() + + // Assert: the views still show the cause with the storage folder replaced; the view is a clone from the webview's realm, so its lines are compared as a plain array. + const shown = lastView(harness) + assert.deepEqual( + { + errors: [...(shown?.errors ?? [])], + refusal: shown?.nodeHistoryRefusal, + }, + { + errors: [ + "Error: EACCES: permission denied, open '/decision.json'", + ], + refusal: "EACCES: permission denied, open '/decision.json'", + }, + ) + } finally { + context.mock.restoreAll() + await harness.close() + } +}) + +// Value: protects=a test replay control refused after the runtime was released still shows its cause with the storage folder replaced, because the reader words causes with the roots it holds itself once no service names them; fails_when=the reader falls back to no storage roots after the release (`{ storage: [] }`), so a control it still serves from its own history store shows the learner's storage path (Cycle 0052 pt2 /ship Step 11, Claude adversarial pass 2); why_new=the earlier release test refused the control before the release; seam=the reader's own runtime release, then the store's saveLearningDecision rejecting with a path inside the learning directory; no_claim=the line's rendering +test('a test replay control refused after the runtime is released shows its cause without the storage folder', async (context) => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await openByOpenReplay(harness) + const store = harness.service.nodeHistoryStore! + await harness.reader.retire(harness.service) + context.mock.method(store, 'saveLearningDecision', async () => { + throw new Error( + `EACCES: permission denied, open '${store.learningDirectory}/decision.json'`, + ) + }) + + // Act: the learner marks the replay understood after the release, and the write is refused. + harness.receivePlaybackPanel({ + type: 'node-history-understanding', + revision: lastView(harness)!.revision, + studyId: history.studyId, + understood: true, + }) + await waitUntil( + () => lastView(harness)?.nodeHistoryRefusal !== undefined, + 'the refusal is published', + READER_RESULT_BOUND_MS, + ) + + // Assert: the view is a clone from the webview's realm, so its lines are compared as a plain array. + const shown = lastView(harness) + assert.deepEqual( + { + errors: [...(shown?.errors ?? [])], + refusal: shown?.nodeHistoryRefusal, + }, + { + errors: [ + "Error: EACCES: permission denied, open '/decision.json'", + ], + refusal: "EACCES: permission denied, open '/decision.json'", + }, + ) + } finally { + context.mock.restoreAll() + await harness.close() + } +}) + +// Value: protects=a failure the reader words after the runtime was released hides the storage folder, the workspace and the home directory as the released service did; fails_when=the reader falls back to the home directory alone (the former `describeFailure`) or to roots it guesses without the released service's workspace, so the learner's storage or repository path shows (Cycle 0052 pt2 /ship Step 9, delta review of the release fallback); why_new=the release tests above cover the storage folder of a refused control only; seam=the reader's own runtime release and its public describeFailure, as the Save Learning Replay command calls it; no_claim=a workspace distinct from the storage root (the harness keeps its storage in the workspace folder) and the notification's rendering +test('a failure worded after the runtime is released hides the storage folder, the workspace and the home directory', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const storage = harness.service.nodeHistoryStore!.learningDirectory + const workspace = harness.service.environment.workspace + await harness.reader.retire(harness.service) + + // Act + const worded = harness.reader.describeFailure( + new Error( + `EACCES: '${storage}/a.json', '${workspace}/src/b.ts', '${homedir()}/c.txt'`, + ), + ) + + // Assert: the harness's service keeps its storage in the workspace folder, so the released service's roots word that folder `` too. + assert.equal( + worded, + "EACCES: '/a.json', '/src/b.ts', '~/c.txt'", + ) + } finally { + await harness.close() + } +}) + +// Value: protects=a failed move whose return to the committed moment fails too does not claim that the last moment is still shown: the line words the move's failure and its recovery, and the recorded-source row tells why the return failed; fails_when=the line says `The last moment is still shown.` while the slot could not show that moment again (local CodeRabbit round 5 on dba70b06, Major); why_new=the move-failure tests return to the committed moment successfully; seam=the harness's held historical debug presentation rejected with a plain Error, the learner's own close of the slot tab and the harness's failure of the next non-file document open; no_claim=a real DAP or editor failure +test('a failed move whose return to the last moment fails too does not say the last moment is still shown', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await openByOpenReplay(harness) + harness.hideSummary() + const held = harness.holdHistoricalDebugPresentation(true) + harness.receivePlaybackPanel({ + type: 'node-history-step', + revision: lastView(harness)!.revision, + studyId: history.studyId, + direction: 'next', + unit: 'stop', + }) + await held.started + // The learner closes the slot's tab, and the editor will refuse to open it again. + const slotTab = harness.groups + .flatMap((group) => group.tabs) + .find((tab) => + ( + tab.input as { uri?: { path?: string } } | undefined + )?.uri?.path?.includes('/node-history-slot/'), + )! + harness.closeTabByHand(slotTab) + harness.failCurrentSourceOpen() + + // Act: the running debugger fails to show the stop, and the return to the last moment cannot open the slot. + held.reject(new Error('The stop could not be shown in Run and Debug.')) + await waitUntil( + () => lastView(harness)?.nodeHistoryRefusal !== undefined, + 'the failure reaches the transport', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.equal( + lastView(harness)?.nodeHistoryRefusal, + 'The recorded moment could not be shown: The stop could not be shown in Run and Debug. Select this stop again to retry.', + ) + } finally { + harness.restoreCurrentSourceOpen() + await harness.close() + } +}) diff --git a/extension/src/tests/studyWorkspaceTestReplaySource.test.ts b/extension/src/tests/studyWorkspaceTestReplaySource.test.ts index 60c510d3..5b0db085 100644 --- a/extension/src/tests/studyWorkspaceTestReplaySource.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplaySource.test.ts @@ -752,9 +752,9 @@ test('a failed presentation is reported as itself when the return to the committ ) // The rejection is awaited below, after the held presentation fails. const outcome = assert.rejects(selecting, { - // The move's failure carries the open path's recovery clause and names the presentation's own cause (Cycle 0051, DS-G). + // The move's failure carries the open path's recovery clause and names the presentation's own cause (Cycle 0051, DS-G); the return failed, so it claims no shown moment (Cycle 0052 pt2, local CodeRabbit round 5). message: - 'The replay debugger could not start: The presentation failed. The last moment is still shown. Select this stop or use Inspect in native debugger to restart it.', + 'The recorded moment could not be shown: The presentation failed. Select this stop again to retry.', }) await held.started @@ -3284,67 +3284,81 @@ test('the placeholder is revealed from its first line when an output follows a s // Act await selectMoment(harness, history.studyId, 3) - // Assert - const revealed = harness.slotReveals().slice(revealsAtStop) + // Assert: the placeholder is revealed from its first line (before its bytes arrive, and again at the commit), and no reveal scrolls it anywhere else. + const revealed = harness + .slotReveals() + .slice(revealsAtStop) + .map((reveal) => reveal.line) assert.deepEqual( - revealed.map((reveal) => reveal.line), - [0], + { + last: revealed.at(-1), + elsewhere: revealed.filter((line) => line !== 0), + }, + { last: 0, elsewhere: [] }, ) } finally { await harness.close() } }) -// Value: protects=a CodeLens above the stopped line carries the whole label Recorded: : · s at a verified stop and none at the placeholder; fails_when=the label exists only at the end of the line, where the editor group's edge cuts it; why_new=F207 (the end-of-line label was cut at the default layout); seam=the workspace harness's CodeLens provider; no_claim=whether both hosts draw the lens (the native check) -test('a CodeLens above the stopped line carries the whole recorded label', async () => { +// Value: protects=the recorded-source slot draws no CodeLens, at a stop or while a move to the next stop is pending: the editor redraws a provider's lens only after its own refresh delay (at least 250 ms in VS Code, about 300 ms in Cursor), so any lens would still name the stop a move left beside the next moment; the stop's whole label is the end-of-line label, which leaves with the highlight in the same host turn; fails_when=a CodeLens provider answers a lens for the slot (N51-V1: 2 to 3 frames in VS Code and about 9 in Cursor of `Recorded: app/lib/actions.ts:35 · 20.56 s` beside the pending target, Cycle 0052 task 1); why_new=the earlier test pinned the lens F207 added; seam=the workspace harness's CodeLens registry, which asks every provider registered for the slot's scheme; no_claim=the native frames, which N52 (b) checks +test('the recorded-source slot draws no CodeLens at a stop or while a move to the next stop is pending', async () => { // Arrange const harness = await workspaceHarness() try { - const history = await saveOneStopNodeHistory( + const history = await saveTwoFileNodeHistory( harness.service.nodeHistoryStore!, ) await openReplayAt(harness, history.studyId, 2) const slot = slotTabs(harness)[0]! + const atStop = { + lenses: harness.codeLensTitles(slot), + labels: slotLabels(harness), + } + const invalidationsBefore = + harness.historicalDebugInvalidationLabels().length + harness.useManualSlotClock() - // Act - const atStop = harness.codeLensTitles(slot) - await selectMoment(harness, history.studyId, 3) - const atOutput = harness.codeLensTitles(slot) - - // Assert - assert.deepEqual(atStop, ['Recorded: src/theme.ts:1 · 0.20 s']) - assert.deepEqual(atOutput, []) - } finally { - await harness.close() - } -}) - -// Value: protects=the editor re-asking for the slot's CodeLens whenever the label is drawn or cleared (a moment selected, the slot retired), so a lens never stays on a line the replay left; fails_when=the reader draws or clears the label without announcing the change, or stops announcing at the retire; why_new=F373 (the fake never exercised onDidChangeCodeLenses); seam=the workspace harness's CodeLens refresh counter; no_claim=how many refreshes the editor runs for one announcement -test('a CodeLens refresh is announced when a moment is selected and when the slot is retired', async () => { - // Arrange - const harness = await workspaceHarness() - try { - const history = await saveOneStopNodeHistory( - harness.service.nodeHistoryStore!, + // Act: Next stop starts; the slot still holds the first stop's bytes. + startMomentSelection(harness, history.studyId, 4) + await waitUntil( + () => + harness.historicalDebugInvalidationLabels().length > + invalidationsBefore, + 'the debugger leaves the first stop', + READER_RESULT_BOUND_MS, + ) + const pending = { + lenses: harness.codeLensTitles(slot), + labels: slotLabels(harness), + } + harness.elapseSlotClock(300) + await waitUntil( + () => + lastView(harness)?.nodeHistoryPosition?.sequence === 4 && + !lastView(harness)?.nodeHistoryPosition?.pending, + 'the second stop commits', + READER_RESULT_BOUND_MS, ) - await openReplayAt(harness, history.studyId, 2) - const refreshesAtOpen = harness.codeLensRefreshCount() - - // Act - await selectMoment(harness, history.studyId, 3) - const refreshesAfterSelect = harness.codeLensRefreshCount() - await harness.reader['retireNodeSourceSlot']() // Assert - assert.equal(refreshesAfterSelect > refreshesAtOpen, true) - assert.equal(harness.codeLensRefreshCount() > refreshesAfterSelect, true) + assert.deepEqual( + { atStop, pending }, + { + atStop: { + lenses: [], + labels: [' Recorded: src/theme.ts:1 · 0.20 s'], + }, + pending: { lenses: [], labels: [] }, + }, + ) } finally { await harness.close() } }) -// Value: protects=the fake editor asking a CodeLens provider only about documents its selector names, and one registration's second dispose removing nothing more; fails_when=a provider registered for another scheme still returns the slot's lens in a test, or a repeated dispose of another extension's registration removes the reader's provider; why_new=F373 (the fake asked every provider about every document and spliced index -1 on a second dispose); seam=the workspace harness's CodeLens registry; no_claim=the real editor's selector scoring -test('a second dispose of another extension’s CodeLens registration keeps the slot’s CodeLens', async () => { +// Value: protects=the fake editor's CodeLens registry, which the slot's no-lens test reads: a registration's second dispose removes nothing more, and a provider for another scheme gives the slot no lens; fails_when=a repeated dispose of another extension's registration splices another provider out, or the fake asks a provider about documents its selector does not name; why_new=F373 (the fake asked every provider about every document and spliced index -1 on a second dispose); seam=the workspace harness's CodeLens registry; no_claim=the real editor's selector scoring +test('a second dispose of another extension’s CodeLens registration removes no other provider', async () => { // Arrange const harness = await workspaceHarness() try { @@ -3361,32 +3375,7 @@ test('a second dispose of another extension’s CodeLens registration keeps the // Assert assert.equal(harness.codeLensProviderCount(), providersWithReader) - assert.deepEqual(harness.codeLensTitles(slotTabs(harness)[0]!), [ - 'Recorded: src/theme.ts:1 · 0.20 s', - ]) - } finally { - await harness.close() - } -}) - -// Value: protects=a recorded path that holds icon syntax such as $(trash) showing as text in the CodeLens title, so a recording cannot draw an icon of its own there; fails_when=the title carries the path unescaped and the editor draws a codicon in place of the characters; why_new=F367 (the title stripped terminal controls only; the hover was escaped in F176); seam=the workspace harness's CodeLens provider; no_claim=how the editor draws the escaped title (the native check) -test('a CodeLens title shows a recorded path with icon syntax as text', async () => { - // Arrange - const harness = await workspaceHarness() - try { - const history = await saveTwoFileNodeHistory( - harness.service.nodeHistoryStore!, - 'src/$(trash).ts', - ) - await openReplayAt(harness, history.studyId, 2) - await selectMoment(harness, history.studyId, 4) - const slot = slotTabs(harness)[0]! - - // Act - const titles = harness.codeLensTitles(slot) - - // Assert - assert.deepEqual(titles, ['Recorded: src/\\$(trash).ts:1 · 0.30 s']) + assert.deepEqual(harness.codeLensTitles(slotTabs(harness)[0]!), []) } finally { await harness.close() } @@ -3441,8 +3430,8 @@ test('the slot keeps the earlier stop’s bytes until the native views have redr } }) -// Value: protects=moving from a stop to an output announces the CodeLens refresh at the leave, answers no lens for the slot at once, and keeps the stop's bytes in the slot until the editor's lens refresh delay (300 ms) has passed, so the stale lens never stands above the placeholder (F387); fails_when=the placeholder replaces the stop's bytes while the editor may still draw the stop's lens; why_new=F387 (native frames 76 to 82 showed the stop's lens above line 1 of the placeholder for 230 ms); seam=the workspace harness's CodeLens registry with its refresh counter, the document model and the hand-driven slot clock; no_claim=when the native editor repaints the lens (the driving agent's frame recheck) -test('the stop’s CodeLens is withdrawn at the leave and the placeholder replaces the stop’s bytes only after the editor’s lens refresh delay', async () => { +// Value: protects=moving from a stop to an output keeps the stop's bytes in the slot for the native views' repaint delay (300 ms) and shows the placeholder only then, so the Call Stack that still draws the stop never stands beside the placeholder (F387), and no CodeLens names the stop meanwhile; fails_when=the placeholder replaces the stop's bytes in the same turn as the debugger's leave, or a lens answers for the slot; why_new=F387 (native frames 76 to 82 showed the stop's lens above line 1 of the placeholder for 230 ms); the lens is gone since Cycle 0052 task 1; seam=the workspace harness's CodeLens registry, the document model and the hand-driven slot clock; no_claim=when the native views repaint (the driving agent's frame recheck) +test('the placeholder replaces the stop’s bytes only after the native views’ repaint delay, with no CodeLens for the slot', async () => { // Arrange const harness = await workspaceHarness() try { @@ -3451,8 +3440,6 @@ test('the stop’s CodeLens is withdrawn at the leave and the placeholder replac ) await openReplayAt(harness, history.studyId, 2) const slot = slotTabs(harness)[0]! - const lensBefore = harness.codeLensTitles(slot) - const refreshesBefore = harness.codeLensRefreshCount() const invalidationsBefore = harness.historicalDebugInvalidationLabels().length harness.useManualSlotClock() @@ -3468,7 +3455,6 @@ test('the stop’s CodeLens is withdrawn at the leave and the placeholder replac ) await new Promise((resolve) => setTimeout(resolve, 50)) const lensDuringLeave = harness.codeLensTitles(slot) - const refreshesDuringLeave = harness.codeLensRefreshCount() const textDuringLeave = harness.documentTextOf(slot) harness.elapseSlotClock(300) await waitUntil( @@ -3480,9 +3466,7 @@ test('the stop’s CodeLens is withdrawn at the leave and the placeholder replac ) // Assert - assert.deepEqual(lensBefore, ['Recorded: src/theme.ts:1 · 0.20 s']) assert.deepEqual(lensDuringLeave, []) - assert.equal(refreshesDuringLeave > refreshesBefore, true) assert.equal(textDuringLeave, THEME_TEXT) assert.notEqual(harness.documentTextOf(slot), THEME_TEXT) } finally { @@ -3508,7 +3492,7 @@ test('the playback time follows the pending stop and returns to the committed mo const outcome = assert.rejects(selecting, { // The move's failure carries the open path's recovery clause and names the presentation's own cause (Cycle 0051, DS-G). message: - 'The replay debugger could not start: The presentation failed. The last moment is still shown. Select this stop or use Inspect in native debugger to restart it.', + 'The recorded moment could not be shown: The presentation failed. The last moment is still shown. Select this stop again to retry.', }) await held.started @@ -3553,7 +3537,7 @@ test('the position line and the playback readout say the requested moment is loa const outcome = assert.rejects(selecting, { // The move's failure carries the open path's recovery clause and names the presentation's own cause (Cycle 0051, DS-G). message: - 'The replay debugger could not start: The presentation failed. The last moment is still shown. Select this stop or use Inspect in native debugger to restart it.', + 'The recorded moment could not be shown: The presentation failed. The last moment is still shown. Select this stop again to retry.', }) await held.started @@ -4449,3 +4433,157 @@ test('a Retry of a failed slot that a newer seek overtakes while its tab is show await harness.close() } }) + +// Value: protects=a move from a stop to an output scrolls the slot to its first line before the placeholder's bytes reach the editor, so the editor never draws the stop's source's sticky-scroll header over the placeholder while it recomputes it; fails_when=the slot keeps the stop's scroll until the commit's reveal, which N52 saw in Cursor as 11 frames of `27 export async function authenticate(` over the placeholder (Cycle 0052 task 1); why_new=the commit's reveal was the only one; seam=the hand-driven slot clock and the harness's held delivery of the next provider result, with the slot's reveals; no_claim=how each host draws its sticky-scroll header (N52 (b)) +test('a move from a stop to an output scrolls the slot to its first line before the placeholder’s bytes arrive', async () => { + // Arrange + const harness = await workspaceHarness() + let release = (): void => {} + try { + const history = await saveTwoFileNodeHistory( + harness.service.nodeHistoryStore!, + ) + await openReplayAt(harness, history.studyId, 2) + const slot = slotTabs(harness)[0]! + const revealsAtStop = harness.slotReveals().length + const invalidationsBefore = + harness.historicalDebugInvalidationLabels().length + harness.useManualSlotClock() + const held = harness.holdNextSourceUpdate() + release = held.release + + // Act: the move leaves the stop, and the settle ends; the placeholder's bytes are on their way. + startMomentSelection(harness, history.studyId, 3) + await waitUntil( + () => + harness.historicalDebugInvalidationLabels().length > + invalidationsBefore, + 'the debugger leaves the stop', + READER_RESULT_BOUND_MS, + ) + harness.elapseSlotClock(300) + await held.started + const whileBytesTravel = { + text: harness.documentTextOf(slot), + reveals: harness + .slotReveals() + .slice(revealsAtStop) + .map((reveal) => reveal.line), + } + held.release() + await waitUntil( + () => + lastView(harness)?.nodeHistoryPosition?.sequence === 3 && + !lastView(harness)?.nodeHistoryPosition?.pending, + 'the output commits', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.deepEqual(whileBytesTravel, { text: THEME_TEXT, reveals: [0] }) + } finally { + release() + await harness.close() + } +}) + +// Value: protects=a move between two moments without a verified stop (an output, then the result) keeps the placeholder in the slot through the whole move: no stop label is drawn, the slot's bytes stay the placeholder, and no reveal scrolls it off its first line; fails_when=the leave step draws a label, puts other bytes in the slot, or reveals another line while the slot holds the placeholder; why_new=the state cell slot-placeholder × leave-to-other-moment was untested (Cycle 0052 pt2 /ship review, state-machines specialist); seam=the workspace harness's slot document text, its source identity decorations and its slot reveal log; no_claim=the native paint (N52 (b)) +test('a move from an output to the result keeps the placeholder in the slot, with no stop label and no reveal off its first line', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await saveOneStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await openReplayAt(harness, history.studyId, 2) + await selectMoment(harness, history.studyId, 1) + const slot = slotTabs(harness)[0]! + const revealsBefore = harness.slotReveals().length + const decorationsBefore = harness.sourceIdentityDecorations().length + + // Act + await selectMoment(harness, history.studyId, 3) + + // Assert + assert.deepEqual( + { + text: harness.documentTextOf(slot), + labels: harness + .sourceIdentityDecorations() + .slice(decorationsBefore) + .filter((call) => call.uri.includes('/node-history-slot/')) + .flatMap((call) => call.ranges), + offFirstLine: harness + .slotReveals() + .slice(revealsBefore) + .filter((reveal) => reveal.line !== 0), + }, + { text: PLACEHOLDER, labels: [], offFirstLine: [] }, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=after the slot failed to show its stop and reads its loading text, a move to another moment (an output) replaces the loading text with the placeholder, shown from its first line, and draws no stop label on the way; fails_when=the loading text stays after the move, a stop label is drawn, or a reveal scrolls the placeholder off its first line; why_new=the state cells slot-loading × leave-to-other-moment and slot-loading × commit-output were untested (Cycle 0052 pt2 /ship review, state-machines specialist); seam=the harness's injected failure of a non-file document open (F170's route to the loading text), its slot document text, source identity decorations and slot reveal log; no_claim=the native paint +test('a move to an output after the slot failed to show its stop replaces the loading text with the placeholder from its first line, with no stop label', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const store = harness.service.nodeHistoryStore! + const history = await saveOneStopNodeHistory(store) + await store.saveReaderPosition(history.studyId, 2) + await openReplayAt(harness, history.studyId, 2) + // The learner closes the slot's tab by hand and the reselect's open fails, so the slot reads its loading text (F170). + const slotAddress = slotTabs(harness)[0]! + const slotTab = harness.groups + .flatMap((group) => group.tabs) + .find((tab) => + ( + tab.input as { uri?: { path?: string } } | undefined + )?.uri?.path?.includes('/node-history-slot/'), + )! + harness.closeTabByHand(slotTab) + harness.failCurrentSourceOpen() + await harness.reader['requestNodeHistoryEvent']( + harness.reader['selectedNodeHistory']!, + 2, + ) + harness.restoreCurrentSourceOpen() + const loadingText = await harness.readCapturedSource(slotAddress) + const revealsBefore = harness.slotReveals().length + const decorationsBefore = harness.sourceIdentityDecorations().length + + // Act + await selectMoment(harness, history.studyId, 1) + + // Assert + const revealed = harness + .slotReveals() + .slice(revealsBefore) + .map((reveal) => reveal.line) + assert.deepEqual( + { + before: loadingText, + after: harness.documentTextOf(slotTabs(harness)[0]!), + labels: harness + .sourceIdentityDecorations() + .slice(decorationsBefore) + .filter((call) => call.uri.includes('/node-history-slot/')) + .flatMap((call) => call.ranges), + lastReveal: revealed.at(-1), + offFirstLine: revealed.filter((line) => line !== 0), + }, + { + before: '// Loading recorded source…\n', + after: PLACEHOLDER, + labels: [], + lastReveal: 0, + offFirstLine: [], + }, + ) + } finally { + harness.restoreCurrentSourceOpen() + await harness.close() + } +}) diff --git a/extension/src/ui/studyHtml.ts b/extension/src/ui/studyHtml.ts index 77d15c8b..63e825be 100644 --- a/extension/src/ui/studyHtml.ts +++ b/extension/src/ui/studyHtml.ts @@ -521,6 +521,8 @@ function studyClient( let selectedContentTab: StudyViewMessage['contentTab'] let revealedFollowedObservationId: string | undefined let scrolledNodeHistoryToken: number | undefined + // The open of a test replay whose clamped texts the learner may have expanded; another open collapses them ({@link collapseShowMoreOnOpen}). + let showMoreOpenToken: number | undefined let readerOptionsOpenedByLearner = false readerOptions.querySelector('summary')?.addEventListener('click', () => { readerOptionsOpenedByLearner = !readerOptions.hasAttribute('open') @@ -2332,6 +2334,7 @@ function studyClient( }, label: string, stepAction: (step: Step) => Node, + marksSteps = false, ): [visual: HTMLDivElement, steps: HTMLOListElement] => { const namespace = 'http://www.w3.org/2000/svg', diagram = document.createElementNS(namespace, 'svg') @@ -2434,6 +2437,11 @@ function studyClient( ) number.setAttribute('text-anchor', 'middle') writeSequenceLines(number, labelX, lines, false) + // A drawing that marks its steps names each arrow and label by its step as it draws them, so no marker pairs them by their order in the drawing (Cycle 0052 task 19). + if (marksSteps) { + arrow.dataset.step = step.id + number.dataset.step = step.id + } diagram.append(arrow, number) const row = document.createElement('li'), heading = document.createElement('strong'), @@ -2999,8 +3007,8 @@ function studyClient( learning.content, `Sequence for ${learning.content.title}. Numbered interactions are described below.`, (step) => testReplayStepAction(step.id, learning, section), + true, ) - tagTestReplayDrawing(visual, learning.content.steps) element('node-learning-sequence').replaceChildren(visual) element('node-learning-steps').replaceChildren(list) } @@ -3009,26 +3017,6 @@ function studyClient( message.nodeHistoryPosition?.pending ? undefined : section, ) } - /** - * Names each arrow label of a test replay's drawing by its step (`data-step`), so the selected step can be marked on the drawing that stays (DS-B); called by {@link renderTestReplayLearning} once per drawing. The shared {@link sequenceView} is unchanged, so the browser replay's diagram keeps its markup. - * Each step's label is the text the renderer appends right after that step's arrow. - * @param visual - The drawing's container. - * @param steps - The material's steps, in the drawing's order. - * @example tagTestReplayDrawing(visual, learning.content.steps) - */ - const tagTestReplayDrawing = ( - visual: HTMLElement, - steps: readonly { id: string }[], - ): void => { - visual.querySelectorAll('path.sequence-arrow').forEach((arrow, index) => { - const label = arrow.nextElementSibling - const step = steps[index] - if (label instanceof SVGElement && step) { - label.dataset.step = step.id - if (arrow instanceof SVGElement) arrow.dataset.step = step.id - } - }) - } /** * Marks the selected step on the test replay's drawing by setting `aria-current="step"` and a class on its existing arrow and label, never by drawing again, so a move keeps the diagram's scroll position and the focus (DS-B, Eng); a pending moment or an event with no step marks none. Called by {@link renderTestReplayLearning} for every Study message. * @param section - The section's worded view, which names the selected step; absent while the moment is pending. @@ -3094,6 +3082,20 @@ function studyClient( } } window.addEventListener('resize', offerShowMore) + /** + * Collapses every clamped text of the test replay's summary when the message names another open of a test replay than the one its texts were expanded for (another replay, or the same one opened again), so an open never starts with an earlier open's expanded texts; a move inside the open keeps them (Cycle 0052 task 20). Called for every Study message, before {@link offerShowMore}. + * @param message - The current Study message; its `nodeHistoryScrollToken` names the open. + * @example collapseShowMoreOnOpen(message) // every Show more reads aria-expanded="false" after open 8 follows open 7 + */ + const collapseShowMoreOnOpen = (message: StudyViewMessage): void => { + const token = message.nodeHistoryScrollToken + if (token === undefined || token === showMoreOpenToken) return + showMoreOpenToken = token + for (const control of Array.from( + document.querySelectorAll('[data-show-more]'), + )) + control.setAttribute('aria-expanded', 'false') + } document.addEventListener('click', (event) => { const control = event.target instanceof Element @@ -4254,6 +4256,8 @@ function studyClient( // Every control is in its state for this message now; a pane without the focus (the learner is in Run and Debug) waits for its next focus event. if (document.hasFocus()) payTransportFocus() offerClampedTitles() + // Each open of a test replay starts with every clamped text collapsed; moves inside it keep the learner's expanded texts (Cycle 0052 task 20). + collapseShowMoreOnOpen(message) // Measured once the content is shown: a hidden page has no lines to count. offerShowMore() // A newly followed pause should reveal its interpretation and facts without requiring a learner scroll past run controls. diff --git a/extension/src/utils/escapeCodiconSyntax.ts b/extension/src/utils/escapeCodiconSyntax.ts deleted file mode 100644 index 5f048975..00000000 --- a/extension/src/utils/escapeCodiconSyntax.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Escapes the icon syntax of VS Code labels, so a recorded text such as a file path shows as written: a CodeLens title or a status bar text draws `$(name)` as an icon, and `\$(name)` as the text. - * - * Called by {@link StudyWorkspace} for the title of the recorded-source slot's CodeLens, whose path comes from a recording and not from Codebook. - * - * @param text - Text that came from a recording. - * @returns The text with every `$(` turned into `\$(`. - * - * @example - * ```ts - * escapeCodiconSyntax('src/$(trash).ts') // => 'src/\\$(trash).ts' - * ``` - */ -export function escapeCodiconSyntax(text: string): string { - return text.replaceAll('$(', '\\$(') -}