From c4a7f6e0f5dd35acbdc5ed862a73a99679f30fbe Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sat, 24 Oct 2026 22:49:03 +0900 Subject: [PATCH 01/30] fix: leaving a replay withdraws its Learning summary and shows no test replay transport; the Application tab gets its own title back (Cycle 0052 tasks 2, 3, 4, 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - retireNodeHistorySelection clears the Learning summary before anything publishes (N51-V2). - shownReplayFamily names 'test' only while a test replay holds the composition, and 'browser' while a recording is active or a same-study Reload loads it (N51-V3). - publish posts to the Learning summary only while it shows a replay (N51-V2, N51-V6). - The Application pane takes back 'codebook · Application' when Live content or a test replay's output replaces a browser replay (N51-V4). Tests changed with reason: five tests read the reader's state after Return to Live from the summary's last message; the summary now receives none once the replay is left, so they read the bottom panel, which every publish reaches. Refs #124 --- extension/src/constants.ts | 4 + extension/src/studyWorkspace.ts | 48 ++++++++-- extension/src/tests/studyWorkspace.test.ts | 6 +- .../studyWorkspaceTestReplayBrowser.test.ts | 67 ++++++++++++++ ...tudyWorkspaceTestReplayComposition.test.ts | 87 +++++++++++++++++++ .../studyWorkspaceTestReplayOutput.test.ts | 3 +- .../studyWorkspaceTestReplaySource.test.ts | 6 +- 7 files changed, 210 insertions(+), 11 deletions(-) diff --git a/extension/src/constants.ts b/extension/src/constants.ts index 29cc274e7..3903fe55b 100644 --- a/extension/src/constants.ts +++ b/extension/src/constants.ts @@ -491,6 +491,10 @@ export const SUMMARY_BESIDE_CHAT_MOVE_ARGUMENT = { viewIds: ['codebook.learningSummary'], destinationId: 'workbench.panel.chat', } as const +/** The Application pane's own title: at its creation, and again when another writer takes its document after a browser replay retitled it (Cycle 0052 task 4). */ +export const APPLICATION_PANE_TITLE = 'codebook · Application' +/** The Application pane's title while it shows a browser replay's recorded frames. */ +export const RECORDED_APPLICATION_PANE_TITLE = 'codebook · Recorded application' /** Why the Learning summary opens on the left in Cursor and how to keep it on the right; Cursor rejects an extension's own secondary side bar container, so only the learner's Move View can place it there. */ export const SUMMARY_PLACEMENT_OFFER = 'Cursor opens the Codebook Learning summary in its primary side bar. To keep it on the right, move it once: choose "New Secondary Side Bar Entry" in the list that opens.' diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 60039f0f8..79fe1e618 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -15,6 +15,7 @@ import { APPLICATION_GROUP_MAY_HAVE_KEYBOARD_MESSAGE, APPLICATION_GROUP_TOOK_KEYBOARD_MESSAGE, APPLICATION_PANE_CLOSED_MESSAGE, + APPLICATION_PANE_TITLE, EDITOR_GROUPS_PRESERVED_MESSAGE, CHAT_KEYBOARD_RETURNED_MESSAGES, CHOICE_RETIRED_ERROR_KINDS, @@ -53,6 +54,7 @@ import { READ_ENDED_FOR_AGENT_KEYBOARD_MESSAGE, READER_CLOSE_STORAGE_TIMEOUT_MS, type ReaderErrorKind, + RECORDED_APPLICATION_PANE_TITLE, RECORDING_WINDOW_FOCUS_WAIT_MS, REPLAY_COVERED_LABEL, REPLAY_VIEWPORT_RECEIPT_LIMIT, @@ -843,6 +845,8 @@ export class StudyWorkspace { /** The disposal of replaced historical debuggers that is still running; the editor accepts the next owner only once it has settled. */ private replayDebuggerRetirement?: Promise private recordingActive = false + /** The study whose same-study Reload is loading its recording again: from the Reload's leave until its load ends or a newer departure supersedes it ({@link leavePlayback}, {@link close}). While it is set the summary composition still shows that browser replay, so the Reload's loading, error and retry messages reach its Learning summary (Cycle 0052 tasks 3 and 18). */ + private recordingReloadStudyId?: string /** The captured-source tabs of the browser replay that were open when it was last left, with the address each had, held as the editor's own tab objects (F485): kept until a test replay's first slot show gets the editor to close them (a refused close keeps them for the next show, F484), or until a browser replay starts, the test replay's slot ends or the reader closes (F482). A tab the learner closes by hand leaves it ({@link forgetClosedRetiredBrowserReplayTabs}), so one the learner opens after the replay was left, even at a recorded address, is a different tab and is not in it; an editor that rebuilds its tab objects (Cursor, at a removed group) leaves entries no tab object matches, and those match by their address (F466, F474). */ private retiredBrowserReplayTabs?: ReadonlyMap private replayViewport?: BrowserViewport @@ -8898,7 +8902,14 @@ export class StudyWorkspace { throw new Error('Select a recording first.') // Same-study Reload keeps its Summary document so a failed read can publish recovery there. this.leavePlayback(true) - await this.openRecording(studyId) + this.recordingReloadStudyId = studyId + try { + await this.openRecording(studyId) + } finally { + // Only this Reload's own mark ends here; a newer departure already cleared it. + if (this.recordingReloadStudyId === studyId) + this.recordingReloadStudyId = undefined + } return } if (action === 'return-current') await this.returnCurrentSelection() @@ -9467,6 +9478,7 @@ export class StudyWorkspace { // A closed reader carries no reason of a reading or a refused change that ended with it. this.presentationReason = undefined this.repositoryRecordingOpen = undefined + this.recordingReloadStudyId = undefined // Closing disposes the native session: a Node stop still loading its values is abandoned, and no Run and Debug hint may survive a reopen. this.selectedNodeHistoryRequest++ // The cut-off selection no longer clears its requested moment (its token is gone), so the bump gives it back. @@ -12414,7 +12426,7 @@ export class StudyWorkspace { return this.replayViewport } const panel = await this.prepareApplicationGroup(signal) - panel.title = 'codebook · Recorded application' + panel.title = RECORDED_APPLICATION_PANE_TITLE const viewport = new BrowserViewport( this.selection.studyId!, (type, detail) => this.recordReplayDelivery(type, detail), @@ -12710,6 +12722,8 @@ export class StudyWorkspace { * @example this.leavePlayback(true) */ private leavePlayback(preserveSummary = false): void { + // An explicit departure supersedes a same-study Reload that is still loading. + if (!preserveSummary) this.recordingReloadStudyId = undefined this.nodeHistoryPlaybackController?.pause() this.repositoryRecordingOpen = undefined if (!this.recordingActive) return @@ -12741,6 +12755,13 @@ export class StudyWorkspace { * ``` */ private async retireNodeHistorySelection(): Promise { + // A test replay shown in the summary composition withdraws its summary before anything publishes, as a browser replay's departure does (Cycle 0052 task 2, N51-V2). + if ( + this.selectedNodeHistory && + this.summaryComposition && + !this.recordingActive + ) + this.clearLearningSummary() // A test replay that ends (Return to Live, an open that did not finish) ends the record of the browser replay's tabs it was to close; a record taken before any test replay was selected stays for the first one (F482). if (this.selectedNodeHistory) this.retiredBrowserReplayTabs = undefined this.selectedNodeHistoryRequest++ @@ -13582,12 +13603,15 @@ export class StudyWorkspace { /** * Names the replay family the summary composition shows, which the Study views read for their page (Cycle 0051, DS-A); the one place the family is decided, read by {@link publish}. - * @returns `browser` while a browser recording is active, `test` for a selected or opening test replay, `undefined` outside the summary composition. + * @returns `browser` while a browser recording is active or reloading, `test` for a selected or opening test replay, `undefined` outside the summary composition and while the reader leaves a replay. * @example this.shownReplayFamily() // => 'test' while a test replay holds the composition */ private shownReplayFamily(): 'browser' | 'test' | undefined { if (!this.summaryComposition) return undefined - return this.recordingActive ? 'browser' : 'test' + if (this.recordingActive || this.recordingReloadStudyId !== undefined) + return 'browser' + // A reader that is leaving a replay shows neither family until open() leaves the composition, so no view draws a test replay transport without a history (Cycle 0052 task 3, N51-V3). + return this.testReplayHoldsComposition() ? 'test' : undefined } /** Publishes a matching projection with stable DOM controls, excluding images from the context channel. @example this.publish() */ @@ -13625,7 +13649,8 @@ export class StudyWorkspace { if (signature === this.lastPublished) return this.lastPublished = signature void this.contextPanel?.webview.postMessage(compact) - if (this.summaryComposition) + // The Learning summary draws only a shown replay: a publish while the reader leaves one would draw the Live Study page there (Cycle 0052 task 2, N51-V2). + if (this.summaryComposition && compact.replayFamily) void this.summaryView?.webview.postMessage(compact) if (this.moments) this.moments.title = this.summaryComposition @@ -14987,6 +15012,7 @@ export class StudyWorkspace { */ private presentApplication(message: StudyViewMessage): void { if (this.recordingActive) return + this.retitleApplicationAfterRecording() // A test replay's pane is one document per history; each later moment is posted into it (EN3). if (message.nodeHistoryApplication) { this.presentNodeHistoryApplication( @@ -15053,6 +15079,15 @@ export class StudyWorkspace { this.app?.reveal(undefined, false) } + /** + * Gives the Application pane its own title back once another writer takes its document after a browser replay retitled it, so the tab never names a recording over Live content or a test replay's output (Cycle 0052 task 4, N51-V4); called by {@link presentApplication} and {@link presentNodeHistoryApplication}. A live run's lease title and a parked recording's title are left as they are. + * @example this.retitleApplicationAfterRecording() + */ + private retitleApplicationAfterRecording(): void { + if (this.app?.title === RECORDED_APPLICATION_PANE_TITLE) + this.app.title = APPLICATION_PANE_TITLE + } + /** * Shows a selected test replay in the Application pane as one document per history and posts each later moment into it (EN3), so moving through the replay never reloads the page and the learner's scroll position, text selection and focus survive; called by {@link presentApplication} for every render and refresh, and by {@link publish} for every published change, while a test replay is selected. * The document is written again only for another history or another pane; an unchanged view's generation posts nothing. A hidden pane's restore copy follows the latest view ({@link BrowserViewport.updateHistory}). @@ -15073,6 +15108,7 @@ export class StudyWorkspace { ]) // This runs inside catch and finally blocks and must not throw: the lease's panel can be disposed before its release is seen, and a write into it throws "Viewport lease is released" (F133). The pane is written again when it is next opened. try { + this.retitleApplicationAfterRecording() if (key !== this.lastApplicationKey) { this.lastApplicationKey = key this.shownNodeHistoryApplicationGeneration = view.generation @@ -18860,7 +18896,7 @@ export class StudyWorkspace { try { panel = vscode.window.createWebviewPanel( 'codebook.scopedBrowser', - 'codebook · Application', + APPLICATION_PANE_TITLE, { viewColumn: STUDY_PRESENTATIONS[this.presentationMode].appColumn, preserveFocus: true, diff --git a/extension/src/tests/studyWorkspace.test.ts b/extension/src/tests/studyWorkspace.test.ts index bb9d5217e..490f43995 100644 --- a/extension/src/tests/studyWorkspace.test.ts +++ b/extension/src/tests/studyWorkspace.test.ts @@ -1415,7 +1415,8 @@ test('returning to the current run leaves a selected Node history so its missing await harness.reader.action('return-current') // Assert - const view = harness.postedMessages().slice(-1)[0]! + // The Learning summary takes no Study message once the replay is left (Cycle 0052 task 2), so the bottom panel, which every publish reaches, shows the reader's state. + const view = harness.playbackPanelMessages.at(-1)! const applicationPanel = harness .nativePanels() .find((panel) => panel.kind === 'codebook.scopedBrowser') @@ -1486,8 +1487,9 @@ test('an agent returning the reader to the current run leaves a selected Node hi ) assert.equal(returned.reader.followingCurrentRun, true) assert.equal(returned.reader.contentTab, 'live') + // The Learning summary takes no Study message once the replay is left (Cycle 0052 task 2), so the bottom panel, which every publish reaches, shows the reader's state. assert.equal( - harness.postedMessages().slice(-1)[0]?.nodeHistory, + harness.playbackPanelMessages.at(-1)?.nodeHistory, undefined, 'the MCP return never reports following the run while the journal masks the application', ) diff --git a/extension/src/tests/studyWorkspaceTestReplayBrowser.test.ts b/extension/src/tests/studyWorkspaceTestReplayBrowser.test.ts index 4b5e1c790..e991966be 100644 --- a/extension/src/tests/studyWorkspaceTestReplayBrowser.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayBrowser.test.ts @@ -1019,3 +1019,70 @@ test('a rebuilt captured-source tab the editor refused to close at the first sto await harness.close() } }) + +// Value: protects=after a browser replay, the Application pane's tab names what it shows: a test replay opened afterwards titles it codebook · Application, not the recording's title; fails_when=the tab keeps codebook · Recorded application over the test output (N51-V4); why_new=Cycle 0052 task 4: only the recording and a live lease retitled the pane; seam=recordedReaderHarness, the native panel's title; no_claim=the native tab's look +test('a test replay opened after a browser replay titles the Application pane codebook · Application', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.openRecording(harness.studyId) + await harness.controller.settle() + const titleDuringRecording = harness + .nativePanels() + .find((panel) => panel.kind === 'codebook.scopedBrowser')?.title + + // Act: the one-replay-at-a-time notice is answered by closing the browser replay. + harness.answerWarningDialogs(async () => 'Close browser replay') + await harness.reader.action('retry') + await harness.reader.openLearningReplay(history.studyId) + await waitUntil( + () => + lastView(harness)?.nodeHistoryPosition?.sequence === 2 && + !lastView(harness)?.loading, + 'the test replay lands on its first stop', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.deepEqual( + { + titleDuringRecording, + titleDuringTestReplay: harness + .nativePanels() + .find((panel) => panel.kind === 'codebook.scopedBrowser')?.title, + }, + { + titleDuringRecording: 'codebook · Recorded application', + titleDuringTestReplay: 'codebook · Application', + }, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=Return to Live from a browser replay gives the Application pane its own title back; fails_when=the Live composition's pane keeps codebook · Recorded application (N51-V4); why_new=Cycle 0052 task 4; seam=recordedReaderHarness, the native panel's title; no_claim=the native tab's look +test('Return to Live from a browser replay titles the Application pane codebook · Application', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + await harness.reader.openRecording(harness.studyId) + await harness.controller.settle() + + // Act + await harness.reader.action('return-current') + + // Assert + assert.equal( + harness + .nativePanels() + .find((panel) => panel.kind === 'codebook.scopedBrowser')?.title, + 'codebook · Application', + ) + } finally { + await harness.close() + } +}) diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 0480efdb1..fb01064e8 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -11,6 +11,7 @@ import { import { StudyError } from '../errors' import { sha256 } from '../runtimeSupport/utils/sha256' import { waitUntil } from '../runtimeSupport/utils/waitUntil' +import type { StudyViewMessage } from '../ui/studyHtml' import { recordedReaderHarness, @@ -510,6 +511,92 @@ test('Return to Live from a test replay gives the Learning summary its placehold } }) +/** + * Records the families of the Study messages a view's webview receives from now on, by wrapping its `postMessage`; the departure tests read it to prove that no message of the wrong family reaches a view while the reader leaves a replay. + * @param view - The harness's Learning summary or bottom panel view. + * @returns The live list of each received Study message's `replayFamily` (`undefined` for a message outside a replay). + * @example const families = recordStudyFamilies(harness.summary) + */ +function recordStudyFamilies(view: { + webview: { postMessage: (message: StudyViewMessage) => Promise } +}): (StudyViewMessage['replayFamily'] | 'none')[] { + const families: (StudyViewMessage['replayFamily'] | 'none')[] = [], + post = view.webview.postMessage.bind(view.webview) + view.webview.postMessage = async (message: StudyViewMessage) => { + if (message.type === 'study') families.push(message.replayFamily ?? 'none') + return post(message) + } + return families +} + +// Value: protects=Return to Live from a test replay withdraws the Learning summary before the reader publishes the Live state, so the summary never draws the Live Study page beside the chat; fails_when=the summary receives any Study message after the return starts (N51 saw 1 to 3 frames of Live Study content); why_new=Cycle 0052 task 2 (N51-V2): retireNodeHistorySelection published while the summary still showed the replay's document; seam=the harness's summary webview postMessage; no_claim=the native frames, which N52 (e) checks +test('Return to Live from a test replay sends the Learning summary no Study message', async () => { + // Arrange + const harness = await workspaceHarness() + try { + await openByOpenReplay(harness) + const summaryFamilies = recordStudyFamilies(harness.summary) + + // Act + await harness.reader.action('return-current') + + // Assert + assert.deepEqual(summaryFamilies, []) + } finally { + await harness.close() + } +}) + +// Value: protects=Return to Live from a browser replay never names the test replay family while it leaves, so neither the bottom panel nor the summary draws a history-less test replay transport; fails_when=any Study message posted after the return starts carries replayFamily 'test' (N51 saw 1 to 2 frames of 'Test journal playback'); why_new=Cycle 0052 task 3 (N51-V3): shownReplayFamily read the absence of a recording as a test replay; seam=recordedReaderHarness, the summary's and bottom panel's postMessage; no_claim=the native frames, which N52 (e) checks +test('Return to Live from a browser replay never shows the test replay transport', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + await harness.reader.openRecording(harness.studyId) + await harness.controller.settle() + const summaryFamilies = recordStudyFamilies(harness.summary), + bottomPanelFamilies = recordStudyFamilies(harness.playbackPanel) + + // Act + await harness.reader.action('return-current') + + // Assert + assert.deepEqual( + [...summaryFamilies, ...bottomPanelFamilies].filter( + (family) => family === 'test', + ), + [], + ) + } finally { + await harness.close() + } +}) + +// Value: protects=a browser replay's Learning summary draws its replay from its first message: no Study message without the recording reaches it while the replay opens, and none reaches it while Return to Live leaves; fails_when=the summary receives a Study message with no recording (N51 saw 1 frame of Study content at the open and at the return); why_new=Cycle 0052 task 6 (N51-V6): the summary got the generic Study document and a publish inside open() before the presented bundle; seam=recordedReaderHarness, the summary webview's postMessage; no_claim=the native frames, which N52 (d) and (e) check +test('a browser replay’s Learning summary receives no Study message without its recording at the open or the return', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + const withoutRecording: string[] = [], + post = harness.summary.webview.postMessage.bind(harness.summary.webview) + harness.summary.webview.postMessage = async (message: StudyViewMessage) => { + if (message.type === 'study' && message.recording === undefined) + withoutRecording.push(message.replayFamily ?? 'none') + return post(message) + } + + // Act + await harness.reader.openRecording(harness.studyId) + await harness.controller.settle() + await harness.reader.action('return-current') + + // Assert + assert.deepEqual(withoutRecording, []) + } finally { + await harness.close() + } +}) + // Value: protects=a refused test replay move shows the host's sentence in the shared transport's status line, read with the summary hidden too, and leaves the committed moment; fails_when=the refusal reaches only the Study pane's error lines, or the moment changes; why_new=Cycle 0051 DS-G (AC14); seam=the projection's nodeHistoryRefusal; no_claim=the status line's rendering (Reader DOM) test('a refused Previous stop at the first stop shows its sentence for the transport and keeps the moment', async () => { // Arrange diff --git a/extension/src/tests/studyWorkspaceTestReplayOutput.test.ts b/extension/src/tests/studyWorkspaceTestReplayOutput.test.ts index d5f63cc21..192ca77fa 100644 --- a/extension/src/tests/studyWorkspaceTestReplayOutput.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayOutput.test.ts @@ -445,8 +445,9 @@ test('returning to Live releases the test replay’s output index and event posi // Act await harness.reader.action('return-current') + // The Learning summary takes no Study message once the replay is left (Cycle 0052 task 2), so the bottom panel, which every publish reaches, shows the reader's state. await waitUntil( - () => lastView(harness)?.nodeHistory === undefined, + () => harness.playbackPanelMessages.at(-1)?.nodeHistory === undefined, 'the reader leaves the test replay', READER_RESULT_BOUND_MS, ) diff --git a/extension/src/tests/studyWorkspaceTestReplaySource.test.ts b/extension/src/tests/studyWorkspaceTestReplaySource.test.ts index f4c1930be..60c510d3b 100644 --- a/extension/src/tests/studyWorkspaceTestReplaySource.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplaySource.test.ts @@ -1902,8 +1902,9 @@ test('returning to Live after a recorded-source slot failed to open leaves no fa await harness.reader.action('return-current') // Assert + // The Learning summary takes no Study message once the replay is left (Cycle 0052 task 2), so the bottom panel, which every publish reaches, shows the reader's state. assert.deepEqual(slotTabs(harness), []) - assert.equal(lastView(harness)?.errors.length, 0) + assert.equal(harness.playbackPanelMessages.at(-1)?.errors.length, 0) } finally { await harness.close() } @@ -3062,7 +3063,8 @@ test('retiring a replay publishes the removal of its slot failure row', async () await harness.reader['retireNodeHistorySelection']() // Assert - assert.equal(lastView(harness)?.errors.length, 0) + // The Learning summary takes no Study message once the replay is left (Cycle 0052 task 2), so the bottom panel, which every publish reaches, shows the reader's state. + assert.equal(harness.playbackPanelMessages.at(-1)?.errors.length, 0) } finally { await harness.close() } From db06b20a46c8883119e99d9d4416671f741d6522 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sat, 24 Oct 2026 22:54:08 +0900 Subject: [PATCH 02/30] fix: Close and Return to Live retire a waiting test replay entry, and a superseded Saved studies open gives the Study pane back (Cycle 0052 tasks 12, 13) - close(), the learner's Return to Live and an agent's Return bump nodeHistoryEntryRequest, so an Open replay entry still waiting for its turn neither asks to close the browser replay nor reopens a closed reader. - openNodeHistoryFromHost leaves an empty summary composition after a superseded open, as after a failed one; leaveEmptySummaryComposition keeps a newer replay that stands in it. Refs #124 --- extension/src/studyWorkspace.ts | 18 ++- ...tudyWorkspaceTestReplayComposition.test.ts | 149 ++++++++++++++++++ 2 files changed, 165 insertions(+), 2 deletions(-) diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 79fe1e618..719c818ef 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -1486,8 +1486,11 @@ export class StudyWorkspace { this.selection.tab !== 'live' || !this.liveComposition || !this.sourceReady() - ) + ) { + // A Return supersedes every test replay entry still waiting for its turn (Cycle 0052 task 12). + this.nodeHistoryEntryRequest++ await this.returnCurrentSelection(requester) + } const state = this.presentationState() // A closed Study only remembers the current run, and a Close while the Return ran ended the named reader: neither displays a source (row 697). const closedReaderAnswer = this.closedReaderReturnAnswer(lifetime, state) @@ -8912,7 +8915,11 @@ export class StudyWorkspace { } return } - if (action === 'return-current') await this.returnCurrentSelection() + if (action === 'return-current') { + // The learner's Return supersedes every test replay entry still waiting for its turn (Cycle 0052 task 12). + this.nodeHistoryEntryRequest++ + await this.returnCurrentSelection() + } if (action === 'source') { // A test replay's source is its recorded-source slot; the Study's own selection shows no article source (F156), so Focus source fronts the slot (F383). if (this.testReplayHoldsComposition()) await this.focusNodeSourceSlot() @@ -9479,6 +9486,8 @@ export class StudyWorkspace { this.presentationReason = undefined this.repositoryRecordingOpen = undefined this.recordingReloadStudyId = undefined + // A test replay entry still waiting for its turn belongs to the closed reader and never opens it again (Cycle 0052 task 12). + this.nodeHistoryEntryRequest++ // Closing disposes the native session: a Node stop still loading its values is abandoned, and no Run and Debug hint may survive a reopen. this.selectedNodeHistoryRequest++ // The cut-off selection no longer clears its requested moment (its token is gone), so the bump gives it back. @@ -11722,6 +11731,11 @@ export class StudyWorkspace { studyId, revision ?? this.revision, ) + if (outcome.status === 'superseded') { + // A superseded open leaves the summary composition it entered when no newer replay stands in it, as a failed open does (Cycle 0052 task 13). + await this.leaveEmptySummaryComposition() + return + } if (outcome.status !== 'failed') { // The learner's own replay open, after the replay is shown: the summary moves beside the chat once per profile (F209). await this.moveSummaryBesideChatOnce() diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index fb01064e8..9c6cf7c16 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -1242,3 +1242,152 @@ test('a test replay whose recorded-source tab stands in another group than the s await harness.close() } }) + +// Value: protects=Close retires every test replay entry still waiting for its turn, so a reader the learner closed is never reopened by an earlier Open replay; fails_when=dismissing a one-replay-at-a-time notice after Close lets the waiting entry open the closed reader (Codex P2 in Cycle 0051's /ship, N51 carried); why_new=Cycle 0052 task 12: neither Close nor Return to Live bumped the entry ticket; seam=recordedReaderHarness, answerWarningDialogs holding the first notice; no_claim=the native notification's own dismissal +test('Close retires a waiting Open replay entry, so dismissing its notice reopens 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> = [] + let noticesShown = 0 + harness.answerWarningDialogs(() => { + noticesShown++ + return answers.length === 0 + ? new Promise((resolve) => { + answers.push(resolve) + }) + : Promise.resolve(undefined) + }) + const firstEntry = harness.reader.openLearningReplay(first.studyId) + await waitUntil( + () => answers.length === 1, + 'the first notice is shown', + READER_RESULT_BOUND_MS, + ) + const secondEntry = harness.reader.openLearningReplay(second.studyId) + + // Act: the learner closes the reader, then dismisses the first notice. + await harness.reader.close() + answers[0]!(undefined) + await Promise.all([firstEntry, secondEntry]) + + // Assert + assert.deepEqual( + { + noticesShown, + opened: harness.reader.inspect().opened, + testReplay: harness.reader.inspect().view?.nodeHistory?.header.studyId, + }, + { noticesShown: 1, opened: false, testReplay: undefined }, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=Return to Live retires every test replay entry still waiting for its turn, so the learner's return is not undone by an earlier Open replay; fails_when=dismissing the notice after Return to Live lets the waiting entry enter the summary composition again; why_new=Cycle 0052 task 12; seam=recordedReaderHarness, answerWarningDialogs holding the first notice; no_claim=the native notification's own dismissal +test('Return to Live retires a waiting Open replay entry, so dismissing its notice opens no test replay', 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(() => + answers.length === 0 + ? new Promise((resolve) => { + answers.push(resolve) + }) + : Promise.resolve(undefined), + ) + const firstEntry = harness.reader.openLearningReplay(first.studyId) + await waitUntil( + () => answers.length === 1, + 'the first notice is shown', + READER_RESULT_BOUND_MS, + ) + const secondEntry = harness.reader.openLearningReplay(second.studyId) + + // Act: the learner returns to Live, then dismisses the first notice. + await harness.reader.action('return-current') + answers[0]!(undefined) + await Promise.all([firstEntry, secondEntry]) + + // Assert + assert.deepEqual( + { + testReplay: harness.reader.inspect().view?.nodeHistory?.header.studyId, + bottomPanelFamily: harness.playbackPanelMessages.at(-1)?.replayFamily, + }, + { testReplay: undefined, bottomPanelFamily: undefined }, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=a Saved studies choice of a test replay whose open a newer reader revision supersedes while it enters the summary composition, with no newer replay to show, gives the Study pane back instead of an empty summary composition; fails_when=the superseded open counts as success and the reader stays in a summary composition that shows no replay (Cycle 0051 /ship review); why_new=Cycle 0052 task 13: openNodeHistoryFromHost left the composition only after a failed open; seam=the reader's revision advanced once the entry stood in the composition, as a repository navigation or another reader change does meanwhile; no_claim=which change advances it natively +test('a Saved studies choice of a test replay superseded while it enters the composition gives the Study pane back', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.open() + const reader = harness.reader as unknown as { + enterSummaryCompositionForReplay: () => Promise + revision: number + } + const enter = reader.enterSummaryCompositionForReplay.bind(harness.reader) + let entered!: () => void + const standsInComposition = new Promise((resolve) => { + entered = resolve + }) + reader.enterSummaryCompositionForReplay = async (): Promise => { + await enter() + // Another reader change lands while the entry stands in the composition. + reader.revision++ + entered() + } + + // Act + harness.receiveContext({ + type: 'node-history', + revision: lastView(harness)!.revision, + studyId: history.studyId, + }) + await standsInComposition + await waitUntil( + () => + harness.panelColumn('codebook.studyContext') !== undefined && + harness.reader.inspect().view?.nodeHistory === undefined, + 'the Study pane comes back with no test replay selected', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.equal(harness.playbackPanelMessages.at(-1)?.replayFamily, undefined) + } finally { + await harness.close() + } +}) From b37912f443a990b93b37ea918da5af1bc87563e6 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sat, 24 Oct 2026 23:13:05 +0900 Subject: [PATCH 03/30] fix: replay entries and reloads keep the learner's view: Open replay shows a hidden summary, a maximized panel keeps a deferred study, a superseded reveal ends quietly, and a Reload keeps its summary (Cycle 0052 tasks 14, 15, 16, 18) - enterSummaryCompositionForReplay shows the Learning summary when the composition already stands, so Open replay with the summary and the bottom panel hidden shows the summary again (task 14). - A browser study chosen over a test replay with the editor area hidden keeps the summary composition and the retired slot's tabs until the area is back; showStudyAwaitingEditorArea closes the tabs and arranges then (task 15). - showLearningSummary ends without an error when a newer replay selection superseded its reveal (task 16). - forgetUnopenedComposition withdraws the summary a failed first entry gave its document; a same-study Reload marks itself until loadPlayback makes the recording active again, so another open() meanwhile keeps the summary composition and its document (task 18). Refs #124 --- extension/src/studyWorkspace.ts | 92 ++++++++--- ...tudyWorkspaceTestReplayComposition.test.ts | 156 ++++++++++++++++++ .../studyWorkspaceTestReplayOpens.test.ts | 87 ++++++++++ 3 files changed, 313 insertions(+), 22 deletions(-) diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 719c818ef..665534b02 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -840,12 +840,14 @@ export class StudyWorkspace { private nodeSourceSlotShownRequest = 0 /** How many times the recorded-source slot was retired ({@link retireNodeSourceSlot}); a show that began before a retirement is closed when it completes ({@link settleLateSlotShow}, F364). */ private nodeSourceSlotRetirements = 0 + /** Whether a retired test replay's recorded-source tabs were left open because the editor area was hidden when a saved study was chosen over it: a close of the tab a group shows brings the area back, so they close once the area is shown again or the wait for it ends ({@link closeSlotTabsAwaitingArea}, Cycle 0052 task 15). */ + private nodeSourceSlotTabsAwaitingArea = false private playback?: PlaybackController private replayDebugger?: ReplayDebugger /** The disposal of replaced historical debuggers that is still running; the editor accepts the next owner only once it has settled. */ private replayDebuggerRetirement?: Promise private recordingActive = false - /** The study whose same-study Reload is loading its recording again: from the Reload's leave until its load ends or a newer departure supersedes it ({@link leavePlayback}, {@link close}). While it is set the summary composition still shows that browser replay, so the Reload's loading, error and retry messages reach its Learning summary (Cycle 0052 tasks 3 and 18). */ + /** The study whose same-study Reload is loading its recording again: from the Reload's leave until {@link loadPlayback} makes the recording active again or a newer departure supersedes it ({@link leavePlayback}, {@link close}); a failed load keeps it. While it is set the summary composition still shows that browser replay, so the Reload's loading, error and retry messages reach its Learning summary (Cycle 0052 tasks 3 and 18). */ private recordingReloadStudyId?: string /** The captured-source tabs of the browser replay that were open when it was last left, with the address each had, held as the editor's own tab objects (F485): kept until a test replay's first slot show gets the editor to close them (a refused close keeps them for the next show, F484), or until a browser replay starts, the test replay's slot ends or the reader closes (F482). A tab the learner closes by hand leaves it ({@link forgetClosedRetiredBrowserReplayTabs}), so one the learner opens after the replay was left, even at a recorded address, is a different tab and is not in it; an editor that rebuilds its tab objects (Cursor, at a removed group) leaves entries no tab object matches, and those match by their address (F466, F474). */ private retiredBrowserReplayTabs?: ReadonlyMap @@ -8161,6 +8163,7 @@ export class StudyWorkspace { // A selected or opening test replay is read in the Study composition, whatever run is current (F208). const liveComposition = !this.recordingActive && + this.recordingReloadStudyId === undefined && !this.testReplayHoldsComposition() && this.selection.follow && this.selection.tab === 'live' && @@ -8171,7 +8174,9 @@ export class StudyWorkspace { ) // A browser recording and a selected or opening test replay share the summary composition (Cycle 0051, DS-A): the Learning summary stands in the chat's place and the Study pane closes. const summaryComposition = - this.recordingActive || this.testReplayHoldsComposition() + this.recordingActive || + this.recordingReloadStudyId !== undefined || + this.testReplayHoldsComposition() const compositionChanged = liveComposition !== this.liveComposition || summaryComposition !== this.summaryComposition @@ -8640,6 +8645,8 @@ export class StudyWorkspace { */ private forgetUnopenedComposition(): void { if (!this.hasComposition()) return + // A failed first entry withdraws the replay's document it may have given the Learning summary (Cycle 0052 task 18). + if (this.summaryComposition) this.clearLearningSummary() this.liveComposition = false this.summaryComposition = false this.layoutReceipt = undefined @@ -8905,14 +8912,9 @@ export class StudyWorkspace { throw new Error('Select a recording first.') // Same-study Reload keeps its Summary document so a failed read can publish recovery there. this.leavePlayback(true) + // The mark lasts until the reloaded recording is active again ({@link loadPlayback}) or a departure supersedes it; a failed load keeps it, so the summary keeps showing the recovery. this.recordingReloadStudyId = studyId - try { - await this.openRecording(studyId) - } finally { - // Only this Reload's own mark ends here; a newer departure already cleared it. - if (this.recordingReloadStudyId === studyId) - this.recordingReloadStudyId = undefined - } + await this.openRecording(studyId) return } if (action === 'return-current') { @@ -11854,8 +11856,11 @@ export class StudyWorkspace { this.summaryComposition && !this.liveComposition && !this.recordingActive - ) + ) { + // The composition stands already, but the learner may have hidden the summary and the bottom panel: the entry shows them again, so Open replay never ends with no visible replay (Cycle 0052 task 14). + await this.showLearningSummary(undefined, true) return + } this.hostOpeningTestReplay = true try { await this.open() @@ -12214,6 +12219,8 @@ export class StudyWorkspace { signal.throwIfAborted() } this.recordingActive = true + // The reloaded recording is active again: a same-study Reload's mark has served its turn. + this.recordingReloadStudyId = undefined // A browser replay starting ends any earlier record of tabs to close (F474). this.retiredBrowserReplayTabs = undefined this.pendingHistory = undefined @@ -12768,7 +12775,9 @@ export class StudyWorkspace { * await this.retireNodeHistorySelection() * ``` */ - private async retireNodeHistorySelection(): Promise { + private async retireNodeHistorySelection( + keepSlotTabsForHiddenArea = false, + ): Promise { // A test replay shown in the summary composition withdraws its summary before anything publishes, as a browser replay's departure does (Cycle 0052 task 2, N51-V2). if ( this.selectedNodeHistory && @@ -12803,7 +12812,7 @@ export class StudyWorkspace { this.errors.delete('history-position'), this.errors.delete('history-learning'), ].some(Boolean) - await this.retireNodeSourceSlot() + await this.retireNodeSourceSlot(keepSlotTabsForHiddenArea) if (removedRows) this.publish() } @@ -12812,7 +12821,9 @@ export class StudyWorkspace { * 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). * @example await this.retireNodeSourceSlot() */ - private async retireNodeSourceSlot(): Promise { + private async retireNodeSourceSlot( + keepTabsForHiddenArea = false, + ): Promise { const slot = this.nodeSourceSlot this.nodeSourceSlotRetirements++ // The retired replay's wait for the native views ends with it. @@ -12825,8 +12836,10 @@ export class StudyWorkspace { const removedRow = this.errors.delete('history-source') this.clearNodeSourceSlotDecorations() try { + // A saved study chosen with the editor area hidden keeps the slot's tabs, which read the placeholder below, until the area is back: closing the tab a group shows would bring the area back (Cycle 0052 task 15). + if (keepTabsForHiddenArea) this.nodeSourceSlotTabsAwaitingArea = true // The retired replay's recorded-source slot goes with it, with any slot tab a rename still opening or one that failed left. - await this.closeNodeSourceSlotTabs() + else await this.closeNodeSourceSlotTabs() } catch (error) { this.report( 'layout', @@ -13622,10 +13635,10 @@ export class StudyWorkspace { */ private shownReplayFamily(): 'browser' | 'test' | undefined { if (!this.summaryComposition) return undefined - if (this.recordingActive || this.recordingReloadStudyId !== undefined) - return 'browser' - // A reader that is leaving a replay shows neither family until open() leaves the composition, so no view draws a test replay transport without a history (Cycle 0052 task 3, N51-V3). - return this.testReplayHoldsComposition() ? 'test' : undefined + if (this.recordingActive) return 'browser' + if (this.testReplayHoldsComposition()) return 'test' + // A same-study Reload still shows its browser replay; a reader that is leaving a replay shows neither family until open() leaves the composition, so no view draws a test replay transport without a history (Cycle 0052 task 3, N51-V3). + return this.recordingReloadStudyId !== undefined ? 'browser' : undefined } /** Publishes a matching projection with stable DOM controls, excluding images from the context channel. @example this.publish() */ @@ -19373,6 +19386,15 @@ export class StudyWorkspace { this.publish() if (keptBehind) this.tellSummaryKeptBehindChat() else this.offerSummaryPlacement('summary-shown') + } catch (error) { + // A newer selection superseded this reveal: its own reveal answers the learner, so this one ends without an error (Cycle 0052 task 16). + if ( + error instanceof StudyError && + error.code === 'STALE_PRESENTATION' && + replay !== this.shownReplayIdentity() + ) + return + throw error } finally { focusSubscription.dispose() } @@ -23342,14 +23364,18 @@ export class StudyWorkspace { choice.hasReadArchive = true if (this.isStaleHistoryRead(request, message.revision)) return this.pendingHistory = undefined - // Browser selection retires the separate Node journal and its historical debugger context. - await this.retireNodeHistorySelection() + // Browser selection retires the separate Node journal and its historical debugger context; with the editor area hidden its slot tabs wait for the area, as the chosen study does (Cycle 0052 task 15). + await this.retireNodeHistorySelection( + Boolean(this.selectedNodeHistory) && + this.replayReaderOpen && + (await this.editorAreaHidden()), + ) // Closing a native preview can publish a new reader revision; the owned history request still controls this switch. if (!this.isLatestHistoryRequest(request)) return await this.showSavedStudy(archive) this.publishChoiceOfClosedReader(request) - // A browser study chosen from a test replay's summary leaves the summary composition for the Study composition (DS-A). - if (this.isLatestHistoryRequest(request)) + // A browser study chosen from a test replay's summary leaves the summary composition for the Study composition (DS-A); a study that waits for the hidden editor area leaves it once the area is shown, with its deferred arrangement (Cycle 0052 task 15). + if (this.isLatestHistoryRequest(request) && !this.studyAwaitsEditorArea()) await this.leaveEmptySummaryComposition() } @@ -24590,6 +24616,26 @@ export class StudyWorkspace { ) } + /** + * Closes the recorded-source tabs a test replay's retirement left open while the editor area was hidden, once that wait ends; called by {@link showStudyAwaitingEditorArea}. + * @returns Once the tabs are closed, or at once when none wait; a refused close is told on the layout line. + * @example await this.closeSlotTabsAwaitingArea() + */ + private async closeSlotTabsAwaitingArea(): Promise { + if (!this.nodeSourceSlotTabsAwaitingArea) return + this.nodeSourceSlotTabsAwaitingArea = false + try { + await this.closeNodeSourceSlotTabs() + } catch (error) { + this.report( + 'layout', + new Error( + `The previous recorded source tab could not close. Arrange the Codebook panes manually. ${String(error)}`, + ), + ) + } + } + /** Says whether the selected saved study still waits for the editor area the learner hid before choosing it; the reader's timer then leaves the panes as they are, while an explicit entry, selection or placement shows them. @example if (!this.studyAwaitsEditorArea()) await this.renderSelection(true, false, signal) */ private studyAwaitsEditorArea(): boolean { return ( @@ -24612,6 +24658,7 @@ export class StudyWorkspace { if (!awaited) return if (!this.studyAwaitsEditorArea() || !this.opened || this.closing) { this.studyAwaitingEditorArea = undefined + await this.closeSlotTabsAwaitingArea() return } if (this.presentationTransition || this.repositoryNavigation !== undefined) @@ -24640,6 +24687,7 @@ export class StudyWorkspace { if (this.studyAwaitingEditorArea !== awaited || this.closing) return this.studyAwaitingEditorArea = undefined try { + await this.closeSlotTabsAwaitingArea() if (awaited.arrange && this.needsReaderArrangement()) await this.open(signal) await this.renderSelection(true, true, signal) diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 9c6cf7c16..0d5c84f75 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -1391,3 +1391,159 @@ test('a Saved studies choice of a test replay superseded while it enters the com await harness.close() } }) + +// Value: protects=Open replay always ends with the Learning summary shown, also when the learner had hidden the summary and the bottom panel while another test replay was shown; fails_when=the entry finds the reader in the summary composition, skips its reveal and leaves no visible UI for the replay it opened (Cycle 0051 /ship review); why_new=Cycle 0052 task 14: enterSummaryCompositionForReplay returned early without showing the summary; seam=hideSummary and hidePlaybackPanel; no_claim=the native views' placement +test('Open replay with the Learning summary and the bottom panel hidden shows the summary again', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const second = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await openByOpenReplay(harness) + harness.hideSummary() + harness.hidePlaybackPanel() + + // Act + await harness.reader.openLearningReplay(second.studyId) + await waitUntil( + () => + lastView(harness)?.nodeHistory?.header.studyId === second.studyId && + !lastView(harness)?.loading, + 'the second replay is shown', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.equal(harness.summary.visible, true) + } finally { + await harness.close() + } +}) + +// Value: protects=a Learning summary reveal that a newer replay selection supersedes ends quietly: the learner's newer choice wins and no failure is told for the older reveal; fails_when=Show Learning Summary rejects with STALE_PRESENTATION ('The selected learning changed while opening its summary.') because the learner chose another replay while the reveal waited (Cycle 0051 /ship Red Team); why_new=Cycle 0052 task 16: assertCurrent threw out of the reveal on any identity change; seam=holdNextCommand on codebook.learningSummary.focus; no_claim=the native view's timing +test('a Learning summary reveal superseded by another replay’s open ends without an error', async () => { + // Arrange + const harness = await workspaceHarness() + let release = (): void => {} + try { + const second = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await openByOpenReplay(harness) + harness.hideSummary() + const held = harness.holdNextCommand('codebook.learningSummary.focus') + release = held.release + const reveal = harness.reader.showLearningSummary() + await held.started + + // Act: the learner opens another replay while the reveal waits for the view. + const secondEntry = harness.reader.openLearningReplay(second.studyId) + await waitUntil( + () => + harness.reader.inspect().view?.nodeHistory?.header.studyId === + second.studyId, + 'the second replay is selected', + READER_RESULT_BOUND_MS, + ) + held.release() + const revealOutcome = await reveal.then( + () => 'ended', + (error: unknown) => String(error), + ) + await secondEntry + + // Assert + assert.equal(revealOutcome, 'ended') + } finally { + release() + await harness.close() + } +}) + +// Value: protects=a first entry that fails after the Learning summary received the replay's document withdraws that document, so no summary with controls that answer nothing stays beside the chat on a closed reader; fails_when=forgetUnopenedComposition drops the composition but leaves the replay's document in the summary (Cycle 0051 /ship review); why_new=Cycle 0052 task 18; seam=the reader's summary placement offer made to fail once, after the summary got its document; no_claim=which native step fails after the reveal +test('a first Open replay that fails after the summary got its document leaves the summary placeholder', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + const reader = harness.reader as unknown as { + offerSummaryPlacement: (...args: unknown[]) => void + } + const offer = reader.offerSummaryPlacement.bind(harness.reader) + let failed = false + reader.offerSummaryPlacement = (...args: unknown[]): void => { + if (failed) return offer(...args) + failed = true + throw new Error('The editor refused the summary placement.') + } + + // Act + await harness.reader.openLearningReplay(history.studyId) + + // Assert + assert.deepEqual( + { + failed, + opened: harness.reader.inspect().opened, + placeholder: harness.summary.webview.html.includes( + 'Open saved learning from Codebook to view its summary.', + ), + }, + { failed: true, opened: false, placeholder: true }, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=a same-study Reload keeps its Learning summary while it loads, also when another open of the reader (Restore, Arrange) runs in that gap; fails_when=the other open sees no recording and no test replay, leaves the summary composition and blanks the summary the Reload kept for its loading and retry messages (Cycle 0051 /ship review); why_new=Cycle 0052 task 18; seam=the reload held at its debugger replacement, the reader's own open() during the hold; no_claim=which native action opens the reader in that gap +test('another open during a same-study Reload keeps the Reload’s Learning summary', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + await harness.reader.openRecording(harness.studyId) + await harness.controller.settle() + const reader = harness.reader as unknown as { + replaceUnavailableReplayDebugger: (signal?: AbortSignal) => Promise + } + const replace = reader.replaceUnavailableReplayDebugger.bind(harness.reader) + let release = (): void => {} + const held = new Promise((resolve) => { + release = resolve + }) + let entered = (): void => {} + const reloadWaits = new Promise((resolve) => { + entered = resolve + }) + reader.replaceUnavailableReplayDebugger = async ( + signal?: AbortSignal, + ): Promise => { + entered() + await held + return replace(signal) + } + + // Act + const reload = harness.reader.action('reload-recording') + await reloadWaits + await harness.reader.open() + const summaryDuringReload = harness.summary.webview.html + release() + await reload + await harness.controller.settle() + + // Assert + assert.equal( + summaryDuringReload.includes( + 'Open saved learning from Codebook to view its summary.', + ), + false, + ) + } finally { + await harness.close() + } +}) diff --git a/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts b/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts index 13aa8d54a..0f552766b 100644 --- a/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts @@ -6,6 +6,7 @@ import { test } from 'node:test' import { waitUntil } from '../runtimeSupport/utils/waitUntil' import { + readerSeams, recordedReaderHarness, workspaceHarness, } from './studyWorkspaceHarness' @@ -758,3 +759,89 @@ test('closing the reader waits for a reader-position write that is still running await harness.close() } }) + +// Value: protects=a saved browser study chosen in the bottom panel's Saved studies while a test replay is shown and the bottom panel is maximized waits for the editor area, as a choice from the Study pane does: the panel stays maximized and the chosen study is selected in it; fails_when=leaving the summary composition opens the Study pane at once, which brings the hidden editor area back and takes the panel out of its maximized state (Cycle 0051 /ship review); why_new=Cycle 0052 task 15: showChosenStudy left the summary composition past the studyAwaitingEditorArea deferral; seam=recordedReaderHarness with a maximized bottom panel, the bottom panel's history message; no_claim=the native editor's own restore of the area +test('a browser study chosen from a test replay with the bottom panel maximized keeps the panel maximized', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + await openReplayBesideBrowserStudy(harness) + harness.maximizePanel() + + // Act + harness.receivePlaybackPanel({ + type: 'history', + revision: lastView(harness)!.revision, + studyId: harness.studyId, + }) + await waitUntil( + () => + harness.reader.inspect().view?.nodeHistory === undefined && + harness.reader.inspect().view?.model.selection.studyId === + harness.studyId && + !harness.reader.inspect().view?.loading, + 'the browser study is chosen', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.deepEqual( + { + panelMaximized: harness.isPanelMaximized(), + studyPaneShown: + harness.panelColumn('codebook.studyContext') !== undefined, + }, + { panelMaximized: true, studyPaneShown: false }, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=once the learner restores the maximized bottom panel, the browser study chosen over a test replay shows in the Study composition and the retired replay's recorded-source tab closes; fails_when=the deferred arrangement never runs, or the slot tab stays open beside the study; why_new=Cycle 0052 task 15: the slot's close waits for the editor area with the chosen study; seam=recordedReaderHarness, restorePanel and the reader's refresh; no_claim=the native editor's own restore of the area +test('restoring the panel after a browser study was chosen from a maximized test replay shows the Study pane and closes the slot tab', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + await openReplayBesideBrowserStudy(harness) + harness.maximizePanel() + harness.receivePlaybackPanel({ + type: 'history', + revision: lastView(harness)!.revision, + studyId: harness.studyId, + }) + await waitUntil( + () => + harness.reader.inspect().view?.nodeHistory === undefined && + harness.reader.inspect().view?.model.selection.studyId === + harness.studyId && + !harness.reader.inspect().view?.loading, + 'the browser study is chosen', + READER_RESULT_BOUND_MS, + ) + + const slotTabCount = (): number => + harness.groups + .flatMap((group) => group.tabs) + .filter((tab) => + JSON.stringify(tab.input).includes('/node-history-slot/'), + ).length + const slotTabsWhileMaximized = slotTabCount() + + // Act + harness.restorePanel() + await readerSeams(harness).refresh() + await waitUntil( + () => + harness.panelColumn('codebook.studyContext') !== undefined && + slotTabCount() === 0, + 'the Study pane shows and the slot tab closes', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.equal(slotTabsWhileMaximized, 1) + } finally { + await harness.close() + } +}) From f028933b7b43b584406a693e68ea76e5e5306d10 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sat, 24 Oct 2026 23:24:05 +0900 Subject: [PATCH 04/30] test: a test replay opened from the Study composition leaves no empty editor group under VS Code's default closeEmptyGroups (Cycle 0052 task 24) The harness kept the group the Application pane's move emptied, because it removes moved-from groups only under closeEmptyGroupsAfterPanelMove; with VS Code's default modeled (closeDisposedPanelTabsLater and closeEmptyGroupsAfterPanelMove) the composition leaves none, as N51 saw natively. Refs #124 --- ...tudyWorkspaceTestReplayComposition.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 0d5c84f75..14781d38c 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -1547,3 +1547,39 @@ test('another open during a same-study Reload keeps the Reload’s Learning summ await harness.close() } }) + +// Value: protects=a test replay opened from the Study composition leaves no empty editor group behind when the Study pane closes, in an editor that removes emptied groups as VS Code does by default; fails_when=the composition keeps the Study pane's emptied group, or an empty group of its own; why_new=Cycle 0052 task 24 (Cycle 0051 /ship review: the harness never removed an emptied group, so one would go unnoticed); seam=closeDisposedPanelTabsLater and closeEmptyGroupsAfterPanelMove (closeEmptyGroups on, VS Code's default); no_claim=an editor with closeEmptyGroups off, which keeps emptied groups by the learner's setting +test('a test replay opened from the Study composition leaves no empty editor group when the editor closes emptied groups', async () => { + // Arrange + const harness = await workspaceHarness() + try { + // The editor removes a group its last tab left, by a close or by a move, as VS Code does with `workbench.editor.closeEmptyGroups` on (its default). + harness.closeDisposedPanelTabsLater() + harness.closeEmptyGroupsAfterPanelMove() + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.open() + + // Act + await harness.reader.openLearningReplay(history.studyId) + await landsOn(harness, 2, 'Open replay lands on the first stop') + await waitUntil( + () => harness.panelColumn('codebook.studyContext') === undefined, + 'the Study pane closed', + READER_RESULT_BOUND_MS, + ) + harness.closePendingDisposedPanelTabs() + + // Assert + assert.deepEqual( + harness.groups + .filter((group) => group.tabs.length === 0) + .map((group) => group.viewColumn), + [], + ) + } finally { + await harness.close() + } +}) From f554cc9a8c1ede35b459909244cc6e5b0f0759d2 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sat, 24 Oct 2026 23:24:54 +0900 Subject: [PATCH 05/30] docs: the README names pt1's replay exits, entries and reloads (Cycle 0052 tasks 2, 3, 4, 6, 12, 14, 15, 18) Refs #124 --- extension/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extension/README.md b/extension/README.md index 9c737fcb1..28c66775b 100644 --- a/extension/README.md +++ b/extension/README.md @@ -73,7 +73,7 @@ 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 the summary again if it is hidden. 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; open it again 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 open it again at the same address, also in an editor that rebuilds its tab objects, and a close that the editor refuses is told and tried again 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 opens the replay again. 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. 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. 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 presents it again. 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 the summary again if it is hidden. 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; open it again 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 open it again at the same address, also in an editor that rebuilds its tab objects, and a close that the editor refuses is told and tried again 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 opens the replay again. 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. 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, so it neither asks again nor reopens the reader. Open replay shows the Learning summary again if you had hidden it, also with the bottom panel hidden. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view and the transport never shows the other replay family's controls; after a browser replay the Application tab is named `codebook · Application` again. Reloading a browser replay keeps its Learning summary while the recording loads again. 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. 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 presents it again. 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. From d05340ae5f967ba7bd9466ad3aebbe5a4845356a Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sat, 24 Oct 2026 23:26:27 +0900 Subject: [PATCH 06/30] fix: the end of a deferred editor-area wait closes only the retired replay's slot tabs, and a slot close failure is reported in one place (Cycle 0052 task 15, OCR self-review) Refs #124 --- extension/src/studyWorkspace.ts | 33 +++++++----- .../studyWorkspaceTestReplayOpens.test.ts | 52 +++++++++++++++++++ 2 files changed, 72 insertions(+), 13 deletions(-) diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 665534b02..b6af0a989 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -9488,6 +9488,8 @@ export class StudyWorkspace { this.presentationReason = undefined this.repositoryRecordingOpen = undefined this.recordingReloadStudyId = undefined + // The reader's close closes its owned tabs, the slot's among them. + this.nodeSourceSlotTabsAwaitingArea = false // A test replay entry still waiting for its turn belongs to the closed reader and never opens it again (Cycle 0052 task 12). this.nodeHistoryEntryRequest++ // Closing disposes the native session: a Node stop still loading its values is abandoned, and no Run and Debug hint may survive a reopen. @@ -12841,12 +12843,7 @@ export class StudyWorkspace { // The retired replay's recorded-source slot goes with it, with any slot tab a rename still opening or one that failed left. else await this.closeNodeSourceSlotTabs() } catch (error) { - this.report( - 'layout', - new Error( - `The previous recorded source tab could not close. Arrange the Codebook panes manually. ${String(error)}`, - ), - ) + this.reportSlotCloseFailure(error) } // A slot tab that stays open (pinned) no longer holds the last stop's bytes: the document asks the provider again. const remaining = vscode.window.tabGroups.all @@ -24625,17 +24622,27 @@ export class StudyWorkspace { if (!this.nodeSourceSlotTabsAwaitingArea) return this.nodeSourceSlotTabsAwaitingArea = false try { - await this.closeNodeSourceSlotTabs() + // A test replay shown meanwhile keeps its own slot tab. + await this.closeNodeSourceSlotTabs(this.nodeSourceSlot?.uri) } catch (error) { - this.report( - 'layout', - new Error( - `The previous recorded source tab could not close. Arrange the Codebook panes manually. ${String(error)}`, - ), - ) + this.reportSlotCloseFailure(error) } } + /** + * Tells on the layout line that an earlier recorded-source tab could not close; called by {@link retireNodeSourceSlot} and {@link closeSlotTabsAwaitingArea}. + * @param error - What the editor's close threw. + * @example this.reportSlotCloseFailure(error) + */ + private reportSlotCloseFailure(error: unknown): void { + this.report( + 'layout', + new Error( + `The previous recorded source tab could not close. Arrange the Codebook panes manually. ${String(error)}`, + ), + ) + } + /** Says whether the selected saved study still waits for the editor area the learner hid before choosing it; the reader's timer then leaves the panes as they are, while an explicit entry, selection or placement shows them. @example if (!this.studyAwaitsEditorArea()) await this.renderSelection(true, false, signal) */ private studyAwaitsEditorArea(): boolean { return ( diff --git a/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts b/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts index 0f552766b..20c7224d3 100644 --- a/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts @@ -845,3 +845,55 @@ test('restoring the panel after a browser study was chosen from a maximized test await harness.close() } }) + +// Value: protects=a test replay opened after a browser study was chosen with the bottom panel maximized keeps its own recorded-source tab when the wait for the editor area ends; fails_when=the end of the wait closes every slot tab, the new replay's among them (OCR self-review of b37912f4); why_new=Cycle 0052 task 15's deferred slot close; seam=recordedReaderHarness with a maximized bottom panel, restorePanel and the reader's refresh; no_claim=the native editor's own restore of the area +test('a test replay opened while a chosen browser study waits for the editor area keeps its slot tab when the wait ends', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + const second = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await openReplayBesideBrowserStudy(harness) + harness.maximizePanel() + harness.receivePlaybackPanel({ + type: 'history', + revision: lastView(harness)!.revision, + studyId: harness.studyId, + }) + await waitUntil( + () => + harness.reader.inspect().view?.nodeHistory === undefined && + harness.reader.inspect().view?.model.selection.studyId === + harness.studyId && + !harness.reader.inspect().view?.loading, + 'the browser study is chosen', + READER_RESULT_BOUND_MS, + ) + + // Act + await harness.reader.openLearningReplay(second.studyId) + await waitUntil( + () => + harness.reader.inspect().view?.nodeHistoryPosition?.sequence === 2 && + !harness.reader.inspect().view?.loading, + 'the second test replay lands on its first stop', + READER_RESULT_BOUND_MS, + ) + harness.restorePanel() + await readerSeams(harness).refresh() + await readerSeams(harness).refresh() + + // Assert + assert.equal( + harness.groups + .flatMap((group) => group.tabs) + .filter((tab) => + JSON.stringify(tab.input).includes('/node-history-slot/'), + ).length, + 1, + ) + } finally { + await harness.close() + } +}) From de77903e37abc4c986228dd1a9912c507f4d68b7 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sat, 24 Oct 2026 23:31:57 +0900 Subject: [PATCH 07/30] fix: a deferred slot close closes only the retired replay's own tabs, an agent's Return retires a waiting entry also from Live, and two tests release their holds and observe the bottom panel (Cycle 0052, local CodeRabbit round 1) - nodeSourceSlotTabsAwaitingArea holds the retired replay's slot tab objects, not a flag (CodeRabbit major). - returnToCurrent bumps nodeHistoryEntryRequest before deciding whether the reader must leave anything (CodeRabbit major). - The Reload test releases its hold in finally; both departure tests assert that the bottom panel observed the return (CodeRabbit minor, trivial). Refs #124 --- extension/src/studyWorkspace.ts | 36 ++++++++++++------- ...tudyWorkspaceTestReplayComposition.test.ts | 12 ++++--- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index b6af0a989..cda1058bf 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -840,8 +840,8 @@ export class StudyWorkspace { private nodeSourceSlotShownRequest = 0 /** How many times the recorded-source slot was retired ({@link retireNodeSourceSlot}); a show that began before a retirement is closed when it completes ({@link settleLateSlotShow}, F364). */ private nodeSourceSlotRetirements = 0 - /** Whether a retired test replay's recorded-source tabs were left open because the editor area was hidden when a saved study was chosen over it: a close of the tab a group shows brings the area back, so they close once the area is shown again or the wait for it ends ({@link closeSlotTabsAwaitingArea}, Cycle 0052 task 15). */ - private nodeSourceSlotTabsAwaitingArea = false + /** The retired test replay's recorded-source tabs, held as the editor's tab objects, that were left open because the editor area was hidden when a saved study was chosen over it: a close of the tab a group shows brings the area back, so they close once the area is shown again or the wait for it ends ({@link closeSlotTabsAwaitingArea}, Cycle 0052 task 15). A slot tab of a replay opened later is never among them. */ + private nodeSourceSlotTabsAwaitingArea?: ReadonlySet private playback?: PlaybackController private replayDebugger?: ReplayDebugger /** The disposal of replaced historical debuggers that is still running; the editor accepts the next owner only once it has settled. */ @@ -1478,6 +1478,8 @@ export class StudyWorkspace { 'STALE_PRESENTATION', 'The reader content changed. Read current presentation state before returning to live execution.', ) + // A Return supersedes every test replay entry still waiting for its turn, also one that finds the reader in Live already (Cycle 0052 task 12). + this.nodeHistoryEntryRequest++ // A return can change the selection while native source rendering fails; report that partial state honestly. // A failed native source open leaves the selection current, so explicit retry must render it again. // A selected Node journal masks the live application even while the selection still follows the run. @@ -1488,11 +1490,8 @@ export class StudyWorkspace { this.selection.tab !== 'live' || !this.liveComposition || !this.sourceReady() - ) { - // A Return supersedes every test replay entry still waiting for its turn (Cycle 0052 task 12). - this.nodeHistoryEntryRequest++ + ) await this.returnCurrentSelection(requester) - } const state = this.presentationState() // A closed Study only remembers the current run, and a Close while the Return ran ended the named reader: neither displays a source (row 697). const closedReaderAnswer = this.closedReaderReturnAnswer(lifetime, state) @@ -9489,7 +9488,7 @@ export class StudyWorkspace { this.repositoryRecordingOpen = undefined this.recordingReloadStudyId = undefined // The reader's close closes its owned tabs, the slot's among them. - this.nodeSourceSlotTabsAwaitingArea = false + this.nodeSourceSlotTabsAwaitingArea = undefined // A test replay entry still waiting for its turn belongs to the closed reader and never opens it again (Cycle 0052 task 12). this.nodeHistoryEntryRequest++ // Closing disposes the native session: a Node stop still loading its values is abandoned, and no Run and Debug hint may survive a reopen. @@ -12839,7 +12838,17 @@ export class StudyWorkspace { this.clearNodeSourceSlotDecorations() try { // A saved study chosen with the editor area hidden keeps the slot's tabs, which read the placeholder below, until the area is back: closing the tab a group shows would bring the area back (Cycle 0052 task 15). - if (keepTabsForHiddenArea) this.nodeSourceSlotTabsAwaitingArea = true + if (keepTabsForHiddenArea) + this.nodeSourceSlotTabsAwaitingArea = new Set( + vscode.window.tabGroups.all + .flatMap((group) => group.tabs) + .filter( + (tab) => + tab.input instanceof vscode.TabInputText && + isNodeSourceSlotPath(tab.input.uri.path) && + this.ownsCapturedSourceTab(tab), + ), + ) // The retired replay's recorded-source slot goes with it, with any slot tab a rename still opening or one that failed left. else await this.closeNodeSourceSlotTabs() } catch (error) { @@ -24619,11 +24628,14 @@ export class StudyWorkspace { * @example await this.closeSlotTabsAwaitingArea() */ private async closeSlotTabsAwaitingArea(): Promise { - if (!this.nodeSourceSlotTabsAwaitingArea) return - this.nodeSourceSlotTabsAwaitingArea = false + const retired = this.nodeSourceSlotTabsAwaitingArea + if (!retired) return + this.nodeSourceSlotTabsAwaitingArea = undefined try { - // A test replay shown meanwhile keeps its own slot tab. - await this.closeNodeSourceSlotTabs(this.nodeSourceSlot?.uri) + // Only the retired replay's own tabs close; a test replay shown meanwhile keeps its slot tab. + await this.closeCapturedSourceTabs( + (uri, tab) => isNodeSourceSlotPath(uri.path) && retired.has(tab), + ) } catch (error) { this.reportSlotCloseFailure(error) } diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 14781d38c..a97b8a879 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -535,12 +535,14 @@ test('Return to Live from a test replay sends the Learning summary no Study mess const harness = await workspaceHarness() try { await openByOpenReplay(harness) - const summaryFamilies = recordStudyFamilies(harness.summary) + const summaryFamilies = recordStudyFamilies(harness.summary), + bottomPanelFamilies = recordStudyFamilies(harness.playbackPanel) // Act await harness.reader.action('return-current') - // Assert + // Assert: the bottom panel, which every publish reaches, observed the return. + assert.equal(bottomPanelFamilies.length > 0, true) assert.deepEqual(summaryFamilies, []) } finally { await harness.close() @@ -560,7 +562,8 @@ test('Return to Live from a browser replay never shows the test replay transport // Act await harness.reader.action('return-current') - // Assert + // Assert: the bottom panel, which every publish reaches, observed the return. + assert.equal(bottomPanelFamilies.length > 0, true) assert.deepEqual( [...summaryFamilies, ...bottomPanelFamilies].filter( (family) => family === 'test', @@ -1504,6 +1507,7 @@ test('a first Open replay that fails after the summary got its document leaves t test('another open during a same-study Reload keeps the Reload’s Learning summary', async () => { // Arrange const harness = await recordedReaderHarness(true) + let release = (): void => {} try { await harness.reader.openRecording(harness.studyId) await harness.controller.settle() @@ -1511,7 +1515,6 @@ test('another open during a same-study Reload keeps the Reload’s Learning summ replaceUnavailableReplayDebugger: (signal?: AbortSignal) => Promise } const replace = reader.replaceUnavailableReplayDebugger.bind(harness.reader) - let release = (): void => {} const held = new Promise((resolve) => { release = resolve }) @@ -1544,6 +1547,7 @@ test('another open during a same-study Reload keeps the Reload’s Learning summ false, ) } finally { + release() await harness.close() } }) From 60389aa01e6275bea64a27756d63459cbdee223b Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sat, 24 Oct 2026 23:42:31 +0900 Subject: [PATCH 08/30] fix: a superseded test replay entry leaves the empty summary composition it built, and a second hidden-area choice keeps the waiting slot tabs (Cycle 0052, local CodeRabbit round 2) - enterCompositionForEntry leaves the summary composition after a Return or Close superseded the entry while it entered, when no newer entry is under way (major). - showChosenStudy keeps the retired replay's slot tabs waiting for the hidden editor area also on a later choice; a normal retirement closes them with the slot (minor). Refs #124 --- extension/src/studyWorkspace.ts | 28 ++++++-- ...tudyWorkspaceTestReplayComposition.test.ts | 67 +++++++++++++++++++ .../studyWorkspaceTestReplayOpens.test.ts | 49 ++++++++++++++ 3 files changed, 137 insertions(+), 7 deletions(-) diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index cda1058bf..131140441 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -962,6 +962,8 @@ export class StudyWorkspace { private liveComposition = false /** Counts the learner's test replay entries ({@link openNodeHistoryFromHost}) in the order they were made, so the latest one wins also while an earlier one still enters the summary composition (EN19, Cycle 0051). */ private nodeHistoryEntryRequest = 0 + /** How many test replay entries ({@link openNodeHistoryFromHost}) are under way, the waiting ones included; a superseded entry leaves the empty summary composition it built only when no newer entry is under way to fill it (Cycle 0052 task 12). */ + private nodeHistoryEntriesUnderWay = 0 /** Settles when the test replay entry in front has entered its composition: entries take turns there, so two entries never run {@link open} at once (one would be refused as busy and leave no replay shown). */ private nodeHistoryEntryTurn: Promise = Promise.resolve() /** `true` while {@link openNodeHistoryFromHost} enters the summary composition for a test replay that has not begun opening ({@link testReplayHoldsComposition}). */ @@ -11723,10 +11725,12 @@ export class StudyWorkspace { this.nodeHistoryEntryTurn = new Promise((resolve) => { releaseTurn = resolve }) + this.nodeHistoryEntriesUnderWay++ try { await previousTurn if (!(await this.enterCompositionForEntry(studyId, entry))) return } finally { + this.nodeHistoryEntriesUnderWay-- releaseTurn() } if (entry !== this.nodeHistoryEntryRequest) return @@ -11779,7 +11783,11 @@ export class StudyWorkspace { ) return false } - return entry === this.nodeHistoryEntryRequest + if (entry === this.nodeHistoryEntryRequest) return true + // A Return or a Close superseded this entry while it entered: with no newer entry under way to fill it, the summary composition it built is left (Cycle 0052 task 12). + if (this.nodeHistoryEntriesUnderWay === 1) + await this.leaveEmptySummaryComposition() + return false } /** @@ -12839,8 +12847,10 @@ export class StudyWorkspace { try { // A saved study chosen with the editor area hidden keeps the slot's tabs, which read the placeholder below, until the area is back: closing the tab a group shows would bring the area back (Cycle 0052 task 15). if (keepTabsForHiddenArea) - this.nodeSourceSlotTabsAwaitingArea = new Set( - vscode.window.tabGroups.all + // A later choice made while the area is still hidden keeps the earlier wait's tabs waiting too. + this.nodeSourceSlotTabsAwaitingArea = new Set([ + ...(this.nodeSourceSlotTabsAwaitingArea ?? []), + ...vscode.window.tabGroups.all .flatMap((group) => group.tabs) .filter( (tab) => @@ -12848,9 +12858,12 @@ export class StudyWorkspace { isNodeSourceSlotPath(tab.input.uri.path) && this.ownsCapturedSourceTab(tab), ), - ) - // The retired replay's recorded-source slot goes with it, with any slot tab a rename still opening or one that failed left. - else await this.closeNodeSourceSlotTabs() + ]) + else { + // The retired replay's recorded-source slot goes with it, with any slot tab a rename still opening or one that failed left; tabs that waited for the area go too. + this.nodeSourceSlotTabsAwaitingArea = undefined + await this.closeNodeSourceSlotTabs() + } } catch (error) { this.reportSlotCloseFailure(error) } @@ -23372,7 +23385,8 @@ export class StudyWorkspace { this.pendingHistory = undefined // Browser selection retires the separate Node journal and its historical debugger context; with the editor area hidden its slot tabs wait for the area, as the chosen study does (Cycle 0052 task 15). await this.retireNodeHistorySelection( - Boolean(this.selectedNodeHistory) && + (Boolean(this.selectedNodeHistory) || + this.nodeSourceSlotTabsAwaitingArea !== undefined) && this.replayReaderOpen && (await this.editorAreaHidden()), ) diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index a97b8a879..9b59fdf0f 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -1,10 +1,12 @@ import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' import { appendFile } from 'node:fs/promises' import { join } from 'node:path' import { test } from 'node:test' import { NODE_TEST_HISTORY_DIRECTORY, + REPOSITORY_BROWSER_PROFILE as PROFILE_ID, STUDY_REMOVAL_REFUSALS, SUMMARY_PLACEMENT_OFFER, } from '../constants' @@ -1587,3 +1589,68 @@ test('a test replay opened from the Study composition leaves no empty editor gro await harness.close() } }) + +// Value: protects=an agent's Return to Live that supersedes a test replay entry already entering the summary composition, while the reader shows Live with its source ready, leaves no empty summary composition behind; fails_when=the superseded entry's open builds the summary composition and the entry ends without leaving it, so the summary shows a replay document with no replay (local CodeRabbit round 2, major); why_new=Cycle 0052 task 12; seam=the entry held at enterSummaryCompositionForReplay, a configured live run, the agent's returnToCurrent; no_claim=the native timing of the agent's call +test('an agent’s Return to Live that supersedes an entering test replay leaves no empty summary composition', async () => { + // Arrange + const harness = await workspaceHarness() + let release = (): void => {} + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + const receipt = harness.service.start({ + instanceId: harness.service.instanceId, + profile: PROFILE_ID, + configuration: harness.configuration, + key: randomUUID(), + }) + await harness.service.record(receipt).preparation + await harness.reader.open() + const reader = harness.reader as unknown as { + enterSummaryCompositionForReplay: () => Promise + } + const enter = reader.enterSummaryCompositionForReplay.bind(harness.reader) + const held = new Promise((resolve) => { + release = resolve + }) + let entered = (): void => {} + const entering = new Promise((resolve) => { + entered = resolve + }) + reader.enterSummaryCompositionForReplay = async (): Promise => { + entered() + await held + return enter() + } + + // Act + const entry = harness.reader.openLearningReplay(history.studyId) + await entering + const state = harness.reader.presentationState() + await harness.reader.returnToCurrent( + { + presentationId: state.presentationId, + expectedRevision: state.reader.contentRevision, + }, + 'agent', + ) + release() + await entry + + // Assert + assert.deepEqual( + { + testReplay: harness.reader.inspect().view?.nodeHistory?.header.studyId, + summaryPlaceholder: harness.summary.webview.html.includes( + 'Open saved learning from Codebook to view its summary.', + ), + }, + { testReplay: undefined, summaryPlaceholder: true }, + ) + } finally { + release() + await harness.close() + } +}) diff --git a/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts b/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts index 20c7224d3..f8dab1e53 100644 --- a/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' import { writeFile } from 'node:fs/promises' import { join } from 'node:path' import { test } from 'node:test' @@ -10,6 +11,7 @@ import { recordedReaderHarness, workspaceHarness, } from './studyWorkspaceHarness' +import { createConfiguredStudy } from './utils/createConfiguredStudy' import { READER_RESULT_BOUND_MS } from './utils/readerResultBound' import { lastView, saveTwoStopNodeHistory } from './utils/twoStopNodeHistory' @@ -897,3 +899,50 @@ test('a test replay opened while a chosen browser study waits for the editor are await harness.close() } }) + +// Value: protects=a second Saved studies choice made while the first still waits for the hidden editor area keeps the retired replay's slot tabs waiting too, so the maximized bottom panel stays maximized; fails_when=the second choice's retirement closes the waiting slot tabs, which brings the editor area back (local CodeRabbit round 2, minor); why_new=Cycle 0052 task 15; seam=recordedReaderHarness with a maximized bottom panel, two history messages from the bottom panel; no_claim=the native editor's own restore of the area +test('a second Saved studies choice while the first waits for the editor area keeps the panel maximized', async () => { + // Arrange: a second saved browser study stands in Saved studies beside the first. + const harness = await recordedReaderHarness(true) + try { + const library = await harness.reader.learningStorage(), + second = createConfiguredStudy(), + runId = randomUUID() + for (const observation of second.observations) + observation.snapshotUri = observation.snapshotUri.replace( + second.runId, + runId, + ) + second.workspaceKey = library.workspaceKey + second.runId = runId + second.id = `${second.instanceId}_${runId}` + second.savedAt = '2026-09-20T00:00:01.000Z' + await library.saveStudy(second) + await openReplayBesideBrowserStudy(harness) + harness.maximizePanel() + const chooseSavedStudy = async (studyId: string): Promise => { + harness.receivePlaybackPanel({ + type: 'history', + revision: harness.reader.inspect().view!.revision, + studyId, + }) + await waitUntil( + () => + harness.reader.inspect().view?.nodeHistory === undefined && + harness.reader.inspect().view?.model.selection.studyId === studyId && + !harness.reader.inspect().view?.loading, + 'the saved study is chosen', + READER_RESULT_BOUND_MS, + ) + } + await chooseSavedStudy(harness.studyId) + + // Act + await chooseSavedStudy(second.id) + + // Assert + assert.equal(harness.isPanelMaximized(), true) + } finally { + await harness.close() + } +}) From addf2e2096f3de77f5da28ab6f256d315b134757 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sat, 24 Oct 2026 23:59:13 +0900 Subject: [PATCH 09/30] fix: the waiting slot record survives Cursor's rebuilt tab objects, a test replay entry ends a saved study's wait for the editor area, and an overtaken Saved studies choice retires nothing (Cycle 0052, local CodeRabbit round 3) - nodeSourceSlotTabsAwaitingArea maps each retired slot tab to its address; a rebuilt tab object matches by address (minor). - enterCompositionForEntry ends a saved study's wait for the hidden editor area and the slot tabs waiting with it (minor; not reproducible in the harness, whose reveal brings the area back first: the guard test passes before and after). - showChosenStudy re-checks its history request after the editor-area read, before retiring (minor). - The superseded-entry test asserts that the reader published after the supersession (trivial, two findings). Refs #124 --- extension/src/studyWorkspace.ts | 45 ++++-- ...tudyWorkspaceTestReplayComposition.test.ts | 10 +- .../studyWorkspaceTestReplayOpens.test.ts | 143 ++++++++++++++++++ 3 files changed, 182 insertions(+), 16 deletions(-) diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 131140441..2a99ac9a5 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -840,8 +840,8 @@ export class StudyWorkspace { private nodeSourceSlotShownRequest = 0 /** How many times the recorded-source slot was retired ({@link retireNodeSourceSlot}); a show that began before a retirement is closed when it completes ({@link settleLateSlotShow}, F364). */ private nodeSourceSlotRetirements = 0 - /** The retired test replay's recorded-source tabs, held as the editor's tab objects, that were left open because the editor area was hidden when a saved study was chosen over it: a close of the tab a group shows brings the area back, so they close once the area is shown again or the wait for it ends ({@link closeSlotTabsAwaitingArea}, Cycle 0052 task 15). A slot tab of a replay opened later is never among them. */ - private nodeSourceSlotTabsAwaitingArea?: ReadonlySet + /** The retired test replay's recorded-source tabs, held as the editor's tab objects with the address each had, that were left open because the editor area was hidden when a saved study was chosen over it: a close of the tab a group shows brings the area back, so they close once the area is shown again or the wait for it ends ({@link closeSlotTabsAwaitingArea}, Cycle 0052 task 15). A tab object the editor rebuilt (Cursor) matches by its address; a test replay entry ends the record, so a slot tab of a replay opened later is never among them. */ + private nodeSourceSlotTabsAwaitingArea?: ReadonlyMap private playback?: PlaybackController private replayDebugger?: ReplayDebugger /** The disposal of replaced historical debuggers that is still running; the editor accepts the next owner only once it has settled. */ @@ -11767,6 +11767,9 @@ export class StudyWorkspace { entry: number, ): Promise { if (entry !== this.nodeHistoryEntryRequest) return false + // The learner's test replay supersedes a saved study that waits for the hidden editor area, and the record of slot tabs waiting with it, so its own slot tab is never closed as an earlier one's (Cycle 0052 task 15). + this.studyAwaitingEditorArea = undefined + this.nodeSourceSlotTabsAwaitingArea = undefined if (!(await this.closeBrowserReplayForTestReplay(entry))) return false if (entry !== this.nodeHistoryEntryRequest) return false try { @@ -12848,15 +12851,16 @@ export class StudyWorkspace { // A saved study chosen with the editor area hidden keeps the slot's tabs, which read the placeholder below, until the area is back: closing the tab a group shows would bring the area back (Cycle 0052 task 15). if (keepTabsForHiddenArea) // A later choice made while the area is still hidden keeps the earlier wait's tabs waiting too. - this.nodeSourceSlotTabsAwaitingArea = new Set([ + this.nodeSourceSlotTabsAwaitingArea = new Map([ ...(this.nodeSourceSlotTabsAwaitingArea ?? []), ...vscode.window.tabGroups.all .flatMap((group) => group.tabs) - .filter( - (tab) => - tab.input instanceof vscode.TabInputText && - isNodeSourceSlotPath(tab.input.uri.path) && - this.ownsCapturedSourceTab(tab), + .flatMap((tab): [vscode.Tab, string][] => + tab.input instanceof vscode.TabInputText && + isNodeSourceSlotPath(tab.input.uri.path) && + this.ownsCapturedSourceTab(tab) + ? [[tab, tab.input.uri.toString()]] + : [], ), ]) else { @@ -23384,12 +23388,14 @@ export class StudyWorkspace { if (this.isStaleHistoryRead(request, message.revision)) return this.pendingHistory = undefined // Browser selection retires the separate Node journal and its historical debugger context; with the editor area hidden its slot tabs wait for the area, as the chosen study does (Cycle 0052 task 15). - await this.retireNodeHistorySelection( + const keepSlotTabs = (Boolean(this.selectedNodeHistory) || this.nodeSourceSlotTabsAwaitingArea !== undefined) && - this.replayReaderOpen && - (await this.editorAreaHidden()), - ) + this.replayReaderOpen && + (await this.editorAreaHidden()) + // A newer choice made during the area read owns the reader: this one retires nothing. + if (!this.isLatestHistoryRequest(request)) return + await this.retireNodeHistorySelection(keepSlotTabs) // Closing a native preview can publish a new reader revision; the owned history request still controls this switch. if (!this.isLatestHistoryRequest(request)) return await this.showSavedStudy(archive) @@ -24645,10 +24651,21 @@ export class StudyWorkspace { const retired = this.nodeSourceSlotTabsAwaitingArea if (!retired) return this.nodeSourceSlotTabsAwaitingArea = undefined + const live = new Set( + vscode.window.tabGroups.all.flatMap((group) => group.tabs), + ) + // A tab object the editor rebuilt meanwhile (Cursor) is in no entry: its address stands for it. + const replacedAddresses = new Set( + [...retired] + .filter(([tab]) => !live.has(tab)) + .map(([, address]) => address), + ) try { - // Only the retired replay's own tabs close; a test replay shown meanwhile keeps its slot tab. + // Only the retired replay's own tabs close. await this.closeCapturedSourceTabs( - (uri, tab) => isNodeSourceSlotPath(uri.path) && retired.has(tab), + (uri, tab) => + isNodeSourceSlotPath(uri.path) && + (retired.has(tab) || replacedAddresses.has(uri.toString())), ) } catch (error) { this.reportSlotCloseFailure(error) diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 9b59fdf0f..2ad280e7e 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -1365,6 +1365,7 @@ test('a Saved studies choice of a test replay superseded while it enters the com } const enter = reader.enterSummaryCompositionForReplay.bind(harness.reader) let entered!: () => void + let messagesAtSupersession = 0 const standsInComposition = new Promise((resolve) => { entered = resolve }) @@ -1372,6 +1373,7 @@ test('a Saved studies choice of a test replay superseded while it enters the com await enter() // Another reader change lands while the entry stands in the composition. reader.revision++ + messagesAtSupersession = harness.playbackPanelMessages.length entered() } @@ -1390,8 +1392,12 @@ test('a Saved studies choice of a test replay superseded while it enters the com READER_RESULT_BOUND_MS, ) - // Assert - assert.equal(harness.playbackPanelMessages.at(-1)?.replayFamily, undefined) + // Assert: the reader published again after the supersession, and its last message names no replay family. + const publishedAfter = harness.playbackPanelMessages.slice( + messagesAtSupersession, + ) + assert.equal(publishedAfter.length > 0, true) + assert.equal(publishedAfter.at(-1)?.replayFamily, undefined) } finally { await harness.close() } diff --git a/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts b/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts index f8dab1e53..460669f5b 100644 --- a/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts @@ -946,3 +946,146 @@ test('a second Saved studies choice while the first waits for the editor area ke await harness.close() } }) + +/** + * Opens a saved test replay beside a saved browser study, maximizes the bottom panel and chooses the browser study there, so the study waits for the hidden editor area with the replay's slot tab; the round-3 tests of Cycle 0052 task 15 start from it. + * @param harness - The recorded reader harness. + * @returns The test replay's header. + * @example const history = await chooseBrowserStudyWithPanelMaximized(harness) + */ +async function chooseBrowserStudyWithPanelMaximized( + harness: Awaited>, +) { + const history = await openReplayBesideBrowserStudy(harness) + harness.maximizePanel() + harness.receivePlaybackPanel({ + type: 'history', + revision: lastView(harness)!.revision, + studyId: harness.studyId, + }) + await waitUntil( + () => + harness.reader.inspect().view?.nodeHistory === undefined && + harness.reader.inspect().view?.model.selection.studyId === + harness.studyId && + !harness.reader.inspect().view?.loading, + 'the browser study is chosen', + READER_RESULT_BOUND_MS, + ) + return history +} + +/** + * Counts the recorded-source slot tabs in every group. + * @param harness - The recorded reader harness. + * @returns How many slot tabs stand open. + * @example slotTabCount(harness) // => 1 + */ +function slotTabCount( + harness: Awaited>, +): number { + return harness.groups + .flatMap((group) => group.tabs) + .filter((tab) => JSON.stringify(tab.input).includes('/node-history-slot/')) + .length +} + +// Value: protects=the same test replay opened again while a chosen browser study waits for the editor area keeps its reused slot tab when the panel is restored; fails_when=the end of the wait closes the reused tab as the earlier wait's (local CodeRabbit round 3, minor); why_new=Cycle 0052 task 15; seam=recordedReaderHarness with a maximized bottom panel, restorePanel and the reader's refresh; no_claim=the native editor's own restore of the area +test('the same test replay opened again while a chosen browser study waits keeps its slot tab when the panel is restored', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + const history = await chooseBrowserStudyWithPanelMaximized(harness) + + // Act + await harness.reader.openLearningReplay(history.studyId) + await waitUntil( + () => + harness.reader.inspect().view?.nodeHistory?.header.studyId === + history.studyId && !harness.reader.inspect().view?.loading, + 'the test replay is shown again', + READER_RESULT_BOUND_MS, + ) + harness.restorePanel() + await readerSeams(harness).refresh() + await readerSeams(harness).refresh() + + // Assert + assert.equal(slotTabCount(harness), 1) + } finally { + await harness.close() + } +}) + +// Value: protects=the retired replay's waiting slot tab closes when the panel is restored also after Cursor rebuilt the tab objects meanwhile; fails_when=the record holds only the old tab objects, so nothing matches and the slot tab stays beside the chosen study (local CodeRabbit round 3, minor); why_new=Cycle 0052 task 15; seam=rebuildTabObjectsNow (Cursor's whole new tab model), restorePanel and the reader's refresh; no_claim=Cursor's own timing of the rebuild +test('a waiting slot tab closes on the panel’s restore also after Cursor rebuilt the tab objects', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + harness.useHostAppName('Cursor') + await chooseBrowserStudyWithPanelMaximized(harness) + const slotTabsWhileWaiting = slotTabCount(harness) + harness.rebuildTabObjectsNow() + + // Act + harness.restorePanel() + await readerSeams(harness).refresh() + await waitUntil( + () => harness.panelColumn('codebook.studyContext') !== undefined, + 'the Study pane shows', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.deepEqual( + { slotTabsWhileWaiting, slotTabsAfter: slotTabCount(harness) }, + { slotTabsWhileWaiting: 1, slotTabsAfter: 0 }, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=a Saved studies choice that a newer test replay entry overtakes while it reads whether the editor area is hidden retires nothing: the newer replay and its debugger stay; fails_when=the old choice retires the replay the learner opened during its read (local CodeRabbit round 3, minor); why_new=Cycle 0052 task 15's area read before the retirement; seam=holdNextCommand on vscode.getEditorLayout; no_claim=the native editor's answer time +test('a Saved studies choice overtaken during its editor-area read retires nothing', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + let release = (): void => {} + try { + await openReplayBesideBrowserStudy(harness) + const second = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + const held = harness.holdNextCommand('vscode.getEditorLayout') + release = held.release + harness.receivePlaybackPanel({ + type: 'history', + revision: lastView(harness)!.revision, + studyId: harness.studyId, + }) + await held.started + + // Act: the learner opens another test replay while the choice reads the layout. + const entry = harness.reader.openLearningReplay(second.studyId) + await waitUntil( + () => + harness.reader.inspect().view?.nodeHistory?.header.studyId === + second.studyId && !harness.reader.inspect().view?.loading, + 'the second test replay is shown', + READER_RESULT_BOUND_MS, + ) + held.release() + await entry + await readerSeams(harness).refresh() + + // Assert + assert.equal( + harness.reader.inspect().view?.nodeHistory?.header.studyId, + second.studyId, + ) + } finally { + release() + await harness.close() + } +}) From 2bba14bc385658cdff2472badc9cf93041c91bbc Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 00:12:31 +0900 Subject: [PATCH 10/30] fix: slot tabs that waited for the hidden editor area close on every way the wait ends, and a tab the learner closes leaves the record (Cycle 0052, local CodeRabbit round 4) - A test replay entry closes the waiting slot tabs before its own slot shows, so a failed open leaves none behind (minor). - An explicit display that ends the wait (renderSelection) closes them too (minor). - The tab-close listener prunes the waiting record as it prunes the retired browser replay's, through the new pruneClosedTabRecord helper, so a slot tab the learner reopens at its address survives the end of the wait (minor). Refs #124 --- extension/src/studyWorkspace.ts | 40 ++++---- .../src/tests/pruneClosedTabRecord.test.ts | 55 +++++++++++ .../studyWorkspaceTestReplayOpens.test.ts | 93 +++++++++++++++++++ extension/src/utils/pruneClosedTabRecord.ts | 30 ++++++ 4 files changed, 200 insertions(+), 18 deletions(-) create mode 100644 extension/src/tests/pruneClosedTabRecord.test.ts create mode 100644 extension/src/utils/pruneClosedTabRecord.ts diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 2a99ac9a5..ccd79a666 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -313,6 +313,7 @@ import { } from './utils/projectNodeTestApplication' import { projectNodeTestSection } from './utils/projectNodeTestSection' import { projectPlaybackStudy } from './utils/projectPlaybackStudy' +import { pruneClosedTabRecord } from './utils/pruneClosedTabRecord' import { readNodeTestOutputReport } from './utils/readNodeTestOutputReport' import { type OpenRequestSettled, @@ -11767,9 +11768,9 @@ export class StudyWorkspace { entry: number, ): Promise { if (entry !== this.nodeHistoryEntryRequest) return false - // The learner's test replay supersedes a saved study that waits for the hidden editor area, and the record of slot tabs waiting with it, so its own slot tab is never closed as an earlier one's (Cycle 0052 task 15). + // The learner's test replay supersedes a saved study that waits for the hidden editor area: the slot tabs that waited with it close now, before its own slot shows, so its tab is never closed as an earlier one's and a failed open leaves none behind (Cycle 0052 task 15). this.studyAwaitingEditorArea = undefined - this.nodeSourceSlotTabsAwaitingArea = undefined + await this.closeSlotTabsAwaitingArea() if (!(await this.closeBrowserReplayForTestReplay(entry))) return false if (entry !== this.nodeHistoryEntryRequest) return false try { @@ -13805,8 +13806,9 @@ export class StudyWorkspace { } // An explicit source request revokes pending chat returns before source I/O can yield. if (!preserveFocus) this.keyboardOutsideEditors = undefined - // An explicit entry, selection or placement shows the panes now; a study that waited for the editor area waits no longer. + // An explicit entry, selection or placement shows the panes now; a study that waited for the editor area waits no longer, nor do the slot tabs that waited with it (Cycle 0052 task 15). this.studyAwaitingEditorArea = undefined + await this.closeSlotTabsAwaitingArea() const lifetime = this.lifetime await this.refreshCitations() // Citation I/O can outlive Close or a later Open; it cannot create a new rendering revision afterward. @@ -25920,8 +25922,8 @@ export class StudyWorkspace { private forgetClosedRetiredBrowserReplayTabs( closed: readonly vscode.Tab[], ): void { - const retired = this.retiredBrowserReplayTabs - if (!retired) return + if (!this.retiredBrowserReplayTabs && !this.nodeSourceSlotTabsAwaitingArea) + return const live = new Set( vscode.window.tabGroups.all.flatMap((group) => group.tabs), ) @@ -25929,19 +25931,21 @@ export class StudyWorkspace { tab.input instanceof vscode.TabInputText ? tab.input.uri.toString() : undefined - const liveAddresses = new Set([...live].map(addressOf)) - const kept = new Map(retired) - for (const tab of closed) { - if (kept.delete(tab)) continue - // A tab object the editor rebuilt (Cursor) is in no entry: the learner closing it by hand ends the entry its address stands for, unless another tab still shows that address (F494). - const address = addressOf(tab) - if (address === undefined || liveAddresses.has(address)) continue - const stale = [...kept].find( - ([entry, entryAddress]) => entryAddress === address && !live.has(entry), - ) - if (stale) kept.delete(stale[0]) - } - if (kept.size !== retired.size) this.retiredBrowserReplayTabs = kept + // A tab the learner closes by hand leaves each record, also as a tab object Cursor rebuilt (F494); a waiting slot tab closed so never takes one opened later at its address (Cycle 0052 task 15). + if (this.retiredBrowserReplayTabs) + this.retiredBrowserReplayTabs = pruneClosedTabRecord( + this.retiredBrowserReplayTabs, + closed, + live, + addressOf, + ) + if (this.nodeSourceSlotTabsAwaitingArea) + this.nodeSourceSlotTabsAwaitingArea = pruneClosedTabRecord( + this.nodeSourceSlotTabsAwaitingArea, + closed, + live, + addressOf, + ) } /** diff --git a/extension/src/tests/pruneClosedTabRecord.test.ts b/extension/src/tests/pruneClosedTabRecord.test.ts new file mode 100644 index 000000000..9f63fc904 --- /dev/null +++ b/extension/src/tests/pruneClosedTabRecord.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { pruneClosedTabRecord } from '../utils/pruneClosedTabRecord' + +type FakeTab = { address?: string } + +const addressOf = (tab: FakeTab): string | undefined => tab.address + +// Value: protects=a closed tab object ending its own entry in a record of tabs; fails_when=the entry stays and a later close by address picks a tab opened afterwards; why_new=Cycle 0052 task 15 (shared by the retired browser replay's tabs and the slot tabs waiting for the editor area); seam=none; no_claim=the editor's tab events +test('a closed tab object leaves the record', () => { + // Arrange + const tab: FakeTab = { address: 'codebook-source://key/a' } + const record = new Map([[tab, 'codebook-source://key/a']]) + + // Act + const pruned = pruneClosedTabRecord(record, [tab], new Set(), addressOf) + + // Assert + assert.deepEqual([...pruned], []) +}) + +// Value: protects=a tab object Cursor rebuilt and the learner then closed ending the entry its address stands for (F494); fails_when=the rebuilt object matches no entry and the old entry stays; why_new=Cycle 0052 task 15; seam=none; no_claim=Cursor's rebuild itself +test('a closed rebuilt tab ends the entry of its address when no live tab shows that address', () => { + // Arrange + const recorded: FakeTab = { address: 'codebook-source://key/a' } + const rebuilt: FakeTab = { address: 'codebook-source://key/a' } + const record = new Map([[recorded, 'codebook-source://key/a']]) + + // Act + const pruned = pruneClosedTabRecord(record, [rebuilt], new Set(), addressOf) + + // Assert + assert.deepEqual([...pruned], []) +}) + +// Value: protects=a closed rebuilt tab leaving the entry in place while another live tab still shows its address; fails_when=closing one of two tabs at an address ends the record of the other; why_new=Cycle 0052 task 15; seam=none; no_claim=none beyond protects +test('a closed rebuilt tab keeps the entry while another live tab shows the address', () => { + // Arrange + const recorded: FakeTab = { address: 'codebook-source://key/a' } + const rebuilt: FakeTab = { address: 'codebook-source://key/a' } + const stillShown: FakeTab = { address: 'codebook-source://key/a' } + const record = new Map([[recorded, 'codebook-source://key/a']]) + + // Act + const pruned = pruneClosedTabRecord( + record, + [rebuilt], + new Set([stillShown]), + addressOf, + ) + + // Assert + assert.equal(pruned, record) +}) diff --git a/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts b/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts index 460669f5b..a98411b84 100644 --- a/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts @@ -1089,3 +1089,96 @@ test('a Saved studies choice overtaken during its editor-area read retires nothi await harness.close() } }) + +// Value: protects=a test replay entry made while a chosen browser study waits for the hidden editor area closes the slot tabs that waited with it, also when the new replay's own slot never shows; fails_when=the entry drops the record of the waiting tabs, so a failed open leaves the earlier replay's slot tab open for good (local CodeRabbit round 4, minor); why_new=Cycle 0052 task 15; seam=failCurrentSourceOpen (the new replay's slot cannot open); no_claim=the native failure's cause +test('a test replay entry whose slot fails to show still closes the slot tab that waited for the editor area', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + const second = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await chooseBrowserStudyWithPanelMaximized(harness) + const slotTabsWhileWaiting = slotTabCount(harness) + harness.failCurrentSourceOpen() + + // Act + await harness.reader.openLearningReplay(second.studyId) + await waitUntil( + () => + harness.reader.inspect().view?.nodeHistory?.header.studyId === + second.studyId && !harness.reader.inspect().view?.loading, + 'the second test replay is selected', + READER_RESULT_BOUND_MS, + ) + harness.restoreCurrentSourceOpen() + + // Assert + assert.deepEqual( + { slotTabsWhileWaiting, slotTabsAfter: slotTabCount(harness) }, + { slotTabsWhileWaiting: 1, slotTabsAfter: 0 }, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=an explicit display (Focus source) that ends a chosen browser study's wait for the hidden editor area closes the slot tabs that waited with it; fails_when=renderSelection ends the wait and the waiting slot tab stays open beside the shown study (local CodeRabbit round 4, minor); why_new=Cycle 0052 task 15; seam=the reader's source action; no_claim=the native editor's own restore of the area +test('an explicit display that ends the wait for the editor area closes the slot tab that waited', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + await chooseBrowserStudyWithPanelMaximized(harness) + const slotTabsWhileWaiting = slotTabCount(harness) + + // Act + await harness.reader.action('source') + + // Assert + assert.deepEqual( + { slotTabsWhileWaiting, slotTabsAfter: slotTabCount(harness) }, + { slotTabsWhileWaiting: 1, slotTabsAfter: 0 }, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=a slot tab the learner closes by hand after Cursor rebuilt the tab objects, and opens again at its address, survives the end of the wait for the editor area; fails_when=the record keeps the closed tab's address and the end of the wait closes the tab the learner opened (local CodeRabbit round 4, minor); why_new=Cycle 0052 task 15; seam=rebuildTabObjectsNow, closeTabByHand, restoreLeftoverCapturedSource at the slot's path, restorePanel and the reader's refresh; no_claim=the native tab strip +test('a waiting slot tab the learner closes after Cursor rebuilt the tabs and opens again survives the end of the wait', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + harness.useHostAppName('Cursor') + await chooseBrowserStudyWithPanelMaximized(harness) + harness.rebuildTabObjectsNow() + const rebuiltSlotTab = harness.groups + .flatMap((group) => group.tabs) + .find((tab) => JSON.stringify(tab.input).includes('/node-history-slot/')) + assert.ok(rebuiltSlotTab) + const slotPath = (rebuiltSlotTab.input as { uri: { path: string } }).uri + .path + harness.closeTabByHand(rebuiltSlotTab) + const reopenedTab = harness.restoreLeftoverCapturedSource({ + active: false, + path: slotPath, + }) + + // Act + harness.restorePanel() + await readerSeams(harness).refresh() + await waitUntil( + () => harness.panelColumn('codebook.studyContext') !== undefined, + 'the Study pane shows', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.equal( + harness.groups.flatMap((group) => group.tabs).includes(reopenedTab), + true, + ) + } finally { + await harness.close() + } +}) diff --git a/extension/src/utils/pruneClosedTabRecord.ts b/extension/src/utils/pruneClosedTabRecord.ts new file mode 100644 index 000000000..77d8e68fe --- /dev/null +++ b/extension/src/utils/pruneClosedTabRecord.ts @@ -0,0 +1,30 @@ +/** + * Drops the entries of a record of tabs that the editor closed, so a later close by address never picks a tab opened afterwards at the same address; read by the reader's tab-close listener for each record it keeps (the retired browser replay's captured-source tabs, the slot tabs waiting for the hidden editor area). + * A closed tab that is an entry's own object ends that entry. A closed tab object the editor rebuilt (Cursor) is in no entry: it ends the entry its address stands for, unless another live tab still shows that address (F494). + * @param record - The record: each tab object with the address it had. + * @param closed - The tabs the editor reports closed. + * @param live - Every tab object the editor shows now. + * @param addressOf - The address of a tab, `undefined` for a tab without one. + * @returns The record without the closed tabs' entries; the same record object when nothing changed. + * @example pruneClosedTabRecord(new Map([[tab, 'file:///a']]), [tab], new Set(), addressOf) // => Map(0) {} + */ +export function pruneClosedTabRecord( + record: ReadonlyMap, + closed: readonly Tab[], + live: ReadonlySet, + addressOf: (tab: Tab) => string | undefined, +): ReadonlyMap { + const liveAddresses = new Set([...live].map(addressOf)) + const kept = new Map(record) + for (const tab of closed) { + if (kept.delete(tab)) continue + const address = addressOf(tab) + // A tab still shown at that address keeps the entry: only its last tab's close ends it. + if (address === undefined || liveAddresses.has(address)) continue + const stale = [...kept].find( + ([entry, entryAddress]) => entryAddress === address && !live.has(entry), + ) + if (stale) kept.delete(stale[0]) + } + return kept.size === record.size ? record : kept +} From 3c57d9a617891320f0be407831b1b0a90f77d9e1 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 00:14:08 +0900 Subject: [PATCH 11/30] refactor: one helper names a tab record's rebuilt addresses, removing the clone the dupes gate found (Cycle 0052 task 15) Refs #124 --- extension/src/studyWorkspace.ts | 21 +++++++------------ .../src/tests/pruneClosedTabRecord.test.ts | 18 ++++++++++++++++ extension/src/utils/replacedTabAddresses.ts | 15 +++++++++++++ 3 files changed, 40 insertions(+), 14 deletions(-) create mode 100644 extension/src/utils/replacedTabAddresses.ts diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index ccd79a666..bd381d6a0 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -327,6 +327,7 @@ import { recordedLeftoverGroups, } from './utils/recordedLeftoverGroups' import { recordingNotShownReason } from './utils/recordingNotShownReason' +import { replacedTabAddresses } from './utils/replacedTabAddresses' import { retainEditorReadingPosition } from './utils/retainEditorReadingPosition' import { sanitizeCause } from './utils/sanitizeCause' import { sanitizedStack } from './utils/sanitizedStack' @@ -24653,14 +24654,10 @@ export class StudyWorkspace { const retired = this.nodeSourceSlotTabsAwaitingArea if (!retired) return this.nodeSourceSlotTabsAwaitingArea = undefined - const live = new Set( - vscode.window.tabGroups.all.flatMap((group) => group.tabs), - ) // A tab object the editor rebuilt meanwhile (Cursor) is in no entry: its address stands for it. - const replacedAddresses = new Set( - [...retired] - .filter(([tab]) => !live.has(tab)) - .map(([, address]) => address), + const replacedAddresses = replacedTabAddresses( + retired, + new Set(vscode.window.tabGroups.all.flatMap((group) => group.tabs)), ) try { // Only the retired replay's own tabs close. @@ -25863,14 +25860,10 @@ export class StudyWorkspace { private async closeRetiredBrowserReplaySourceTabs(): Promise { const retired = this.retiredBrowserReplayTabs if (!retired) return - const live = new Set( - vscode.window.tabGroups.all.flatMap((group) => group.tabs), - ) // A tab object the editor replaced is no longer live; its address stands for it, unless the learner closed that tab (its entry is gone then). - const replacedAddresses = new Set( - [...retired] - .filter(([tab]) => !live.has(tab)) - .map(([, address]) => address), + const replacedAddresses = replacedTabAddresses( + retired, + new Set(vscode.window.tabGroups.all.flatMap((group) => group.tabs)), ) await this.closeCapturedSourceTabs( (uri, tab) => diff --git a/extension/src/tests/pruneClosedTabRecord.test.ts b/extension/src/tests/pruneClosedTabRecord.test.ts index 9f63fc904..8e41f1de0 100644 --- a/extension/src/tests/pruneClosedTabRecord.test.ts +++ b/extension/src/tests/pruneClosedTabRecord.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { pruneClosedTabRecord } from '../utils/pruneClosedTabRecord' +import { replacedTabAddresses } from '../utils/replacedTabAddresses' type FakeTab = { address?: string } @@ -53,3 +54,20 @@ test('a closed rebuilt tab keeps the entry while another live tab shows the addr // Assert assert.equal(pruned, record) }) + +// Value: protects=the address of a recorded tab object the editor rebuilt standing for that tab when the record's tabs are closed; fails_when=a rebuilt tab matches nothing and stays open; why_new=Cycle 0052 task 15 (replacedTabAddresses, shared by both records); seam=none; no_claim=Cursor's rebuild itself +test('a recorded tab object the editor no longer shows gives its address', () => { + // Arrange + const shown: FakeTab = { address: 'codebook-source://key/a' } + const rebuilt: FakeTab = { address: 'codebook-source://key/b' } + const record = new Map([ + [shown, 'codebook-source://key/a'], + [rebuilt, 'codebook-source://key/b'], + ]) + + // Act + const addresses = replacedTabAddresses(record, new Set([shown])) + + // Assert + assert.deepEqual([...addresses], ['codebook-source://key/b']) +}) diff --git a/extension/src/utils/replacedTabAddresses.ts b/extension/src/utils/replacedTabAddresses.ts new file mode 100644 index 000000000..c4a6b0eab --- /dev/null +++ b/extension/src/utils/replacedTabAddresses.ts @@ -0,0 +1,15 @@ +/** + * Names the addresses whose recorded tab objects the editor no longer shows: a tab object the editor rebuilt (Cursor) is in no entry of the record, so its address stands for it when the record's tabs are closed; read by the reader before it closes the retired browser replay's captured-source tabs and the slot tabs that waited for the hidden editor area. + * @param record - The record: each tab object with the address it had. + * @param live - Every tab object the editor shows now. + * @returns The addresses of the entries whose tab object is not live. + * @example replacedTabAddresses(new Map([[oldTab, 'file:///a']]), new Set([newTab])) // => Set { 'file:///a' } + */ +export function replacedTabAddresses( + record: ReadonlyMap, + live: ReadonlySet, +): ReadonlySet { + return new Set( + [...record].filter(([tab]) => !live.has(tab)).map(([, address]) => address), + ) +} From 6ba0dc3993b39aa9b05f52efff5023a29c8d2688 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 12:22:21 +0900 Subject: [PATCH 12/30] fix: a same-study Reload still loading counts as the shown browser replay, and leaving it cancels its pending load (Cycle 0052, local CodeRabbit round 5) A test replay chosen during the Reload asks to close the browser replay first; the close (and any explicit departure) runs PlaybackController.leave, whose abort ends the pending load before it can retire the test replay (major, two findings). Refs #124 --- extension/src/studyWorkspace.ts | 22 +++++-- ...tudyWorkspaceTestReplayComposition.test.ts | 61 +++++++++++++++++++ 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index bd381d6a0..77e7c9d06 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -11805,7 +11805,10 @@ export class StudyWorkspace { private async closeBrowserReplayForTestReplay( entry: number, ): Promise { - if (!this.recordingActive) return true + // A same-study Reload still loading is the browser replay shown (Cycle 0052 task 18). + const browserReplayShown = (): boolean => + this.recordingActive || this.recordingReloadStudyId !== undefined + if (!browserReplayShown()) return true const studyId = this.selection.studyId, revision = this.playback?.state().revision const refusal = describeNodeLearningSave({ kind: 'open-refused' }) @@ -11818,13 +11821,13 @@ export class StudyWorkspace { if ( action !== NODE_LEARNING_SAVE_ACTION.closeBrowserReplay || entry !== this.nodeHistoryEntryRequest || - !this.recordingActive || + !browserReplayShown() || this.selection.studyId !== studyId || this.playback?.state().revision !== revision ) return false await this.returnCurrentSelection('learner') - return !this.recordingActive + return !browserReplayShown() } /** @@ -12758,10 +12761,18 @@ export class StudyWorkspace { */ private leavePlayback(preserveSummary = false): void { // An explicit departure supersedes a same-study Reload that is still loading. + const reloading = this.recordingReloadStudyId !== undefined if (!preserveSummary) this.recordingReloadStudyId = undefined this.nodeHistoryPlaybackController?.pause() this.repositoryRecordingOpen = undefined - if (!this.recordingActive) return + if (!this.recordingActive) { + // The Reload's pending load is cancelled with the departure, so it never comes back over the reader's next choice (Cycle 0052 task 18). + if (reloading && !preserveSummary) { + this.playback?.leave() + this.clearLearningSummary() + } + return + } // The captured-source tabs it has now are closed once, when a test replay's slot first shows over them; tabs opened later are not (F466, F474). this.retiredBrowserReplayTabs = this.browserReplaySourceTabs( this.playback?.state().studyId ?? this.sourceView?.studyId, @@ -23189,7 +23200,8 @@ export class StudyWorkspace { ) this.publish() if (message.type === 'node-history') { - if (this.recordingActive) + // A same-study Reload still loading counts as the browser replay shown (Cycle 0052 task 18). + if (this.recordingActive || this.recordingReloadStudyId !== undefined) throw new Error('Close browser replay before opening a test replay.') // A Saved studies choice is the learner's entry: it enters the summary composition and tells a failure as Open replay does (DS-A, DS-G). await this.openNodeHistoryFromHost(message.studyId, message.revision) diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 2ad280e7e..f531b43e3 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -1660,3 +1660,64 @@ test('an agent’s Return to Live that supersedes an entering test replay leaves await harness.close() } }) + +// Value: protects=a test replay opened while a same-study Reload of a browser replay still loads asks to close that browser replay first, and closing it cancels the pending load, so the load never comes back over the test replay; fails_when=the entry treats the reloading replay as gone, opens the test replay, and the resumed load retires it for the browser replay (local CodeRabbit round 5, major); why_new=Cycle 0052 task 18's Reload mark; seam=the reload held at its debugger replacement, answerWarningDialogs choosing Close browser replay; no_claim=the native notification +test('a test replay opened during a same-study Reload closes the reloading browser replay and stays shown', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + let release = (): void => {} + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.openRecording(harness.studyId) + await harness.controller.settle() + const reader = harness.reader as unknown as { + replaceUnavailableReplayDebugger: (signal?: AbortSignal) => Promise + } + const replace = reader.replaceUnavailableReplayDebugger.bind(harness.reader) + const held = new Promise((resolve) => { + release = resolve + }) + let entered = (): void => {} + const reloadWaits = new Promise((resolve) => { + entered = resolve + }) + reader.replaceUnavailableReplayDebugger = async ( + signal?: AbortSignal, + ): Promise => { + entered() + await held + return replace(signal) + } + let noticesShown = 0 + harness.answerWarningDialogs(async () => { + noticesShown++ + return 'Close browser replay' + }) + const reload = harness.reader.action('reload-recording') + await reloadWaits + + // Act: the learner opens the test replay while the Reload still loads; the load goes on afterwards. + const entry = harness.reader.openLearningReplay(history.studyId) + await new Promise((resolve) => setImmediate(resolve)) + release() + await Promise.all([entry, reload]) + await harness.controller.settle() + await landsOn(harness, 2, 'the test replay stays on its first stop') + + // Assert + assert.deepEqual( + { + noticesShown, + testReplay: harness.reader.inspect().view?.nodeHistory?.header.studyId, + recording: harness.controller.state().studyId, + }, + { noticesShown: 1, testReplay: history.studyId, recording: undefined }, + ) + } finally { + release() + await harness.close() + } +}) From 498ed55487089a9cc426e8ae419111439de38e6a Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 12:36:47 +0900 Subject: [PATCH 13/30] fix: a same-study Reload keeps the recording on every surface during refreshes, and a closed rebuilt tab ends every stale entry of its address (Cycle 0052, local CodeRabbit round 6) - refreshPresentation renders nothing while a same-study Reload loads, so the Application pane never shows Live or Study content beside the reloading browser replay's summary (major). - pruneClosedTabRecord ends every entry of a closed tab's address whose object the editor no longer shows, when no live tab shows that address (minor). - The Reload-entry test waits for the one-replay-at-a-time notice instead of one setImmediate; the editor-area tests use the shared helpers (trivial, two findings). Refs #124 --- extension/src/studyWorkspace.ts | 2 + .../src/tests/pruneClosedTabRecord.test.ts | 23 ++++++++ ...tudyWorkspaceTestReplayComposition.test.ts | 54 ++++++++++++++++++- .../studyWorkspaceTestReplayOpens.test.ts | 54 ++----------------- extension/src/utils/pruneClosedTabRecord.ts | 13 +++-- 5 files changed, 89 insertions(+), 57 deletions(-) diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 77e7c9d06..265314508 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -10820,6 +10820,8 @@ export class StudyWorkspace { if (this.selectedNodeHistory !== undefined) return 'publish' if ( !this.recordingActive && + // A same-study Reload keeps the recording it loads on every surface until its load ends (Cycle 0052 task 18). + this.recordingReloadStudyId === undefined && this.repositoryNavigation === undefined && !this.presentationTransition && // A saved study chosen with the editor area hidden is shown by showStudyAwaitingEditorArea once the area is back. diff --git a/extension/src/tests/pruneClosedTabRecord.test.ts b/extension/src/tests/pruneClosedTabRecord.test.ts index 8e41f1de0..342ee4896 100644 --- a/extension/src/tests/pruneClosedTabRecord.test.ts +++ b/extension/src/tests/pruneClosedTabRecord.test.ts @@ -71,3 +71,26 @@ test('a recorded tab object the editor no longer shows gives its address', () => // Assert assert.deepEqual([...addresses], ['codebook-source://key/b']) }) + +// Value: protects=a closed rebuilt tab ending every stale entry of its address, so an entry the record took twice for one address (once per rebuilt object) never survives to close a tab opened later there; fails_when=only one of two stale entries of the address is removed (local CodeRabbit round 6, minor); why_new=Cycle 0052 task 15; seam=none; no_claim=Cursor's rebuild itself +test('a closed rebuilt tab ends every stale entry of its address', () => { + // Arrange + const first: FakeTab = { address: 'codebook-source://key/a' } + const second: FakeTab = { address: 'codebook-source://key/a' } + const closedRebuilt: FakeTab = { address: 'codebook-source://key/a' } + const record = new Map([ + [first, 'codebook-source://key/a'], + [second, 'codebook-source://key/a'], + ]) + + // Act + const pruned = pruneClosedTabRecord( + record, + [closedRebuilt], + new Set(), + addressOf, + ) + + // Assert + assert.deepEqual([...pruned], []) +}) diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index f531b43e3..ec995b991 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -16,6 +16,7 @@ import { waitUntil } from '../runtimeSupport/utils/waitUntil' import type { StudyViewMessage } from '../ui/studyHtml' import { + readerSeams, recordedReaderHarness, workspaceHarness, } from './studyWorkspaceHarness' @@ -1701,7 +1702,11 @@ test('a test replay opened during a same-study Reload closes the reloading brows // Act: the learner opens the test replay while the Reload still loads; the load goes on afterwards. const entry = harness.reader.openLearningReplay(history.studyId) - await new Promise((resolve) => setImmediate(resolve)) + await waitUntil( + () => noticesShown === 1, + 'the one-replay-at-a-time notice is shown', + READER_RESULT_BOUND_MS, + ) release() await Promise.all([entry, reload]) await harness.controller.settle() @@ -1721,3 +1726,50 @@ test('a test replay opened during a same-study Reload closes the reloading brows await harness.close() } }) + +// Value: protects=the reader's periodic refresh during a same-study Reload leaves the Application pane on the recording it reloads: no Live or Study content is drawn there while the summary still shows the browser replay; fails_when=refreshPresentation renders the selection during the Reload and presentApplication writes non-recording content into the pane (local CodeRabbit round 6, major); why_new=Cycle 0052 task 18's Reload mark; seam=the reload held at its debugger replacement, the reader's refresh; no_claim=the native pane's pixels +test('a refresh during a same-study Reload keeps the Application pane on the recording', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + let release = (): void => {} + try { + await harness.reader.openRecording(harness.studyId) + await harness.controller.settle() + const reader = harness.reader as unknown as { + replaceUnavailableReplayDebugger: (signal?: AbortSignal) => Promise + } + const replace = reader.replaceUnavailableReplayDebugger.bind(harness.reader) + const held = new Promise((resolve) => { + release = resolve + }) + let entered = (): void => {} + const reloadWaits = new Promise((resolve) => { + entered = resolve + }) + reader.replaceUnavailableReplayDebugger = async ( + signal?: AbortSignal, + ): Promise => { + entered() + await held + return replace(signal) + } + const application = harness + .nativePanels() + .find((panel) => panel.kind === 'codebook.scopedBrowser')! + const reload = harness.reader.action('reload-recording') + await reloadWaits + const documentBeforeRefresh = application.webview.html + + // Act + await readerSeams(harness).refresh() + const documentAfterRefresh = application.webview.html + release() + await reload + + // Assert + assert.equal(documentAfterRefresh, documentBeforeRefresh) + } finally { + release() + await harness.close() + } +}) diff --git a/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts b/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts index a98411b84..73d091173 100644 --- a/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts @@ -805,30 +805,8 @@ test('restoring the panel after a browser study was chosen from a maximized test // Arrange const harness = await recordedReaderHarness(true) try { - await openReplayBesideBrowserStudy(harness) - harness.maximizePanel() - harness.receivePlaybackPanel({ - type: 'history', - revision: lastView(harness)!.revision, - studyId: harness.studyId, - }) - await waitUntil( - () => - harness.reader.inspect().view?.nodeHistory === undefined && - harness.reader.inspect().view?.model.selection.studyId === - harness.studyId && - !harness.reader.inspect().view?.loading, - 'the browser study is chosen', - READER_RESULT_BOUND_MS, - ) - - const slotTabCount = (): number => - harness.groups - .flatMap((group) => group.tabs) - .filter((tab) => - JSON.stringify(tab.input).includes('/node-history-slot/'), - ).length - const slotTabsWhileMaximized = slotTabCount() + await chooseBrowserStudyWithPanelMaximized(harness) + const slotTabsWhileMaximized = slotTabCount(harness) // Act harness.restorePanel() @@ -836,7 +814,7 @@ test('restoring the panel after a browser study was chosen from a maximized test await waitUntil( () => harness.panelColumn('codebook.studyContext') !== undefined && - slotTabCount() === 0, + slotTabCount(harness) === 0, 'the Study pane shows and the slot tab closes', READER_RESULT_BOUND_MS, ) @@ -856,22 +834,7 @@ test('a test replay opened while a chosen browser study waits for the editor are const second = await saveTwoStopNodeHistory( harness.service.nodeHistoryStore!, ) - await openReplayBesideBrowserStudy(harness) - harness.maximizePanel() - harness.receivePlaybackPanel({ - type: 'history', - revision: lastView(harness)!.revision, - studyId: harness.studyId, - }) - await waitUntil( - () => - harness.reader.inspect().view?.nodeHistory === undefined && - harness.reader.inspect().view?.model.selection.studyId === - harness.studyId && - !harness.reader.inspect().view?.loading, - 'the browser study is chosen', - READER_RESULT_BOUND_MS, - ) + await chooseBrowserStudyWithPanelMaximized(harness) // Act await harness.reader.openLearningReplay(second.studyId) @@ -887,14 +850,7 @@ test('a test replay opened while a chosen browser study waits for the editor are await readerSeams(harness).refresh() // Assert - assert.equal( - harness.groups - .flatMap((group) => group.tabs) - .filter((tab) => - JSON.stringify(tab.input).includes('/node-history-slot/'), - ).length, - 1, - ) + assert.equal(slotTabCount(harness), 1) } finally { await harness.close() } diff --git a/extension/src/utils/pruneClosedTabRecord.ts b/extension/src/utils/pruneClosedTabRecord.ts index 77d8e68fe..bb99781ba 100644 --- a/extension/src/utils/pruneClosedTabRecord.ts +++ b/extension/src/utils/pruneClosedTabRecord.ts @@ -1,6 +1,6 @@ /** * Drops the entries of a record of tabs that the editor closed, so a later close by address never picks a tab opened afterwards at the same address; read by the reader's tab-close listener for each record it keeps (the retired browser replay's captured-source tabs, the slot tabs waiting for the hidden editor area). - * A closed tab that is an entry's own object ends that entry. A closed tab object the editor rebuilt (Cursor) is in no entry: it ends the entry its address stands for, unless another live tab still shows that address (F494). + * A closed tab that is an entry's own object ends that entry. A closed tab whose address no live tab shows also ends every entry of that address whose object the editor no longer shows: a tab object the editor rebuilt (Cursor) is in no entry, so its address stands for it (F494). A live tab at that address keeps those entries. * @param record - The record: each tab object with the address it had. * @param closed - The tabs the editor reports closed. * @param live - Every tab object the editor shows now. @@ -17,14 +17,13 @@ export function pruneClosedTabRecord( const liveAddresses = new Set([...live].map(addressOf)) const kept = new Map(record) for (const tab of closed) { - if (kept.delete(tab)) continue + kept.delete(tab) const address = addressOf(tab) - // A tab still shown at that address keeps the entry: only its last tab's close ends it. + // A tab still shown at that address keeps its entries: only the last tab's close ends them. if (address === undefined || liveAddresses.has(address)) continue - const stale = [...kept].find( - ([entry, entryAddress]) => entryAddress === address && !live.has(entry), - ) - if (stale) kept.delete(stale[0]) + // Every entry of that address whose object the editor no longer shows ends with it (Cursor may have rebuilt the object more than once). + for (const [entry, entryAddress] of [...kept]) + if (entryAddress === address && !live.has(entry)) kept.delete(entry) } return kept.size === record.size ? record : kept } From f61415fdf98e078ed821db2919b8d4c1ee9dbecf Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 12:47:24 +0900 Subject: [PATCH 14/30] fix: the last superseded test replay entry to end leaves the empty summary composition, also when a waiting entry was superseded with the entering one (Cycle 0052, local CodeRabbit round 7) Refs #124 --- extension/src/studyWorkspace.ts | 20 ++++-- ...tudyWorkspaceTestReplayComposition.test.ts | 66 +++++++++++++++++++ 2 files changed, 79 insertions(+), 7 deletions(-) diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 265314508..3038c3cef 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -964,7 +964,7 @@ export class StudyWorkspace { private liveComposition = false /** Counts the learner's test replay entries ({@link openNodeHistoryFromHost}) in the order they were made, so the latest one wins also while an earlier one still enters the summary composition (EN19, Cycle 0051). */ private nodeHistoryEntryRequest = 0 - /** How many test replay entries ({@link openNodeHistoryFromHost}) are under way, the waiting ones included; a superseded entry leaves the empty summary composition it built only when no newer entry is under way to fill it (Cycle 0052 task 12). */ + /** How many test replay entries ({@link openNodeHistoryFromHost}) are under way, the waiting ones included; the last superseded entry to end leaves the empty summary composition an earlier one built only when none is under way to fill it (Cycle 0052 task 12). */ private nodeHistoryEntriesUnderWay = 0 /** Settles when the test replay entry in front has entered its composition: entries take turns there, so two entries never run {@link open} at once (one would be refused as busy and leave no replay shown). */ private nodeHistoryEntryTurn: Promise = Promise.resolve() @@ -11730,13 +11730,23 @@ export class StudyWorkspace { releaseTurn = resolve }) this.nodeHistoryEntriesUnderWay++ + let entered = false try { await previousTurn - if (!(await this.enterCompositionForEntry(studyId, entry))) return + entered = await this.enterCompositionForEntry(studyId, entry) } finally { this.nodeHistoryEntriesUnderWay-- releaseTurn() } + if (!entered) { + // The last superseded entry to end leaves the summary composition an earlier one built, when no entry is under way to fill it: a Return or a Close superseded them all (Cycle 0052 task 12). + if ( + entry !== this.nodeHistoryEntryRequest && + this.nodeHistoryEntriesUnderWay === 0 + ) + await this.leaveEmptySummaryComposition() + return + } if (entry !== this.nodeHistoryEntryRequest) return const outcome = await this.openNodeHistory( studyId, @@ -11790,11 +11800,7 @@ export class StudyWorkspace { ) return false } - if (entry === this.nodeHistoryEntryRequest) return true - // A Return or a Close superseded this entry while it entered: with no newer entry under way to fill it, the summary composition it built is left (Cycle 0052 task 12). - if (this.nodeHistoryEntriesUnderWay === 1) - await this.leaveEmptySummaryComposition() - return false + return entry === this.nodeHistoryEntryRequest } /** diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index ec995b991..4f6a4848b 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -1773,3 +1773,69 @@ test('a refresh during a same-study Reload keeps the Application pane on the rec await harness.close() } }) + +// Value: protects=an agent's Return to Live that supersedes one test replay entry entering the summary composition and a second one waiting for its turn leaves no empty summary composition, whichever of them ends last; fails_when=the entering one skips the leave because the waiting one is still counted, and the waiting one ends at its ticket check without leaving (local CodeRabbit round 7, minor); why_new=Cycle 0052 task 12; seam=the first entry held at enterSummaryCompositionForReplay, a configured live run, the agent's returnToCurrent; no_claim=the native timing of the agent's call +test('an agent’s Return to Live that supersedes an entering and a waiting test replay entry leaves no empty summary composition', async () => { + // Arrange + const harness = await workspaceHarness() + let release = (): void => {} + try { + const store = harness.service.nodeHistoryStore! + const first = await saveTwoStopNodeHistory(store) + const second = await saveTwoStopNodeHistory(store) + await harness.reader.action('retry') + const receipt = harness.service.start({ + instanceId: harness.service.instanceId, + profile: PROFILE_ID, + configuration: harness.configuration, + key: randomUUID(), + }) + await harness.service.record(receipt).preparation + await harness.reader.open() + const reader = harness.reader as unknown as { + enterSummaryCompositionForReplay: () => Promise + } + const enter = reader.enterSummaryCompositionForReplay.bind(harness.reader) + const held = new Promise((resolve) => { + release = resolve + }) + let entered = (): void => {} + const entering = new Promise((resolve) => { + entered = resolve + }) + reader.enterSummaryCompositionForReplay = async (): Promise => { + entered() + await held + return enter() + } + + // Act + const firstEntry = harness.reader.openLearningReplay(first.studyId) + await entering + const secondEntry = harness.reader.openLearningReplay(second.studyId) + const state = harness.reader.presentationState() + await harness.reader.returnToCurrent( + { + presentationId: state.presentationId, + expectedRevision: state.reader.contentRevision, + }, + 'agent', + ) + release() + await Promise.all([firstEntry, secondEntry]) + + // Assert + assert.deepEqual( + { + testReplay: harness.reader.inspect().view?.nodeHistory?.header.studyId, + summaryPlaceholder: harness.summary.webview.html.includes( + 'Open saved learning from Codebook to view its summary.', + ), + }, + { testReplay: undefined, summaryPlaceholder: true }, + ) + } finally { + release() + await harness.close() + } +}) From c03cc15b69a46db64135b55d7613c45d412c0d71 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 13:01:00 +0900 Subject: [PATCH 15/30] fix: Open replay into a standing composition waits for the work window before its reveal, and a superseded entry leaves the composition only outside a Return or a presentation change (Cycle 0052, local CodeRabbit round 8) - enterSummaryCompositionForReplay waits for the work window's focus (waitForRecordingWindow) and reveals nothing for an entry overtaken meanwhile (minor, two findings). - leaveEmptySummaryComposition leaves the arrangement to a Return or a presentation change under way (minor; not reproduced in the harness, where the entry ends after the Return: the guard test passes before and after). - The CHANGELOG line for cancelled waiting entries comes with this release's entry (/ship). Refs #124 --- extension/src/studyWorkspace.ts | 16 ++- ...tudyWorkspaceTestReplayComposition.test.ts | 117 ++++++++++++++++-- 2 files changed, 119 insertions(+), 14 deletions(-) diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 3038c3cef..20599f38f 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -11787,7 +11787,7 @@ export class StudyWorkspace { if (!(await this.closeBrowserReplayForTestReplay(entry))) return false if (entry !== this.nodeHistoryEntryRequest) return false try { - await this.enterSummaryCompositionForReplay() + await this.enterSummaryCompositionForReplay(entry) } catch (error) { // A newer entry answers for the learner now. if (entry !== this.nodeHistoryEntryRequest) return false @@ -11873,16 +11873,19 @@ export class StudyWorkspace { * Opens the summary composition for a test replay that is about to open (Cycle 0051, DS-A): a closed reader opens, and the Live composition and the Study composition give way to it, so the Study pane closes and the Learning summary takes the chat's place; called by {@link openNodeHistoryFromHost}. * Only a reader that already stands in the summary composition for a test replay (another test replay was shown) is left as it is; an earlier entry that failed after it left the Live composition is opened again, so the Application, the slot and the debugger never show without a transport (F384). * @throws What {@link open} throws; the caller tells it. - * @example await this.enterSummaryCompositionForReplay() + * @param entry - The entry's ticket ({@link nodeHistoryEntryRequest}); an entry a newer one or a Return overtook while it waited for the work window reveals nothing. + * @example await this.enterSummaryCompositionForReplay(entry) */ - private async enterSummaryCompositionForReplay(): Promise { + private async enterSummaryCompositionForReplay(entry: number): Promise { if ( this.opened && this.summaryComposition && !this.liveComposition && !this.recordingActive ) { - // The composition stands already, but the learner may have hidden the summary and the bottom panel: the entry shows them again, so Open replay never ends with no visible replay (Cycle 0052 task 14). + // The composition stands already, but the learner may have hidden the summary and the bottom panel: the entry shows them again, so Open replay never ends with no visible replay (Cycle 0052 task 14). The reveal waits for the work window as open()'s does, and an entry overtaken meanwhile reveals nothing. + await this.waitForRecordingWindow(undefined, this.lifetime) + if (entry !== this.nodeHistoryEntryRequest) return await this.showLearningSummary(undefined, true) return } @@ -11906,7 +11909,10 @@ export class StudyWorkspace { this.closing || !this.summaryComposition || this.recordingActive || - this.testReplayHoldsComposition() + this.testReplayHoldsComposition() || + // A Return or a presentation change under way arranges the reader itself (Cycle 0052 task 12). + this.returningCurrent !== undefined || + this.presentationTransition !== undefined ) return try { diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 4f6a4848b..754f3d804 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -1361,7 +1361,7 @@ test('a Saved studies choice of a test replay superseded while it enters the com await harness.reader.action('retry') await harness.reader.open() const reader = harness.reader as unknown as { - enterSummaryCompositionForReplay: () => Promise + enterSummaryCompositionForReplay: (entry: number) => Promise revision: number } const enter = reader.enterSummaryCompositionForReplay.bind(harness.reader) @@ -1370,8 +1370,10 @@ test('a Saved studies choice of a test replay superseded while it enters the com const standsInComposition = new Promise((resolve) => { entered = resolve }) - reader.enterSummaryCompositionForReplay = async (): Promise => { - await enter() + reader.enterSummaryCompositionForReplay = async ( + entry: number, + ): Promise => { + await enter(entry) // Another reader change lands while the entry stands in the composition. reader.revision++ messagesAtSupersession = harness.playbackPanelMessages.length @@ -1616,7 +1618,7 @@ test('an agent’s Return to Live that supersedes an entering test replay leaves await harness.service.record(receipt).preparation await harness.reader.open() const reader = harness.reader as unknown as { - enterSummaryCompositionForReplay: () => Promise + enterSummaryCompositionForReplay: (entry: number) => Promise } const enter = reader.enterSummaryCompositionForReplay.bind(harness.reader) const held = new Promise((resolve) => { @@ -1626,10 +1628,12 @@ test('an agent’s Return to Live that supersedes an entering test replay leaves const entering = new Promise((resolve) => { entered = resolve }) - reader.enterSummaryCompositionForReplay = async (): Promise => { + reader.enterSummaryCompositionForReplay = async ( + entry: number, + ): Promise => { entered() await held - return enter() + return enter(entry) } // Act @@ -1793,7 +1797,7 @@ test('an agent’s Return to Live that supersedes an entering and a waiting test await harness.service.record(receipt).preparation await harness.reader.open() const reader = harness.reader as unknown as { - enterSummaryCompositionForReplay: () => Promise + enterSummaryCompositionForReplay: (entry: number) => Promise } const enter = reader.enterSummaryCompositionForReplay.bind(harness.reader) const held = new Promise((resolve) => { @@ -1803,10 +1807,12 @@ test('an agent’s Return to Live that supersedes an entering and a waiting test const entering = new Promise((resolve) => { entered = resolve }) - reader.enterSummaryCompositionForReplay = async (): Promise => { + reader.enterSummaryCompositionForReplay = async ( + entry: number, + ): Promise => { entered() await held - return enter() + return enter(entry) } // Act @@ -1839,3 +1845,96 @@ test('an agent’s Return to Live that supersedes an entering and a waiting test await harness.close() } }) + +// Value: protects=Open replay made while another test replay is shown and the work window has lost the desktop focus waits for the focus before showing the summary, as an open from a closed reader does, instead of failing at once; fails_when=the entry into the standing composition reveals the summary unfocused and is told as a failed open (local CodeRabbit round 8, minor); why_new=Cycle 0052 task 14's reveal; seam=changeWindowFocus; no_claim=the native window manager +test('Open replay into a standing composition waits for the work window’s focus before showing the summary', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const second = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await openByOpenReplay(harness) + harness.hideSummary() + harness.changeWindowFocus(false) + + // Act: the learner clicks back into the window a moment after Open replay. + const entry = harness.reader.openLearningReplay(second.studyId) + setTimeout(() => harness.changeWindowFocus(true), 200) // gstack-timing-ok: the learner's click comes after the entry began waiting; the wait itself is bounded by RECORDING_WINDOW_FOCUS_WAIT_MS + await entry + await waitUntil( + () => + lastView(harness)?.nodeHistory?.header.studyId === second.studyId && + !lastView(harness)?.loading, + 'the second replay is shown', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.deepEqual( + { errors: harness.errorMessages, summaryShown: harness.summary.visible }, + { errors: [], summaryShown: true }, + ) + } finally { + harness.changeWindowFocus(true) + await harness.close() + } +}) + +// Value: protects=a learner's Return to Live that supersedes a test replay entry entering the summary composition arranges the reader itself, and the superseded entry's end tells no control error; fails_when=the entry's leave of the empty composition runs open() during the Return, which refuses as busy and reports a control error the learner caused nothing for (local CodeRabbit round 8, minor); why_new=Cycle 0052 task 12; seam=the entry held at enterSummaryCompositionForReplay, a configured live run, the learner's return-current action; no_claim=the native timing +test('a learner’s Return to Live that supersedes an entering test replay tells no control error', async () => { + // Arrange + const harness = await workspaceHarness() + let release = (): void => {} + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + const receipt = harness.service.start({ + instanceId: harness.service.instanceId, + profile: PROFILE_ID, + configuration: harness.configuration, + key: randomUUID(), + }) + await harness.service.record(receipt).preparation + await harness.reader.open() + const reader = harness.reader as unknown as { + enterSummaryCompositionForReplay: (entry: number) => Promise + } + const enter = reader.enterSummaryCompositionForReplay.bind(harness.reader) + const held = new Promise((resolve) => { + release = resolve + }) + let entered = (): void => {} + const entering = new Promise((resolve) => { + entered = resolve + }) + reader.enterSummaryCompositionForReplay = async ( + entry: number, + ): Promise => { + entered() + await held + return enter(entry) + } + + // Act + const entry = harness.reader.openLearningReplay(history.studyId) + await entering + const returning = harness.reader.action('return-current') + release() + await Promise.all([entry, returning]) + + // Assert + assert.deepEqual( + structuredClone({ + errors: harness.reader.inspect().view?.errors, + testReplay: harness.reader.inspect().view?.nodeHistory?.header.studyId, + }), + { errors: [], testReplay: undefined }, + ) + } finally { + release() + await harness.close() + } +}) From 35e0215e6f017e10791e76c1b72929a0ce30f3c4 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 13:09:26 +0900 Subject: [PATCH 16/30] fix: an explicit display reads the reader's lifetime before it closes the waiting slot tabs, so a Close and a new Open meanwhile cut it off (Cycle 0052 task 15, OCR self-review); README line 76 says each step once (local CodeRabbit round 9) - renderSelection captured the lifetime after the new closeSlotTabsAwaitingArea await: a Close and an explicit Open during that close let the old Focus source render into the reopened reader. The new host test is red on c03cc15b ('rendered') and green after. - README: the repeated 'again' of line 76 is reworded (round 9, minor); no behavior sentence changes. Refs #124 --- extension/README.md | 2 +- extension/src/studyWorkspace.ts | 3 +- .../studyWorkspaceTestReplayOpens.test.ts | 28 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/extension/README.md b/extension/README.md index 28c66775b..bdfbf758f 100644 --- a/extension/README.md +++ b/extension/README.md @@ -73,7 +73,7 @@ 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 the summary again if it is hidden. 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; open it again 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 open it again at the same address, also in an editor that rebuilds its tab objects, and a close that the editor refuses is told and tried again 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 opens the replay again. 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. 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, so it neither asks again nor reopens the reader. Open replay shows the Learning summary again if you had hidden it, also with the bottom panel hidden. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view and the transport never shows the other replay family's controls; after a browser replay the Application tab is named `codebook · Application` again. Reloading a browser replay keeps its Learning summary while the recording loads again. 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. 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 presents it again. 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. 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. 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, so it neither asks a second time nor reopens the reader. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view and the transport never shows the other replay family's controls; after a browser replay the Application tab takes back its name `codebook · Application`. Reloading a browser replay keeps its Learning summary while the recording reloads. 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. 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. diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 20599f38f..ecc69f7a9 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -13834,8 +13834,9 @@ export class StudyWorkspace { if (!preserveFocus) this.keyboardOutsideEditors = undefined // An explicit entry, selection or placement shows the panes now; a study that waited for the editor area waits no longer, nor do the slot tabs that waited with it (Cycle 0052 task 15). this.studyAwaitingEditorArea = undefined - await this.closeSlotTabsAwaitingArea() + // The lifetime is read before the first await, so a Close during the slot close cuts this render off also after a new Open. const lifetime = this.lifetime + await this.closeSlotTabsAwaitingArea() await this.refreshCitations() // Citation I/O can outlive Close or a later Open; it cannot create a new rendering revision afterward. this.assertLifetime(lifetime) diff --git a/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts b/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts index 73d091173..a14732a61 100644 --- a/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts @@ -1100,6 +1100,34 @@ test('an explicit display that ends the wait for the editor area closes the slot } }) +// Value: protects=an explicit display whose close of the waiting slot tabs outlives a Close and a new Open is cut off with the closed reader, so it never renders into the reader the learner opened again; fails_when=renderSelection reads the reader's lifetime only after that close and goes on rendering the reopened reader (OCR self-review, Cycle 0052 task 15); why_new=Cycle 0052 task 15; seam=holdNextTabCloseResponse during the source action's slot close; no_claim=the native editor's own restore of the area +test('an explicit display that outlives Close and a new Open during its slot close renders nothing into the reopened reader', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + await chooseBrowserStudyWithPanelMaximized(harness) + const held = harness.holdNextTabCloseResponse() + const display = harness.reader.action('source').then( + () => 'rendered', + (error: unknown) => String(error), + ) + await held.started + await harness.reader.close() + await harness.reader.open() + + // Act + held.release() + + // Assert + assert.equal( + await display, + 'Error: The study workspace was closed; repeat the action explicitly if needed.', + ) + } finally { + await harness.close() + } +}) + // Value: protects=a slot tab the learner closes by hand after Cursor rebuilt the tab objects, and opens again at its address, survives the end of the wait for the editor area; fails_when=the record keeps the closed tab's address and the end of the wait closes the tab the learner opened (local CodeRabbit round 4, minor); why_new=Cycle 0052 task 15; seam=rebuildTabObjectsNow, closeTabByHand, restoreLeftoverCapturedSource at the slot's path, restorePanel and the reader's refresh; no_claim=the native tab strip test('a waiting slot tab the learner closes after Cursor rebuilt the tabs and opens again survives the end of the wait', async () => { // Arrange From 40d30aa18e471ec2e074e4dbea917d65aabbdd93 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 14:25:36 +0900 Subject: [PATCH 17/30] fix: a same-study Reload counts as the browser replay shown for choices, selections and the agent's state, and a Return ends a test replay that is still opening (Cycle 0052 tasks 28, 29, 30; pt1 /ship review pass 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - browserReplayShown(): one predicate for a loaded recording or a same-study Reload's mark. A saved study chosen during or after a failed Reload opens its own recording (task 28), a step selection is refused until the recording has loaded again (task 28), and presentationState reads contentTab 'recording', followingCurrentRun false and the failed load's reason, so an agent returns before a new run (task 29). - returnCurrentSelection ends a test replay that is still opening (marker, loading state, summary), so the Return arranges Live at once and no loading state survives the superseded open (task 30; learner and agent). - Tests: Reload choice/selection/state (red on 35e0215e), Return during an open (red without the fix: loading true), state cells reload-failed × Return / Open replay, entry × Close, coverage-audit tests (Close during Reload, a refused waiting-slot close, a failed open during an agent's presentation change), two pruneClosedTabRecord/replacedTabAddresses edge cases. - JSDoc: retireNodeHistorySelection, retireNodeSourceSlot (one flag name: keepSlotTabsForHiddenArea), closeSlotTabsAwaitingArea's callers; four `as unknown as` casts in tests replaced by readerSeams.debuggerReplacement. - README line 76: the Reload choice and selection sentence. Refs #124 --- extension/README.md | 2 +- extension/src/studyWorkspace.ts | 71 +- .../src/tests/pruneClosedTabRecord.test.ts | 31 + ...tudyWorkspaceTestReplayComposition.test.ts | 713 +++++++++++++++++- .../studyWorkspaceTestReplayOpens.test.ts | 37 + 5 files changed, 808 insertions(+), 46 deletions(-) diff --git a/extension/README.md b/extension/README.md index bdfbf758f..5809bed8c 100644 --- a/extension/README.md +++ b/extension/README.md @@ -73,7 +73,7 @@ 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. 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. 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, so it neither asks a second time nor reopens the reader. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view and the transport never shows the other replay family's controls; after a browser replay the Application tab takes back its name `codebook · Application`. Reloading a browser replay keeps its Learning summary while the recording reloads. 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. 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. 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. 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, so it neither asks a second time nor reopens the reader. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view and the transport never shows the other replay family's controls; after a browser replay the Application tab takes back its name `codebook · Application`. Reloading a browser replay keeps its Learning summary while the recording reloads. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, and a step you select before the recording has loaded again is refused until it has. 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. 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. diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index ecc69f7a9..5af4a56c7 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -1363,7 +1363,7 @@ export class StudyWorkspace { 'The recorded source of the test replay is not shown in its pane. Use Focus source to show it again.' else if (playback?.readiness === 'loading') reason = 'The recorded source is opening in its native pane.' - else if (this.recordingActive && playback?.readiness === 'failed') + else if (this.browserReplayShown() && playback?.readiness === 'failed') reason = recordingNotShownReason(playback.reason) else reason = this.capturedSourceReason() } else if (!matchingColumns) @@ -1385,10 +1385,13 @@ export class StudyWorkspace { presentationControlRevision: this.presentationControlRevision, reader: { contentRevision: this.revision + this.readerSelectionRevision, - contentTab: this.recordingActive ? 'recording' : this.selection.tab, + // A same-study Reload that is loading or failed still shows its browser replay, so an agent returns to the run before it starts another (Cycle 0052 task 18). + contentTab: this.browserReplayShown() + ? 'recording' + : this.selection.tab, followingCurrentRun: this.selection.follow && - !this.recordingActive && + !this.browserReplayShown() && !this.selectedNodeHistory, }, mode: this.presentationMode, @@ -1490,7 +1493,7 @@ export class StudyWorkspace { if ( this.selectedNodeHistory || !this.selection.follow || - this.recordingActive || + this.browserReplayShown() || this.selection.tab !== 'live' || !this.liveComposition || !this.sourceReady() @@ -8165,8 +8168,7 @@ export class StudyWorkspace { const currentRecord = this.currentRecord() // A selected or opening test replay is read in the Study composition, whatever run is current (F208). const liveComposition = - !this.recordingActive && - this.recordingReloadStudyId === undefined && + !this.browserReplayShown() && !this.testReplayHoldsComposition() && this.selection.follow && this.selection.tab === 'live' && @@ -8177,9 +8179,7 @@ export class StudyWorkspace { ) // A browser recording and a selected or opening test replay share the summary composition (Cycle 0051, DS-A): the Learning summary stands in the chat's place and the Study pane closes. const summaryComposition = - this.recordingActive || - this.recordingReloadStudyId !== undefined || - this.testReplayHoldsComposition() + this.browserReplayShown() || this.testReplayHoldsComposition() const compositionChanged = liveComposition !== this.liveComposition || summaryComposition !== this.summaryComposition @@ -9173,6 +9173,14 @@ export class StudyWorkspace { this.historyRequest++ this.leavePlayback() this.pendingHistory = undefined + // A test replay still opening is superseded by the request above and ends here with its opening marker, loading state and the summary it drew, so this Return arranges Live at once; its open finds itself superseded and changes nothing more (Cycle 0052 task 30). + if (this.pendingNodeHistory !== undefined) { + if (this.summaryComposition && !this.recordingActive) + this.clearLearningSummary() + this.pendingNodeHistory = undefined + this.pendingNodeHistoryStudyId = undefined + this.loading = false + } // A selected Node journal would otherwise keep masking the live application and debugger after the return. await this.retireNodeHistorySelection() this.assertLifetime(lifetime) @@ -9346,6 +9354,11 @@ export class StudyWorkspace { !this.message().model.progressByItem.some((item) => item.id === selected) ) throw new Error('Select the matching study profile first.') + // A same-study Reload shows its browser replay with no recording to seek in until its load ends: a step is selected in the replay once it is loaded, never outside it (Cycle 0052 task 18). + if (!this.recordingActive && this.browserReplayShown()) + throw new Error( + 'The recording is not loaded. Select this step once it has loaded again.', + ) this.readerSelectionRevision++ this.historyRequest++ if (this.recordingActive) { @@ -10819,9 +10832,8 @@ export class StudyWorkspace { if (this.opened && !this.closing) { if (this.selectedNodeHistory !== undefined) return 'publish' if ( - !this.recordingActive && // A same-study Reload keeps the recording it loads on every surface until its load ends (Cycle 0052 task 18). - this.recordingReloadStudyId === undefined && + !this.browserReplayShown() && this.repositoryNavigation === undefined && !this.presentationTransition && // A saved study chosen with the editor area hidden is shown by showStudyAwaitingEditorArea once the area is back. @@ -11813,10 +11825,7 @@ export class StudyWorkspace { private async closeBrowserReplayForTestReplay( entry: number, ): Promise { - // A same-study Reload still loading is the browser replay shown (Cycle 0052 task 18). - const browserReplayShown = (): boolean => - this.recordingActive || this.recordingReloadStudyId !== undefined - if (!browserReplayShown()) return true + if (!this.browserReplayShown()) return true const studyId = this.selection.studyId, revision = this.playback?.state().revision const refusal = describeNodeLearningSave({ kind: 'open-refused' }) @@ -11829,13 +11838,13 @@ export class StudyWorkspace { if ( action !== NODE_LEARNING_SAVE_ACTION.closeBrowserReplay || entry !== this.nodeHistoryEntryRequest || - !browserReplayShown() || + !this.browserReplayShown() || this.selection.studyId !== studyId || this.playback?.state().revision !== revision ) return false await this.returnCurrentSelection('learner') - return !browserReplayShown() + return !this.browserReplayShown() } /** @@ -12807,8 +12816,9 @@ export class StudyWorkspace { /** * Retires the selected Node journal with its playback and historical debugger context when the learner leaves it for a browser history or the current run. - * Ends the recorded-source slot ({@link retireNodeSourceSlot}); the learner's own tabs, a pinned or edited slot tab among them, stay. + * A replay shown in the summary composition withdraws its Learning summary first, before anything publishes (Cycle 0052 task 2). Ends the recorded-source slot ({@link retireNodeSourceSlot}); the learner's own tabs, a pinned or edited slot tab among them, stay. * A caller rechecks its own request afterwards, because the native tab close can let the reader change. + * @param keepSlotTabsForHiddenArea - `true` when a saved study is chosen while the editor area is hidden: the slot's tabs stay open until the area is back ({@link retireNodeSourceSlot}). * @example * ```ts * await this.retireNodeHistorySelection() @@ -12857,11 +12867,13 @@ 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}. + * 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. * @example await this.retireNodeSourceSlot() */ private async retireNodeSourceSlot( - keepTabsForHiddenArea = false, + keepSlotTabsForHiddenArea = false, ): Promise { const slot = this.nodeSourceSlot this.nodeSourceSlotRetirements++ @@ -12876,7 +12888,7 @@ export class StudyWorkspace { this.clearNodeSourceSlotDecorations() try { // A saved study chosen with the editor area hidden keeps the slot's tabs, which read the placeholder below, until the area is back: closing the tab a group shows would bring the area back (Cycle 0052 task 15). - if (keepTabsForHiddenArea) + if (keepSlotTabsForHiddenArea) // A later choice made while the area is still hidden keeps the earlier wait's tabs waiting too. this.nodeSourceSlotTabsAwaitingArea = new Map([ ...(this.nodeSourceSlotTabsAwaitingArea ?? []), @@ -13678,6 +13690,17 @@ export class StudyWorkspace { this.publish() } + /** + * Says whether the reader shows a browser replay: a loaded recording, or a same-study Reload whose recording is loading again or failed to load, which keeps the replay's summary composition and recovery until a newer selection supersedes it (Cycle 0052 task 18); asked wherever the reader decides between a browser replay and its other selections: {@link open}'s composition, {@link refreshPresentation}, a test replay's entry, a saved study's choice, an item's selection, {@link presentationState} and an agent's Return. + * @returns + * - `true` while a recording is active or a same-study Reload's mark is set + * - `false` otherwise + * @example this.browserReplayShown() // => true after a same-study Reload whose load failed + */ + private browserReplayShown(): boolean { + return this.recordingActive || this.recordingReloadStudyId !== undefined + } + /** * Names the replay family the summary composition shows, which the Study views read for their page (Cycle 0051, DS-A); the one place the family is decided, read by {@link publish}. * @returns `browser` while a browser recording is active or reloading, `test` for a selected or opening test replay, `undefined` outside the summary composition and while the reader leaves a replay. @@ -23215,8 +23238,7 @@ export class StudyWorkspace { ) this.publish() if (message.type === 'node-history') { - // A same-study Reload still loading counts as the browser replay shown (Cycle 0052 task 18). - if (this.recordingActive || this.recordingReloadStudyId !== undefined) + if (this.browserReplayShown()) throw new Error('Close browser replay before opening a test replay.') // A Saved studies choice is the learner's entry: it enters the summary composition and tells a failure as Open replay does (DS-A, DS-G). await this.openNodeHistoryFromHost(message.studyId, message.revision) @@ -23381,7 +23403,8 @@ export class StudyWorkspace { * @example await this.openChosenStudy({ type: 'history', revision: this.revision, studyId }) */ private async openChosenStudy(message: HistoryPaneMessage): Promise { - if (this.recordingActive) { + // A same-study Reload that is loading or failed still shows its browser replay: the choice opens the chosen recording as it does during the replay, and supersedes the Reload (Cycle 0052 task 18). + if (this.browserReplayShown()) { await this.openRecording(message.studyId) return } @@ -24673,7 +24696,7 @@ export class StudyWorkspace { } /** - * Closes the recorded-source tabs a test replay's retirement left open while the editor area was hidden, once that wait ends; called by {@link showStudyAwaitingEditorArea}. + * Closes the recorded-source tabs a test replay's retirement left open while the editor area was hidden, once that wait ends; called by {@link showStudyAwaitingEditorArea} (the area is back, or the wait ended without it), by {@link renderSelection} (an explicit entry, selection or placement) and by {@link enterCompositionForEntry} (a test replay entry). * @returns Once the tabs are closed, or at once when none wait; a refused close is told on the layout line. * @example await this.closeSlotTabsAwaitingArea() */ diff --git a/extension/src/tests/pruneClosedTabRecord.test.ts b/extension/src/tests/pruneClosedTabRecord.test.ts index 342ee4896..bc40ce3d1 100644 --- a/extension/src/tests/pruneClosedTabRecord.test.ts +++ b/extension/src/tests/pruneClosedTabRecord.test.ts @@ -94,3 +94,34 @@ test('a closed rebuilt tab ends every stale entry of its address', () => { // Assert assert.deepEqual([...pruned], []) }) + +// Value: protects=a closed tab without an address (a webview, the chat) ending no entry of the record; fails_when=an address-less close is matched against the entries' addresses and drops an unrelated entry, so a recorded slot tab stays open after its wait; why_new=Cycle 0052 /ship testing specialist: the address-less branch had no case; seam=none; no_claim=the editor's tab events +test('a closed tab without an address leaves every entry of the record', () => { + // Arrange + const recorded: FakeTab = { address: 'codebook-source://key/a' } + const webview: FakeTab = {} + const record = new Map([[recorded, 'codebook-source://key/a']]) + + // Act + const pruned = pruneClosedTabRecord(record, [webview], new Set(), addressOf) + + // Assert + assert.equal(pruned, record) +}) + +// Value: protects=a record whose tab objects the editor all still shows naming no address to close by; fails_when=a live tab's address is named and the close picks a tab the learner opened at that address; why_new=Cycle 0052 /ship testing specialist: only the mixed case had a test; seam=none; no_claim=Cursor's rebuild itself +test('a record whose tabs are all still shown gives no address', () => { + // Arrange + const first: FakeTab = { address: 'codebook-source://key/a' } + const second: FakeTab = { address: 'codebook-source://key/b' } + const record = new Map([ + [first, 'codebook-source://key/a'], + [second, 'codebook-source://key/b'], + ]) + + // Act + const addresses = replacedTabAddresses(record, new Set([first, second])) + + // Assert + assert.deepEqual([...addresses], []) +}) diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 754f3d804..583a9e4c5 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -20,6 +20,7 @@ import { recordedReaderHarness, workspaceHarness, } from './studyWorkspaceHarness' +import { createConfiguredStudy } from './utils/createConfiguredStudy' import { nodeTestHistoryHeader } from './utils/nodeTestHistoryFixture' import { READER_RESULT_BOUND_MS } from './utils/readerResultBound' import { lastView, saveTwoStopNodeHistory } from './utils/twoStopNodeHistory' @@ -1522,10 +1523,8 @@ test('another open during a same-study Reload keeps the Reload’s Learning summ try { await harness.reader.openRecording(harness.studyId) await harness.controller.settle() - const reader = harness.reader as unknown as { - replaceUnavailableReplayDebugger: (signal?: AbortSignal) => Promise - } - const replace = reader.replaceUnavailableReplayDebugger.bind(harness.reader) + const seams = readerSeams(harness) + const replace = seams.debuggerReplacement const held = new Promise((resolve) => { release = resolve }) @@ -1533,9 +1532,7 @@ test('another open during a same-study Reload keeps the Reload’s Learning summ const reloadWaits = new Promise((resolve) => { entered = resolve }) - reader.replaceUnavailableReplayDebugger = async ( - signal?: AbortSignal, - ): Promise => { + seams.debuggerReplacement = async (signal?: AbortSignal): Promise => { entered() await held return replace(signal) @@ -1678,10 +1675,8 @@ test('a test replay opened during a same-study Reload closes the reloading brows await harness.reader.action('retry') await harness.reader.openRecording(harness.studyId) await harness.controller.settle() - const reader = harness.reader as unknown as { - replaceUnavailableReplayDebugger: (signal?: AbortSignal) => Promise - } - const replace = reader.replaceUnavailableReplayDebugger.bind(harness.reader) + const seams = readerSeams(harness) + const replace = seams.debuggerReplacement const held = new Promise((resolve) => { release = resolve }) @@ -1689,9 +1684,7 @@ test('a test replay opened during a same-study Reload closes the reloading brows const reloadWaits = new Promise((resolve) => { entered = resolve }) - reader.replaceUnavailableReplayDebugger = async ( - signal?: AbortSignal, - ): Promise => { + seams.debuggerReplacement = async (signal?: AbortSignal): Promise => { entered() await held return replace(signal) @@ -1739,10 +1732,8 @@ test('a refresh during a same-study Reload keeps the Application pane on the rec try { await harness.reader.openRecording(harness.studyId) await harness.controller.settle() - const reader = harness.reader as unknown as { - replaceUnavailableReplayDebugger: (signal?: AbortSignal) => Promise - } - const replace = reader.replaceUnavailableReplayDebugger.bind(harness.reader) + const seams = readerSeams(harness) + const replace = seams.debuggerReplacement const held = new Promise((resolve) => { release = resolve }) @@ -1750,9 +1741,7 @@ test('a refresh during a same-study Reload keeps the Application pane on the rec const reloadWaits = new Promise((resolve) => { entered = resolve }) - reader.replaceUnavailableReplayDebugger = async ( - signal?: AbortSignal, - ): Promise => { + seams.debuggerReplacement = async (signal?: AbortSignal): Promise => { entered() await held return replace(signal) @@ -1938,3 +1927,685 @@ test('a learner’s Return to Live that supersedes an entering test replay tells await harness.close() } }) + +// Value: protects=Close ends a same-study Reload's mark, so a reader the learner opens again afterwards stands in the Study composition and not in a summary composition for a recording that is gone; fails_when=close() keeps the Reload mark and the reopened reader closes its Study pane for a browser replay nobody shows (Cycle 0052 task 18); why_new=the Reload tests only end the mark through the load or a Return, none through Close; seam=the reload held at its debugger replacement, the reader's close and open; no_claim=the native pane's pixels +test('a reader closed during a same-study Reload opens again in the Study composition', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + let release = (): void => {} + try { + await harness.reader.openRecording(harness.studyId) + await harness.controller.settle() + const seams = readerSeams(harness) + const replace = seams.debuggerReplacement + const held = new Promise((resolve) => { + release = resolve + }) + let entered = (): void => {} + const reloadWaits = new Promise((resolve) => { + entered = resolve + }) + seams.debuggerReplacement = async (signal?: AbortSignal): Promise => { + entered() + await held + return replace(signal) + } + const reload = harness.reader.action('reload-recording').then( + () => undefined, + () => undefined, + ) + await reloadWaits + + // Act: the learner closes the reader while the Reload loads, then opens it again. + await harness.reader.close() + release() + await reload + await harness.reader.open() + + // Assert + assert.deepEqual( + { + studyPane: harness.panelColumn('codebook.studyContext') !== undefined, + replayFamily: lastView(harness)?.replayFamily, + }, + { studyPane: true, replayFamily: undefined }, + ) + } finally { + release() + await harness.close() + } +}) + +// Value: protects=a test replay open that fails while an agent's presentation change is in flight tells its failure once and adds no control error: the failed open's leave of the empty summary composition waits for the presentation change, which arranges the reader itself; fails_when=leaveEmptySummaryComposition runs open() during the presentation change, which refuses as pending and reports a control error beside the told failure (Cycle 0052 task 12); why_new=the Return guard test covers returningCurrent only, none holds a presentation change; seam=readerSeams.debuggerReplacement held then failed, the agent's Restore of the Study workspace (changePresentation) admitted while the replay opens; no_claim=the native layout of the presentation change +test('a test replay open that fails during an agent’s presentation change adds no control error', async () => { + // Arrange + const harness = await workspaceHarness() + const seams = readerSeams(harness) + let releaseOpen = (): void => {} + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.open() + const openWaits = new Promise((resolve) => { + releaseOpen = resolve + }) + let openEntered = (): void => {} + const opening = new Promise((resolve) => { + openEntered = resolve + }) + seams.debuggerReplacement = async (): Promise => { + openEntered() + await openWaits + throw new Error('The historical debugger did not start.') + } + const entry = harness.reader.openLearningReplay(history.studyId) + await opening + + // Act: the agent's Restore is admitted while the replay opens and still runs when the open fails. + const state = harness.reader.presentationState() + const change = harness.reader + .changePresentation({ + presentationId: state.presentationId, + expectedRevision: state.presentationControlRevision, + mode: 'study-workspace', + action: 'restore', + }) + .then( + () => undefined, + () => undefined, + ) + releaseOpen() + await entry + await change + + // Assert + assert.deepEqual( + { + toldFailures: [...harness.errorMessages], + pendingErrorLines: seams + .errorLines() + .filter((line) => line.includes('pending')), + }, + { + toldFailures: [ + 'Codebook could not open the test replay "src/theme.test.ts": The historical debugger did not start. The saved history is unchanged; open it again from Saved studies.', + ], + pendingErrorLines: [], + }, + ) + } finally { + releaseOpen() + await harness.close() + } +}) + +type RecordedHarness = Awaited> + +/** + * Holds the next recording load of the reader at its debugger replacement (later loads run unheld), so a test ends that load by its own choice: resumed, or failed. + * @param harness - The recorded reader harness. + * @returns `hasEntered` settles once the load reached the hold; `release` lets the load go on; `fail` makes it fail as a debugger that does not start. + * @example const load = holdNextRecordingLoad(harness); const reload = harness.reader.action('reload-recording'); await load.hasEntered; load.fail() + */ +function holdNextRecordingLoad(harness: RecordedHarness) { + const seams = readerSeams(harness) + const replace = seams.debuggerReplacement + let finish = (_failure: boolean): void => {} + const finished = new Promise((resolve) => { + finish = resolve + }) + let entered = (): void => {} + const hasEntered = new Promise((resolve) => { + entered = resolve + }) + seams.debuggerReplacement = async (signal?: AbortSignal): Promise => { + // Only the next load is held: later loads start the debugger as usual. + seams.debuggerReplacement = replace + entered() + if (await finished) + throw new Error('The historical debugger did not start.') + return replace(signal) + } + return { + hasEntered, + release: (): void => finish(false), + fail: (): void => finish(true), + } +} + +/** + * Saves a second browser study beside the harness's recorded one, so the learner has another Saved studies entry to choose. + * @param harness - The recorded reader harness. + * @returns The saved study. + * @example const other = await saveOtherBrowserStudy(harness) + */ +async function saveOtherBrowserStudy(harness: RecordedHarness) { + const library = await harness.reader.learningStorage(), + study = createConfiguredStudy(), + runId = randomUUID() + for (const observation of study.observations) + observation.snapshotUri = observation.snapshotUri.replace( + study.runId, + runId, + ) + study.workspaceKey = library.workspaceKey + study.runId = runId + study.id = `${study.instanceId}_${runId}` + study.savedAt = '2026-09-20T00:00:01.000Z' + await library.saveStudy(study) + // Retry storage reloads the catalog the reader shows. + await harness.reader.action('retry') + return study +} + +/** + * Opens a recorded study, then starts a same-study Reload whose load is held, optionally failing it. + * @param harness - The recorded reader harness. + * @param ending - `held` leaves the load held; `failed` fails it and waits for the failed readiness. + * @returns The hold, and the Reload's promise (settled whatever its outcome). + * @example const { load, reload } = await reloadHeldOrFailed(harness, 'failed') + */ +async function reloadHeldOrFailed( + harness: RecordedHarness, + ending: 'held' | 'failed', +) { + await harness.reader.openRecording(harness.studyId) + await harness.controller.settle() + const load = holdNextRecordingLoad(harness) + const reload = harness.reader.action('reload-recording').then( + () => undefined, + () => undefined, + ) + await load.hasEntered + if (ending === 'failed') { + load.fail() + await waitUntil( + () => harness.controller.state().readiness === 'failed', + 'the Reload’s load fails', + READER_RESULT_BOUND_MS, + ) + await reload + } + return { load, reload } +} + +// Value: protects=a Saved studies choice made after a same-study Reload's load failed opens the chosen recording, as it does during a browser replay, instead of leaving the learner on the failed replay; fails_when=openChosenStudy treats the failed Reload as no browser replay and the chosen study is never opened (Cycle 0052 task 18); why_new=the Reload mark's loading and failed cells had no Saved studies choice test; seam=a Reload whose debugger replacement is held then failed, the bottom panel's history message; no_claim=the chosen study's own recording content, which the saved configured study does not carry +test('a Saved studies choice after a failed same-study Reload opens the chosen recording', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + const other = await saveOtherBrowserStudy(harness) + await reloadHeldOrFailed(harness, 'failed') + + // Act + harness.receivePlaybackPanel({ + type: 'history', + revision: harness.reader.inspect().view!.revision, + studyId: other.id, + }) + await waitUntil( + () => harness.controller.state().studyId === other.id, + 'the chosen recording is opened', + READER_RESULT_BOUND_MS, + ) + await harness.controller.settle() + + // Assert + assert.deepEqual( + { + recording: harness.controller.state().studyId, + ...compositionOf(harness), + }, + { + recording: other.id, + studyPane: false, + application: true, + summary: true, + family: 'browser', + }, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=a Saved studies choice made while a same-study Reload still loads opens the chosen recording and the held Reload, once it ends, does not come back over it; fails_when=the choice is dropped while the Reload is loading, or the resumed Reload retires the chosen recording for the old one (Cycle 0052 task 18); why_new=the Reload mark's loading cell had no Saved studies choice test; seam=a Reload held at its debugger replacement, the bottom panel's history message; no_claim=the chosen study's own recording content +test('a Saved studies choice during a same-study Reload opens the chosen recording and the Reload does not come back', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + let load: Awaited>['load'] | undefined + try { + const other = await saveOtherBrowserStudy(harness) + const held = await reloadHeldOrFailed(harness, 'held') + load = held.load + + // Act: the learner chooses another saved study while the Reload loads; the Reload's load then ends. + harness.receivePlaybackPanel({ + type: 'history', + revision: harness.reader.inspect().view!.revision, + studyId: other.id, + }) + await waitUntil( + () => harness.controller.state().studyId === other.id, + 'the chosen recording is opened', + READER_RESULT_BOUND_MS, + ) + load.release() + await held.reload + await harness.controller.settle() + + // Assert + assert.deepEqual( + { + recording: harness.controller.state().studyId, + ...compositionOf(harness), + }, + { + recording: other.id, + studyPane: false, + application: true, + summary: true, + family: 'browser', + }, + ) + } finally { + load?.release() + await harness.close() + } +}) + +for (const ending of ['held', 'failed'] as const) { + // Value: protects=a step selected while a same-study Reload has no loaded recording is refused with the sentence that says when to select it, and changes neither the reader's selection nor its composition; fails_when=select() moves the selection and renders a Study item while the Learning summary still shows the browser replay (Cycle 0052 task 18); why_new=the Reload mark's select row was untested; seam=a Reload whose debugger replacement is held or failed, the reader's select; no_claim=the step list's native rendering + test(`selecting a step while a same-study Reload is ${ending === 'held' ? 'loading' : 'failed'} is refused and changes nothing`, async () => { + // Arrange + const harness = await recordedReaderHarness(true) + let load: Awaited>['load'] | undefined + try { + const reloading = await reloadHeldOrFailed(harness, ending) + load = reloading.load + const itemBefore = harness.reader.inspect().view!.model.selection.itemId + const compositionBefore = compositionOf(harness) + + // Act + const refusal = await harness.reader + .select('repository/interface/theme') + .then( + () => undefined, + (error: unknown) => + // The reader's errors come from its own module context, so `instanceof Error` cannot tell them. + typeof error === 'object' && error !== null && 'message' in error + ? String(error.message) + : '', + ) + + // Assert + assert.deepEqual( + { + refusal, + itemChanged: + harness.reader.inspect().view!.model.selection.itemId !== + itemBefore, + compositionChanged: + JSON.stringify(compositionOf(harness)) !== + JSON.stringify(compositionBefore), + }, + { + refusal: + 'The recording is not loaded. Select this step once it has loaded again.', + itemChanged: false, + compositionChanged: false, + }, + ) + } finally { + load?.release() + await harness.close() + } + }) +} + +// Value: protects=an agent reading the presentation state during a same-study Reload is told the browser replay is shown (tab recording, not following the run) and, once the load failed, the failed recording's reason; fails_when=presentationState reads the selection's tab, can say it follows the current run, or gives the captured-source reason for a recording that did not load (Cycle 0052 task 18); why_new=the Reload mark's presentation row was untested; seam=a Reload whose debugger replacement is held then failed, presentationState; no_claim=the agent's next call +test('the presentation state during a same-study Reload reads the recording tab and, once failed, the failed recording’s reason', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + let load: Awaited>['load'] | undefined + try { + const reloading = await reloadHeldOrFailed(harness, 'held') + load = reloading.load + + // Act + const whileLoading = harness.reader.presentationState().reader + load.fail() + await reloading.reload + await waitUntil( + () => harness.controller.state().readiness === 'failed', + 'the Reload’s load fails', + READER_RESULT_BOUND_MS, + ) + const afterFailure = harness.reader.presentationState() + + // Assert + assert.deepEqual( + { + whileLoading: { + contentTab: whileLoading.contentTab, + followingCurrentRun: whileLoading.followingCurrentRun, + }, + afterFailure: { + contentTab: afterFailure.reader.contentTab, + followingCurrentRun: afterFailure.reader.followingCurrentRun, + reason: afterFailure.reason, + }, + }, + { + whileLoading: { contentTab: 'recording', followingCurrentRun: false }, + afterFailure: { + contentTab: 'recording', + followingCurrentRun: false, + reason: + 'The recording is not shown: it could not be loaded (The historical debugger did not start.). Open the recording again, then restore the layout.', + }, + }, + ) + } finally { + load?.release() + await harness.close() + } +}) + +// Value: protects=Return to Live after a same-study Reload's load failed ends the failed replay: the summary reads its placeholder and a step can be selected again; fails_when=the Reload mark outlives the Return, so selecting a step is refused as if the recording were still reloading (Cycle 0052 task 18); why_new=the Reload mark's failed cell was only left through a retry or Close; seam=a Reload whose debugger replacement is held then failed, the learner's return-current action; no_claim=the Live composition's panes, which need a paused live run +test('Return to Live after a failed same-study Reload ends the failed replay', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + await reloadHeldOrFailed(harness, 'failed') + + // Act + await harness.reader.action('return-current') + await harness.controller.settle() + const selectionRefusal = await harness.reader + .select('repository/interface/theme') + .then( + () => undefined, + (error: unknown) => + // The reader's errors come from its own module context, so `instanceof Error` cannot tell them. + typeof error === 'object' && error !== null && 'message' in error + ? String(error.message) + : '', + ) + + // Assert + assert.deepEqual( + { + recording: harness.controller.state().studyId, + summaryPlaceholder: harness.summary.webview.html.includes( + 'Open saved learning from Codebook to view its summary.', + ), + selectionRefusal, + }, + { + recording: undefined, + summaryPlaceholder: true, + selectionRefusal: undefined, + }, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=a test replay opened after a same-study Reload's load failed asks to close the failed browser replay first, and answering Close browser replay opens the test replay; fails_when=the failed Reload counts as no browser replay and no notice is shown, or the answer leaves the browser replay in place (Cycle 0052 task 18); why_new=the Reload mark's failed cell had only the loading cell tested for the test replay entry; seam=a Reload whose debugger replacement is held then failed, answerWarningDialogs choosing Close browser replay; no_claim=the native notification +test('a test replay opened after a failed same-study Reload asks to close the browser replay and then opens', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await reloadHeldOrFailed(harness, 'failed') + let noticesShown = 0 + harness.answerWarningDialogs(async () => { + noticesShown++ + return 'Close browser replay' + }) + + // Act + await harness.reader.openLearningReplay(history.studyId) + await landsOn(harness, 2, 'the test replay lands on its first stop') + + // Assert + assert.deepEqual( + { + noticesShown, + testReplay: harness.reader.inspect().view?.nodeHistory?.header.studyId, + recording: harness.controller.state().studyId, + family: lastView(harness)?.replayFamily, + }, + { + noticesShown: 1, + testReplay: history.studyId, + recording: undefined, + family: 'test', + }, + ) + } finally { + await harness.close() + } +}) + +/** + * Reads the parts of the composition that show whether a closed or reopened reader holds a replay: the panes, and whether the Learning summary shows a replay's transport (the summary view's own visibility is kept by the host after a Close). + * @param harness - The workspace harness. + * @returns Whether the Study pane and the Application pane show, and whether the summary shows a replay's transport. + * @example replayFreeComposition(harness) // => { studyPane: true, application: true, summaryShowsReplay: false } + */ +function replayFreeComposition(harness: Harness) { + const { studyPane, application } = compositionOf(harness) + return { + studyPane, + application, + summaryShowsReplay: harness.summary.webview.html.includes( + 'id="recording-transport"', + ), + } +} + +// Value: protects=Close while an Open replay entry waits for the work window's focus inside the summary composition's entry leaves no summary composition behind, and the reader the learner opens again stands in the Study composition with no replay; fails_when=the entry resumes after Close and builds the composition for a closed reader, or the reopened reader still holds the replay (Cycle 0052 task 12); why_new=Close was tested against waiting entries (the notice) but not against an entry already entering; seam=changeWindowFocus(false) holding the entry in its focus wait; no_claim=the native window manager +test('Close during an Open replay entry that waits for the window focus leaves no summary composition', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.open() + harness.changeWindowFocus(false) + const entry = harness.reader.openLearningReplay(history.studyId) + await new Promise((resolve) => { + setTimeout(resolve, 150) // gstack-timing-ok: the entry has no observable mark while it waits for the focus; the wait itself is bounded by RECORDING_WINDOW_FOCUS_WAIT_MS + }) + + // Act: the learner closes the reader while the entry waits, the window gets its focus back, and the learner opens the reader again. + await harness.reader.close() + harness.changeWindowFocus(true) + await entry + const afterClose = { + opened: harness.reader.inspect().opened, + ...replayFreeComposition(harness), + } + await harness.reader.open() + + // Assert + assert.deepEqual( + { + afterClose, + reopened: replayFreeComposition(harness), + testReplay: harness.reader.inspect().view?.nodeHistory?.header.studyId, + }, + { + afterClose: { + opened: false, + studyPane: false, + application: false, + summaryShowsReplay: false, + }, + reopened: { + studyPane: true, + application: true, + summaryShowsReplay: false, + }, + testReplay: undefined, + }, + ) + } finally { + harness.changeWindowFocus(true) + await harness.close() + } +}) + +/** + * Starts a test replay entry whose open is held at the reader's debugger replacement, with a live run current. + * @param harness - The workspace harness. + * @returns The entry, and `release` to let its open go on. + * @example const opening = await startHeldTestReplayOpen(harness); opening.release() + */ +async function startHeldTestReplayOpen(harness: Harness) { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + const receipt = harness.service.start({ + instanceId: harness.service.instanceId, + profile: PROFILE_ID, + configuration: harness.configuration, + key: randomUUID(), + }) + await harness.service.record(receipt).preparation + await harness.reader.open() + const seams = readerSeams(harness) + const replace = seams.debuggerReplacement + let release = (): void => {} + const held = new Promise((resolve) => { + release = resolve + }) + let entered = (): void => {} + const opening = new Promise((resolve) => { + entered = resolve + }) + seams.debuggerReplacement = async (signal?: AbortSignal): Promise => { + // Only this open is held: later loads start the debugger as usual. + seams.debuggerReplacement = replace + entered() + await held + return replace(signal) + } + const entry = harness.reader.openLearningReplay(history.studyId) + await opening + return { entry, release, studyId: history.studyId } +} + +// Value: protects=Close while a test replay entry is already opening its history leaves the reader closed, and the reader the learner opens again shows no test replay and no summary composition; fails_when=the opening entry resumes after Close, selects the replay and builds a composition for a closed reader (Cycle 0052 task 12); why_new=Close was tested against waiting entries, not an entry inside openNodeHistory; seam=the open held at the debugger replacement, the learner's close and open; no_claim=the native pane's pixels +test('Close during a test replay open leaves the reader closed and it opens again with no replay', async () => { + // Arrange + const harness = await workspaceHarness() + let release = (): void => {} + try { + const opening = await startHeldTestReplayOpen(harness) + release = opening.release + + // Act + await harness.reader.close() + opening.release() + await opening.entry + const afterClose = { + opened: harness.reader.inspect().opened, + ...replayFreeComposition(harness), + } + await harness.reader.open() + + // Assert + assert.deepEqual( + { + afterClose, + reopened: replayFreeComposition(harness), + testReplay: harness.reader.inspect().view?.nodeHistory?.header.studyId, + errors: [...harness.errorMessages], + }, + { + afterClose: { + opened: false, + studyPane: false, + application: false, + summaryShowsReplay: false, + }, + reopened: { + studyPane: true, + application: true, + summaryShowsReplay: false, + }, + testReplay: undefined, + errors: [], + }, + ) + } finally { + release() + await harness.close() + } +}) + +for (const requester of ['learner', 'agent'] as const) { + // Value: protects=a Return to Live (the learner's action or an agent's returnToCurrent) while a test replay entry is already opening its history ends that open at once: the reader leaves the summary composition for Live, ends its loading state, gives the Learning summary its placeholder, selects no replay and tells no failure or control error; fails_when=the opening marker keeps the summary composition through the Return, so the reader stays loading with the opening replay's summary in Live after the open ends superseded (Cycle 0052 task 30, found by the pt1 /ship state-machine cell entry-opening × Return); why_new=the Return was tested against entering and waiting entries, not an entry inside openNodeHistory; seam=the open held at the debugger replacement, a configured live run; no_claim=the Live composition's native panes + test(`${requester === 'agent' ? 'an agent' : 'a learner'}’s Return to Live during a test replay open ends the open in Live with no replay`, async () => { + // Arrange + const harness = await workspaceHarness() + let release = (): void => {} + try { + const opening = await startHeldTestReplayOpen(harness) + release = opening.release + + // Act + if (requester === 'learner') await harness.reader.action('return-current') + else { + const state = harness.reader.presentationState() + await harness.reader.returnToCurrent( + { + presentationId: state.presentationId, + expectedRevision: state.reader.contentRevision, + }, + 'agent', + ) + } + opening.release() + await opening.entry + + // Assert + assert.deepEqual( + { + loading: readerSeams(harness).isLoading(), + contentTab: harness.reader.presentationState().reader.contentTab, + testReplay: + harness.reader.inspect().view?.nodeHistory?.header.studyId, + summaryPlaceholder: harness.summary.webview.html.includes( + 'Open saved learning from Codebook to view its summary.', + ), + controlErrors: readerSeams(harness).errorLines(), + toldFailures: [...harness.errorMessages], + }, + { + loading: false, + contentTab: 'live', + testReplay: undefined, + summaryPlaceholder: true, + controlErrors: [], + toldFailures: [], + }, + ) + } finally { + release() + await harness.close() + } + }) +} diff --git a/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts b/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts index a14732a61..111a04783 100644 --- a/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts @@ -1166,3 +1166,40 @@ test('a waiting slot tab the learner closes after Cursor rebuilt the tabs and op await harness.close() } }) + +// Value: protects=a refused close of the slot tabs that waited for the hidden editor area does not stop the explicit display that ended the wait: the chosen study is shown and the refusal is told on the layout line; fails_when=closeSlotTabsAwaitingArea lets the editor's refusal escape and the display rejects (Cycle 0052 task 15); why_new=the other wait tests accept every close, so the new catch is never reached; seam=refuseCloseOfTabsShowing on the slot tab, the reader's source action; no_claim=the wording of the editor's refusal +test('a refused close of the slot tab that waited for the editor area is told and does not stop the display', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + let acceptClose = (): void => {} + try { + await chooseBrowserStudyWithPanelMaximized(harness) + acceptClose = harness.refuseCloseOfTabsShowing('/node-history-slot/') + + // Act + await harness.reader.action('source') + + // Assert + assert.deepEqual( + { + slotTabsAfter: slotTabCount(harness), + studyShown: + harness.reader.inspect().view?.model.selection.studyId === + harness.studyId, + layoutLine: [...(harness.reader.inspect().view?.errors ?? [])].filter( + (line) => line.includes('previous recorded source tab'), + ), + }, + { + slotTabsAfter: 1, + studyShown: true, + layoutLine: [ + 'Error: The previous recorded source tab could not close. Arrange the Codebook panes manually. Error: The editor kept an earlier recorded source tab.', + ], + }, + ) + } finally { + acceptClose() + await harness.close() + } +}) From 0a8d85dea7e1d1ce693a5bf35fee5bd27218006e Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 14:50:04 +0900 Subject: [PATCH 18/30] fix: Reload and Retry load a same-study Reload's recording again after it failed, and the Live tab leaves it (Cycle 0052 tasks 31, 32; pt1 /ship review pass 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reloadShownRecording(): the Reload action works whenever the browser replay is shown (browserReplayShown()), reloading the recording the replay last asked for; before, a Reload whose load failed before the recording was active, or a second press while it loaded, threw "Select a recording first." (task 31). - Retry storage / refresh during a loading or failed Reload loads the recording again instead of rendering a Study selection under the replay; the 'tab' handler's wasRecording reads browserReplayShown(), so choosing Live re-arranges the reader (task 32). - returnCurrentSelection clears an opening test replay's summary unconditionally (the recordingActive guard was unreachable after leavePlayback()). - Tests: F1-F4 red on 40d30aa1; cells reload-failed × agent Return, reload-loading × learner/agent Return, reload-failed × Close and × refresh (parametrized over held and failed), each red under a mutation of its guarding line; the Close-during-entry test now holds a standing composition's focus wait (red when Close keeps the entry ticket), with the harness counting the extension's unfocused window reads instead of fixed waits; the round-8 focus test waits on that count instead of a 200 ms timer. - README line 76: Reload and Retry after a failed reload, and Live leaving it. Refs #124 --- extension/README.md | 2 +- extension/src/studyWorkspace.ts | 38 +- extension/src/tests/studyWorkspaceHarness.ts | 16 +- ...tudyWorkspaceTestReplayComposition.test.ts | 475 ++++++++++++++---- 4 files changed, 419 insertions(+), 112 deletions(-) diff --git a/extension/README.md b/extension/README.md index 5809bed8c..2c131a8f8 100644 --- a/extension/README.md +++ b/extension/README.md @@ -73,7 +73,7 @@ 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. 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. 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, so it neither asks a second time nor reopens the reader. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view and the transport never shows the other replay family's controls; after a browser replay the Application tab takes back its name `codebook · Application`. Reloading a browser replay keeps its Learning summary while the recording reloads. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, and a step you select before the recording has loaded again is refused until it has. 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. 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. 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. 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, so it neither asks a second time nor reopens the reader. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view and the transport never shows the other replay family's controls; after a browser replay the Application tab takes back its name `codebook · Application`. Reloading a browser replay keeps its Learning summary while the recording reloads. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, 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, and choosing Live leaves the reloading replay for the Live composition. 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. 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. diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 5af4a56c7..d62a829b4 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -8910,14 +8910,7 @@ export class StudyWorkspace { return } if (action === 'reload-recording') { - const studyId = this.selection.studyId - if (!this.recordingActive || !studyId) - throw new Error('Select a recording first.') - // Same-study Reload keeps its Summary document so a failed read can publish recovery there. - this.leavePlayback(true) - // The mark lasts until the reloaded recording is active again ({@link loadPlayback}) or a departure supersedes it; a failed load keeps it, so the summary keeps showing the recovery. - this.recordingReloadStudyId = studyId - await this.openRecording(studyId) + await this.reloadShownRecording() return } if (action === 'return-current') { @@ -9142,10 +9135,33 @@ export class StudyWorkspace { this.publish() return } + // A same-study Reload whose recording did not load still shows its browser replay: Retry loads that recording again, as its Reload does, and renders no Study selection under the replay (Cycle 0052 task 32). + if (!this.recordingActive && this.browserReplayShown()) { + await this.reloadShownRecording() + return + } await this.renderSelection(true, true) } } + /** + * Loads the shown browser replay's recording again (a same-study Reload): the Summary keeps its document, so a failed read can publish its recovery there, and the Reload's mark keeps the replay shown until the recording is active again ({@link loadPlayback}) or a departure supersedes it; a failed load keeps it. Called by the Reload action and by Retry while a Reload's recording has not loaded (Cycle 0052 tasks 18 and 31). + * @returns Once the recording's open is accepted; the load itself continues in the playback controller. + * @throws When no browser replay is shown, or it names no study. + * @example await this.reloadShownRecording() + */ + private async reloadShownRecording(): Promise { + // A Reload whose recording has not loaded (still loading, or failed) retries the recording the replay last asked for. + const studyId = this.recordingActive + ? this.selection.studyId + : (this.playback?.state().studyId ?? this.selection.studyId) + if (!this.browserReplayShown() || !studyId) + throw new Error('Select a recording first.') + this.leavePlayback(true) + this.recordingReloadStudyId = studyId + await this.openRecording(studyId) + } + /** * Prepares the native Live composition and releases historical rendering when a learner or MCP control explicitly returns, before a later run starts. * @param requester - `'agent'` for codebook_return_current ({@link returnToCurrent}): the Application preparation is an agent's ({@link open} with `forAgent`), so beside a chat view it gives the keyboard to no place it cannot prove and tells once where it is. The Study pane's Return Current is the learner's: the place whose control the learner pressed is read first, and the Return's end gives it the keyboard back or says where the keyboard is ({@link settleLearnersReturnKeyboard}, row 624). @@ -9175,8 +9191,7 @@ export class StudyWorkspace { this.pendingHistory = undefined // A test replay still opening is superseded by the request above and ends here with its opening marker, loading state and the summary it drew, so this Return arranges Live at once; its open finds itself superseded and changes nothing more (Cycle 0052 task 30). if (this.pendingNodeHistory !== undefined) { - if (this.summaryComposition && !this.recordingActive) - this.clearLearningSummary() + this.clearLearningSummary() this.pendingNodeHistory = undefined this.pendingNodeHistoryStudyId = undefined this.loading = false @@ -23181,7 +23196,8 @@ export class StudyWorkspace { throw new Error('Select a saved study before opening its recording.') await this.openRecording(studyId) } else { - const wasRecording = this.recordingActive + // A same-study Reload that is loading or failed shows its browser replay too: leaving it arranges the reader again (Cycle 0052 task 32). + const wasRecording = this.browserReplayShown() this.leavePlayback() // Leaving Recording retires the line of a finished removal, as choosing another entry does: a refusal that told the learner to leave Recording first is no longer true under a delete button that can be used again. if (wasRecording) this.dismissStudyRemoval() diff --git a/extension/src/tests/studyWorkspaceHarness.ts b/extension/src/tests/studyWorkspaceHarness.ts index 669ef1391..3ab630c57 100644 --- a/extension/src/tests/studyWorkspaceHarness.ts +++ b/extension/src/tests/studyWorkspaceHarness.ts @@ -1273,6 +1273,18 @@ export async function workspaceHarness( | undefined const windowState = { focused: true, active: true }, windowListeners = new Set<(state: typeof windowState) => void>() + /** How many times the extension read `vscode.window.state.focused` while the window had no focus; a test waits on it to act while the reader waits for the focus. */ + let unfocusedWindowReads = 0 + /** The window state the extension reads: the harness's own state, with each unfocused read counted. */ + const windowStateForExtension = { + get focused(): boolean { + if (!windowState.focused) unfocusedWindowReads++ + return windowState.focused + }, + get active(): boolean { + return windowState.active + }, + } const panelReveals: { kind: string column?: number @@ -4573,7 +4585,7 @@ export async function workspaceHarness( ? saveStatus : createStatusItem() }, - state: windowState, + state: windowStateForExtension, onDidChangeWindowState: ( callback: (state: typeof windowState) => void, ) => { @@ -5594,6 +5606,8 @@ export async function workspaceHarness( for (const listener of windowListeners) listener(windowState) }, windowFocusListeners: () => windowListeners.size, + /** How many times the extension read the window's focus while it had none ({@link changeWindowFocus}); see `windowStateForExtension`. @example harness.unfocusedWindowReads() // => 3 while the reader waits for the focus */ + unfocusedWindowReads: (): number => unfocusedWindowReads, delayPanelRegistration(kind: string, deferred = false) { delayedPanelKind = kind deferredPanelRegistration = deferred diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 583a9e4c5..283602ecc 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -1724,48 +1724,34 @@ test('a test replay opened during a same-study Reload closes the reloading brows } }) -// Value: protects=the reader's periodic refresh during a same-study Reload leaves the Application pane on the recording it reloads: no Live or Study content is drawn there while the summary still shows the browser replay; fails_when=refreshPresentation renders the selection during the Reload and presentApplication writes non-recording content into the pane (local CodeRabbit round 6, major); why_new=Cycle 0052 task 18's Reload mark; seam=the reload held at its debugger replacement, the reader's refresh; no_claim=the native pane's pixels -test('a refresh during a same-study Reload keeps the Application pane on the recording', async () => { - // Arrange - const harness = await recordedReaderHarness(true) - let release = (): void => {} - try { - await harness.reader.openRecording(harness.studyId) - await harness.controller.settle() - const seams = readerSeams(harness) - const replace = seams.debuggerReplacement - const held = new Promise((resolve) => { - release = resolve - }) - let entered = (): void => {} - const reloadWaits = new Promise((resolve) => { - entered = resolve - }) - seams.debuggerReplacement = async (signal?: AbortSignal): Promise => { - entered() - await held - return replace(signal) - } - const application = harness - .nativePanels() - .find((panel) => panel.kind === 'codebook.scopedBrowser')! - const reload = harness.reader.action('reload-recording') - await reloadWaits - const documentBeforeRefresh = application.webview.html +for (const ending of ['held', 'failed'] as const) { + // Value: protects=the reader's periodic refresh during a same-study Reload, loading or failed, leaves the Application pane on the recording it reloads: no Live or Study content is drawn there while the summary still shows the browser replay; fails_when=refreshPresentation renders the selection during the Reload and presentApplication writes non-recording content into the pane (local CodeRabbit round 6, major); why_new=Cycle 0052 task 18's Reload mark; seam=the reload held at, or failed at, its debugger replacement, the reader's refresh; no_claim=the native pane's pixels + test(`a refresh during a same-study Reload${ending === 'held' ? '' : ' that failed'} keeps the Application pane on the recording`, async () => { + // Arrange + const harness = await recordedReaderHarness(true) + let load: Awaited>['load'] | undefined + try { + const reloading = await reloadHeldOrFailed(harness, ending) + load = reloading.load + const application = harness + .nativePanels() + .find((panel) => panel.kind === 'codebook.scopedBrowser')! + const documentBeforeRefresh = application.webview.html - // Act - await readerSeams(harness).refresh() - const documentAfterRefresh = application.webview.html - release() - await reload + // Act + await readerSeams(harness).refresh() + const documentAfterRefresh = application.webview.html + load.release() + await reloading.reload - // Assert - assert.equal(documentAfterRefresh, documentBeforeRefresh) - } finally { - release() - await harness.close() - } -}) + // Assert + assert.equal(documentAfterRefresh, documentBeforeRefresh) + } finally { + load?.release() + await harness.close() + } + }) +} // Value: protects=an agent's Return to Live that supersedes one test replay entry entering the summary composition and a second one waiting for its turn leaves no empty summary composition, whichever of them ends last; fails_when=the entering one skips the leave because the waiting one is still counted, and the waiting one ends at its ticket check without leaving (local CodeRabbit round 7, minor); why_new=Cycle 0052 task 12; seam=the first entry held at enterSummaryCompositionForReplay, a configured live run, the agent's returnToCurrent; no_claim=the native timing of the agent's call test('an agent’s Return to Live that supersedes an entering and a waiting test replay entry leaves no empty summary composition', async () => { @@ -1835,7 +1821,7 @@ test('an agent’s Return to Live that supersedes an entering and a waiting test } }) -// Value: protects=Open replay made while another test replay is shown and the work window has lost the desktop focus waits for the focus before showing the summary, as an open from a closed reader does, instead of failing at once; fails_when=the entry into the standing composition reveals the summary unfocused and is told as a failed open (local CodeRabbit round 8, minor); why_new=Cycle 0052 task 14's reveal; seam=changeWindowFocus; no_claim=the native window manager +// Value: protects=Open replay made while another test replay is shown and the work window has lost the desktop focus waits for the focus before showing the summary, as an open from a closed reader does, instead of failing at once; fails_when=the entry into the standing composition reveals the summary unfocused and is told as a failed open (local CodeRabbit round 8, minor); why_new=Cycle 0052 task 14's reveal; seam=changeWindowFocus, the harness's count of unfocused window reads; no_claim=the native window manager test('Open replay into a standing composition waits for the work window’s focus before showing the summary', async () => { // Arrange const harness = await workspaceHarness() @@ -1848,8 +1834,14 @@ test('Open replay into a standing composition waits for the work window’s focu harness.changeWindowFocus(false) // Act: the learner clicks back into the window a moment after Open replay. + const readsBefore = harness.unfocusedWindowReads() const entry = harness.reader.openLearningReplay(second.studyId) - setTimeout(() => harness.changeWindowFocus(true), 200) // gstack-timing-ok: the learner's click comes after the entry began waiting; the wait itself is bounded by RECORDING_WINDOW_FOCUS_WAIT_MS + await waitUntil( + () => harness.unfocusedWindowReads() > readsBefore, + 'the entry waits for the window focus', + READER_RESULT_BOUND_MS, + ) + harness.changeWindowFocus(true) await entry await waitUntil( () => @@ -1928,53 +1920,36 @@ test('a learner’s Return to Live that supersedes an entering test replay tells } }) -// Value: protects=Close ends a same-study Reload's mark, so a reader the learner opens again afterwards stands in the Study composition and not in a summary composition for a recording that is gone; fails_when=close() keeps the Reload mark and the reopened reader closes its Study pane for a browser replay nobody shows (Cycle 0052 task 18); why_new=the Reload tests only end the mark through the load or a Return, none through Close; seam=the reload held at its debugger replacement, the reader's close and open; no_claim=the native pane's pixels -test('a reader closed during a same-study Reload opens again in the Study composition', async () => { - // Arrange - const harness = await recordedReaderHarness(true) - let release = (): void => {} - try { - await harness.reader.openRecording(harness.studyId) - await harness.controller.settle() - const seams = readerSeams(harness) - const replace = seams.debuggerReplacement - const held = new Promise((resolve) => { - release = resolve - }) - let entered = (): void => {} - const reloadWaits = new Promise((resolve) => { - entered = resolve - }) - seams.debuggerReplacement = async (signal?: AbortSignal): Promise => { - entered() - await held - return replace(signal) - } - const reload = harness.reader.action('reload-recording').then( - () => undefined, - () => undefined, - ) - await reloadWaits +for (const ending of ['held', 'failed'] as const) { + // Value: protects=Close ends a same-study Reload's mark, loading or failed, so a reader the learner opens again afterwards stands in the Study composition and not in a summary composition for a recording that is gone; fails_when=close() keeps the Reload mark and the reopened reader closes its Study pane for a browser replay nobody shows (Cycle 0052 task 18); why_new=the Reload tests only end the mark through the load or a Return, none through Close; seam=the reload held at, or failed at, its debugger replacement, the reader's close and open; no_claim=the native pane's pixels + test(`a reader closed during a same-study Reload${ending === 'held' ? '' : ' that failed'} opens again in the Study composition`, async () => { + // Arrange + const harness = await recordedReaderHarness(true) + let load: Awaited>['load'] | undefined + try { + const reloading = await reloadHeldOrFailed(harness, ending) + load = reloading.load - // Act: the learner closes the reader while the Reload loads, then opens it again. - await harness.reader.close() - release() - await reload - await harness.reader.open() + // Act: the learner closes the reader during the Reload, then opens it again. + await harness.reader.close() + load.release() + await reloading.reload + await harness.reader.open() - // Assert - assert.deepEqual( - { - studyPane: harness.panelColumn('codebook.studyContext') !== undefined, - replayFamily: lastView(harness)?.replayFamily, - }, - { studyPane: true, replayFamily: undefined }, - ) - } finally { - release() - await harness.close() - } -}) + // Assert + assert.deepEqual( + { + studyPane: harness.panelColumn('codebook.studyContext') !== undefined, + replayFamily: lastView(harness)?.replayFamily, + }, + { studyPane: true, replayFamily: undefined }, + ) + } finally { + load?.release() + await harness.close() + } + }) +} // Value: protects=a test replay open that fails while an agent's presentation change is in flight tells its failure once and adds no control error: the failed open's leave of the empty summary composition waits for the presentation change, which arranges the reader itself; fails_when=leaveEmptySummaryComposition runs open() during the presentation change, which refuses as pending and reports a control error beside the told failure (Cycle 0052 task 12); why_new=the Return guard test covers returningCurrent only, none holds a presentation change; seam=readerSeams.debuggerReplacement held then failed, the agent's Restore of the Study workspace (changePresentation) admitted while the replay opens; no_claim=the native layout of the presentation change test('a test replay open that fails during an agent’s presentation change adds no control error', async () => { @@ -2412,21 +2387,23 @@ function replayFreeComposition(harness: Harness) { } } -// Value: protects=Close while an Open replay entry waits for the work window's focus inside the summary composition's entry leaves no summary composition behind, and the reader the learner opens again stands in the Study composition with no replay; fails_when=the entry resumes after Close and builds the composition for a closed reader, or the reopened reader still holds the replay (Cycle 0052 task 12); why_new=Close was tested against waiting entries (the notice) but not against an entry already entering; seam=changeWindowFocus(false) holding the entry in its focus wait; no_claim=the native window manager -test('Close during an Open replay entry that waits for the window focus leaves no summary composition', async () => { +// Value: protects=Close while an Open replay entry into a standing summary composition waits for the work window's focus ends that entry: the reader closes, the entry neither reopens it nor tells a failure, and the reader the learner opens again shows the replay it showed before Close, never the entry's; fails_when=the entry resumes after Close and reopens the closed reader, shows its own replay, or tells a failed open (Cycle 0052 task 12); why_new=Close was tested against waiting entries (the notice) but not against an entry already entering; seam=changeWindowFocus(false) holding the entry in its focus wait, the harness's count of unfocused window reads; no_claim=the native window manager +test('Close during an Open replay entry that waits for the window focus ends the entry, and the reopened reader shows the earlier replay', async () => { // Arrange const harness = await workspaceHarness() try { - const history = await saveTwoStopNodeHistory( + const second = await saveTwoStopNodeHistory( harness.service.nodeHistoryStore!, ) - await harness.reader.action('retry') - await harness.reader.open() + const first = await openByOpenReplay(harness) harness.changeWindowFocus(false) - const entry = harness.reader.openLearningReplay(history.studyId) - await new Promise((resolve) => { - setTimeout(resolve, 150) // gstack-timing-ok: the entry has no observable mark while it waits for the focus; the wait itself is bounded by RECORDING_WINDOW_FOCUS_WAIT_MS - }) + const readsBefore = harness.unfocusedWindowReads() + const entry = harness.reader.openLearningReplay(second.studyId) + await waitUntil( + () => harness.unfocusedWindowReads() > readsBefore, + 'the entry waits for the window focus', + READER_RESULT_BOUND_MS, + ) // Act: the learner closes the reader while the entry waits, the window gets its focus back, and the learner opens the reader again. await harness.reader.close() @@ -2444,6 +2421,8 @@ test('Close during an Open replay entry that waits for the window focus leaves n afterClose, reopened: replayFreeComposition(harness), testReplay: harness.reader.inspect().view?.nodeHistory?.header.studyId, + toldFailures: [...harness.errorMessages], + controlErrors: readerSeams(harness).errorLines(), }, { afterClose: { @@ -2453,11 +2432,13 @@ test('Close during an Open replay entry that waits for the window focus leaves n summaryShowsReplay: false, }, reopened: { - studyPane: true, + studyPane: false, application: true, - summaryShowsReplay: false, + summaryShowsReplay: true, }, - testReplay: undefined, + testReplay: first.studyId, + toldFailures: [], + controlErrors: [], }, ) } finally { @@ -2609,3 +2590,299 @@ for (const requester of ['learner', 'agent'] as const) { } }) } + +/** + * Says what an error thrown out of the reader reads, or `undefined` when nothing was thrown. + * @param operation - The reader call whose refusal is observed. + * @returns The error's message, `''` for a throw without one, `undefined` when the call succeeded. + * @example await refusalOf(() => harness.reader.select('repository/interface/theme')) // => undefined + */ +async function refusalOf( + operation: () => Promise, +): Promise { + return operation().then( + () => undefined, + (error: unknown) => + // The reader's errors come from its own module context, so `instanceof Error` cannot tell them. + typeof error === 'object' && error !== null && 'message' in error + ? String(error.message) + : '', + ) +} + +/** + * Reads what the browser replay shows now: the recording's readiness, whether the Learning summary draws its transport, and the replay family told. + * @param harness - The recorded reader harness. + * @returns The observations. + * @example browserReplayObservations(harness) // => { readiness: 'ready', transport: true, family: 'browser' } + */ +function browserReplayObservations(harness: RecordedHarness) { + return { + readiness: harness.controller.state().readiness, + transport: harness.summary.webview.html.includes( + 'id="recording-transport"', + ), + family: lastView(harness)?.replayFamily, + } +} + +/** + * Opens the Reload fixture and ends the hold, so a test can look at the browser replay once every load has ended. + * @param harness - The recorded reader harness. + * @example await settleReload(harness, reloading) + */ +async function settleReload( + harness: RecordedHarness, + reloading: Awaited>, +): Promise { + reloading.load.release() + await reloading.reload + await harness.controller.settle() +} + +/** The Learning summary's text while the reader shows no replay. */ +const SUMMARY_PLACEHOLDER = + 'Open saved learning from Codebook to view its summary.' + +// Value: protects=Reload pressed again after a same-study Reload's load failed loads the recording again, so the learner is not stuck on the failed replay with a refusal; fails_when=the Reload action refuses with "Select a recording first." because no recording is active after the failed load (Cycle 0052 task 31); why_new=the Reload mark's failed cell had no second Reload; seam=a Reload whose debugger replacement is failed once, then the Reload action again; no_claim=the native pane's pixels +test('Reload pressed again after a failed same-study Reload loads the recording', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + const reloading = await reloadHeldOrFailed(harness, 'failed') + + // Act + const refusal = await refusalOf(() => + harness.reader.action('reload-recording'), + ) + await waitUntil( + () => harness.controller.state().readiness === 'ready', + 'the second Reload loads the recording', + READER_RESULT_BOUND_MS, + ).catch(() => undefined) + await settleReload(harness, reloading) + + // Assert + assert.deepEqual( + { refusal, ...browserReplayObservations(harness) }, + { + refusal: undefined, + readiness: 'ready', + transport: true, + family: 'browser', + }, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=Reload pressed again while a same-study Reload still loads is not refused and restarts the load, so the recording ends loaded and shown once; fails_when=the Reload action refuses with "Select a recording first." while no recording is active, or the held first load comes back over the second (Cycle 0052 task 31); why_new=the Reload mark's loading cell had no second Reload; seam=the first load held at its debugger replacement, the Reload action again; no_claim=the native pane's pixels +test('Reload pressed again during a same-study Reload is not refused and ends with the recording shown once', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + const reloading = await reloadHeldOrFailed(harness, 'held') + + // Act + const refusal = await refusalOf(() => + harness.reader.action('reload-recording'), + ) + await settleReload(harness, reloading) + await waitUntil( + () => harness.controller.state().readiness === 'ready', + 'the recording is loaded', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.deepEqual( + { + refusal, + ...browserReplayObservations(harness), + transportsDrawn: + harness.summary.webview.html.split('id="recording-transport"') + .length - 1, + }, + { + refusal: undefined, + readiness: 'ready', + transport: true, + family: 'browser', + transportsDrawn: 1, + }, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=Retry storage after a same-study Reload's load failed loads the recording again and never draws a Study selection under the browser summary; fails_when=the retry renders the selection while the replay is shown, so the presentation leaves the recording tab and the recording stays failed (Cycle 0052 task 31); why_new=the Reload mark's failed cell had no Retry; seam=a Reload whose debugger replacement is failed once, the retry action; no_claim=the native pane's pixels +test('Retry after a failed same-study Reload loads the recording and keeps the recording tab', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + const reloading = await reloadHeldOrFailed(harness, 'failed') + + // Act + const refusal = await refusalOf(() => harness.reader.action('retry')) + await waitUntil( + () => harness.controller.state().readiness === 'ready', + 'the retry loads the recording', + READER_RESULT_BOUND_MS, + ).catch(() => undefined) + await settleReload(harness, reloading) + + // Assert + assert.deepEqual( + { + refusal, + ...browserReplayObservations(harness), + contentTab: harness.reader.presentationState().reader.contentTab, + }, + { + refusal: undefined, + readiness: 'ready', + transport: true, + family: 'browser', + contentTab: 'recording', + }, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=choosing the Live tab after a same-study Reload's load failed leaves the summary composition: the Study pane is back, the summary reads its placeholder and the presentation reads the Live tab; fails_when=the tab message sees no recording and leaves the reader arranged for a browser replay that is gone (Cycle 0052 task 31); why_new=the Reload mark's failed cell had no tab choice; seam=a Reload whose debugger replacement is failed once, the Study pane's tab message; no_claim=the Live composition's native panes +test('choosing the Live tab after a failed same-study Reload leaves the summary composition', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + await reloadHeldOrFailed(harness, 'failed') + + // Act + harness.receive({ + type: 'tab', + tab: 'live', + revision: harness.reader.inspect().view!.revision, + }) + await waitUntil( + () => harness.panelColumn('codebook.studyContext') !== undefined, + 'the Study pane is back', + READER_RESULT_BOUND_MS, + ).catch(() => undefined) + await harness.controller.settle() + + // Assert + assert.deepEqual( + { + studyPane: harness.panelColumn('codebook.studyContext') !== undefined, + family: lastView(harness)?.replayFamily, + summaryPlaceholder: + harness.summary.webview.html.includes(SUMMARY_PLACEHOLDER), + contentTab: harness.reader.presentationState().reader.contentTab, + }, + { + studyPane: true, + family: undefined, + summaryPlaceholder: true, + contentTab: 'live', + }, + ) + } finally { + await harness.close() + } +}) + +// Value: protects=an agent's Return to Live after a same-study Reload's load failed ends the failed replay: the presentation reads the Live tab, the summary its placeholder, and a step can be selected again; fails_when=the Return leaves the Reload mark, so the presentation keeps the recording tab or the step selection is still refused as not loaded (Cycle 0052 task 31); why_new=the failed cell was tested with the learner's Return only; seam=a Reload whose debugger replacement is failed once, the agent's returnToCurrent; no_claim=the Live composition's panes +test('an agent’s Return to Live after a failed same-study Reload ends the failed replay', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + await reloadHeldOrFailed(harness, 'failed') + const state = harness.reader.presentationState() + + // Act + const refusal = await refusalOf(() => + harness.reader.returnToCurrent( + { + presentationId: state.presentationId, + expectedRevision: state.reader.contentRevision, + }, + 'agent', + ), + ) + await harness.controller.settle() + const selectionRefusal = await refusalOf(() => + harness.reader.select('repository/interface/theme'), + ) + + // Assert + assert.deepEqual( + { + refusal, + contentTab: harness.reader.presentationState().reader.contentTab, + readiness: harness.controller.state().readiness, + summaryPlaceholder: + harness.summary.webview.html.includes(SUMMARY_PLACEHOLDER), + selectionRefusal, + }, + { + refusal: undefined, + contentTab: 'live', + readiness: 'idle', + summaryPlaceholder: true, + selectionRefusal: undefined, + }, + ) + } finally { + await harness.close() + } +}) + +for (const requester of ['learner', 'agent'] as const) { + // Value: protects=a Return to Live, the learner's or an agent's, while a same-study Reload still loads cancels that load: once it is released the recording does not come back over Live; fails_when=the resumed Reload makes the recording active again after the Return (Cycle 0052 task 18); why_new=the Reload mark's loading cell was left through a Return in no test; seam=the reload held at its debugger replacement, the return-current action or returnToCurrent; no_claim=the Live composition's panes + test(`${requester === 'agent' ? 'an agent' : 'a learner'}’s Return to Live during a same-study Reload keeps the recording from coming back`, async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + const reloading = await reloadHeldOrFailed(harness, 'held') + const state = harness.reader.presentationState() + + // Act + const refusal = await refusalOf(() => + requester === 'learner' + ? harness.reader.action('return-current') + : harness.reader.returnToCurrent( + { + presentationId: state.presentationId, + expectedRevision: state.reader.contentRevision, + }, + 'agent', + ), + ) + // The released load's own work ends within the controller's settle: a recording that came back would be active by then. + await settleReload(harness, reloading) + + // Assert + assert.deepEqual( + { + refusal, + readiness: harness.controller.state().readiness, + transport: harness.summary.webview.html.includes( + 'id="recording-transport"', + ), + contentTab: harness.reader.presentationState().reader.contentTab, + }, + { + refusal: undefined, + readiness: 'idle', + transport: false, + contentTab: 'live', + }, + ) + } finally { + await harness.close() + } + }) +} From 5bd1db27579a4c9b628cfc02b0c514fa3f50ca31 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 15:03:39 +0900 Subject: [PATCH 19/30] fix: a runtime release and a study deletion respect a same-study Reload that is loading or failed (Cycle 0052 task 33; pt1 /ship review pass 3) - retire() renders the selection only when no browser replay is shown (browserReplayShown()), so a runtime release during a loading or failed Reload keeps the replay's Application pane. - recordingOpenOrOpening() counts a loading or failed Reload, so deleting the replay's study is refused with the Recording-open sentence until the learner leaves the replay. - Evidence, no defect: the refresh's once-per-run Live entry needs selection.follow, which a recording's open sets false and only Return or the Live tab (both ending the mark first) set true again; its test passed on 0a8d85de too, so the guard stays as it was. - Tests: a runtime release during a held and a failed Reload, deleting a failed Reload's study (red on 0a8d85de); three Reload tests no longer swallow waitUntil timeouts; the harness's unfocused-window-read counter is documented as a heuristic signal. - README line 76: the replay's study cannot be deleted until you leave it. Refs #124 --- extension/README.md | 2 +- extension/src/studyWorkspace.ts | 9 +- extension/src/tests/studyWorkspaceHarness.ts | 2 +- ...tudyWorkspaceTestReplayComposition.test.ts | 84 ++++++++++++++++++- 4 files changed, 89 insertions(+), 8 deletions(-) diff --git a/extension/README.md b/extension/README.md index 2c131a8f8..71f02cafc 100644 --- a/extension/README.md +++ b/extension/README.md @@ -73,7 +73,7 @@ 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. 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. 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, so it neither asks a second time nor reopens the reader. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view and the transport never shows the other replay family's controls; after a browser replay the Application tab takes back its name `codebook · Application`. Reloading a browser replay keeps its Learning summary while the recording reloads. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, 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, and choosing Live leaves the reloading replay for the Live composition. 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. 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. 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. 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, so it neither asks a second time nor reopens the reader. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view and the transport never shows the other replay family's controls; after a browser replay the Application tab takes back its name `codebook · Application`. Reloading a browser replay keeps its Learning summary while the recording reloads. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, 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, and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted. 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. 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. diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index d62a829b4..101f4b05d 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -8781,7 +8781,8 @@ export class StudyWorkspace { clearInterval(this.timer) this.timer = undefined } - if (this.opened && !this.recordingActive) await this.renderSelection() + // A same-study Reload that is loading or failed still shows its browser replay: no Study selection is rendered under it (Cycle 0052 task 33). + if (this.opened && !this.browserReplayShown()) await this.renderSelection() this.publishLearningSave() if (failure) throw new Error( @@ -23719,10 +23720,12 @@ export class StudyWorkspace { ) } - /** Says whether Recording is open, or an accepted open is still loading: the controller reports the load from the moment it accepts the open, before the reader marks Recording as active. @example if (this.recordingOpenOrOpening()) return STUDY_REMOVAL_REFUSALS.recordingOpen */ + /** Says whether Recording is open (a loaded recording, or a same-study Reload that is loading or failed: {@link browserReplayShown}), or an accepted open is still loading: the controller reports the load from the moment it accepts the open, before the reader marks Recording as active. @example if (this.recordingOpenOrOpening()) return STUDY_REMOVAL_REFUSALS.recordingOpen */ private recordingOpenOrOpening(): boolean { + // A same-study Reload whose load failed still shows its browser replay, whose Reload and Retry need its study (Cycle 0052 task 33). return ( - this.recordingActive || this.playback?.state().readiness === 'loading' + this.browserReplayShown() || + this.playback?.state().readiness === 'loading' ) } diff --git a/extension/src/tests/studyWorkspaceHarness.ts b/extension/src/tests/studyWorkspaceHarness.ts index 3ab630c57..1c5a160aa 100644 --- a/extension/src/tests/studyWorkspaceHarness.ts +++ b/extension/src/tests/studyWorkspaceHarness.ts @@ -1273,7 +1273,7 @@ export async function workspaceHarness( | undefined const windowState = { focused: true, active: true }, windowListeners = new Set<(state: typeof windowState) => void>() - /** How many times the extension read `vscode.window.state.focused` while the window had no focus; a test waits on it to act while the reader waits for the focus. */ + /** How many times the extension read `vscode.window.state.focused` while the window had no focus; a test waits on it to act while the reader waits for the focus. It is a heuristic signal: any reader path that reads the focus counts, so a test that waits on it asserts an outcome that holds wherever the entry stood when it acted. */ let unfocusedWindowReads = 0 /** The window state the extension reads: the harness's own state, with each unfocused read counted. */ const windowStateForExtension = { diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 283602ecc..60be173e6 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -2659,7 +2659,7 @@ test('Reload pressed again after a failed same-study Reload loads the recording' () => harness.controller.state().readiness === 'ready', 'the second Reload loads the recording', READER_RESULT_BOUND_MS, - ).catch(() => undefined) + ) await settleReload(harness, reloading) // Assert @@ -2730,7 +2730,7 @@ test('Retry after a failed same-study Reload loads the recording and keeps the r () => harness.controller.state().readiness === 'ready', 'the retry loads the recording', READER_RESULT_BOUND_MS, - ).catch(() => undefined) + ) await settleReload(harness, reloading) // Assert @@ -2770,7 +2770,7 @@ test('choosing the Live tab after a failed same-study Reload leaves the summary () => harness.panelColumn('codebook.studyContext') !== undefined, 'the Study pane is back', READER_RESULT_BOUND_MS, - ).catch(() => undefined) + ) await harness.controller.settle() // Assert @@ -2886,3 +2886,81 @@ for (const requester of ['learner', 'agent'] as const) { } }) } + +for (const ending of ['held', 'failed'] as const) { + // Value: protects=a runtime release (the run's service retiring) during a same-study Reload, loading or failed, leaves the browser replay shown: no Study selection is drawn in the Application pane under it; fails_when=retire() renders the selection because no recording is active yet, and the pane stops showing the recording the Reload loads (Cycle 0052 task 33); why_new=the Reload mark's release row was untested; seam=a Reload held at, or failed at, its debugger replacement, the reader's retire(service); no_claim=the native pane's pixels + test(`a runtime release during a same-study Reload${ending === 'held' ? '' : ' that failed'} keeps the Application pane on the recording`, async () => { + // Arrange + const harness = await recordedReaderHarness(true) + let load: Awaited>['load'] | undefined + try { + const reloading = await reloadHeldOrFailed(harness, ending) + load = reloading.load + const application = harness + .nativePanels() + .find((panel) => panel.kind === 'codebook.scopedBrowser')! + const documentBeforeRelease = application.webview.html + + // Act + await harness.reader.retire(harness.service) + const afterRelease = { + applicationUnchanged: + application.webview.html === documentBeforeRelease, + contentTab: harness.reader.presentationState().reader.contentTab, + } + await settleReload(harness, reloading) + + // Assert + assert.deepEqual(afterRelease, { + applicationUnchanged: true, + contentTab: 'recording', + }) + } finally { + load?.release() + await harness.close() + } + }) +} + +// Value: protects=deleting the study of a failed same-study Reload is refused with the Recording-open sentence and the study is not moved to the Trash, since the replay's Reload and Retry need it; fails_when=the failed Reload counts as no open recording and the study is removed under the replay (Cycle 0052 task 33); why_new=the Reload mark's removal row was untested; seam=a Reload failed at its debugger replacement, the Study pane's delete-study message; no_claim=the native Trash +test('deleting the study of a failed same-study Reload is refused and keeps the study', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + await reloadHeldOrFailed(harness, 'failed') + + // Act + await readerSeams(harness).receive( + { + type: 'delete-study', + revision: harness.reader.inspect().view!.revision, + studyId: harness.studyId, + }, + 'context', + ) + + // Assert + assert.deepEqual( + { + studyRemoval: structuredClone( + harness.reader.inspect().view!.studyRemoval, + ), + trashMoves: harness.trashMoves, + archiveKept: + (await readerSeams(harness).readSavedArchive(harness.studyId)) !== + undefined, + }, + { + studyRemoval: { + status: 'refused', + studyId: harness.studyId, + text: 'Choose Overview or Live to leave Recording before deleting this study.', + }, + trashMoves: [], + archiveKept: true, + }, + ) + } finally { + await harness.close() + } +}) From 40ecb54262ddff12ce4be0dc672f82b85427598f Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 15:30:42 +0900 Subject: [PATCH 20/30] fix: earlier test replay opens leave a newer entry's composition alone, refused waiting-slot closes are retried, and a recording opened over a test replay keeps its summary document (Cycle 0052 task 35; pt1 /ship Step 11 last-round fixes) - L1: a failed or superseded test replay open leaves the empty summary composition only when no entry is under way, so a newer Open replay entering it opens with its summary and transport (Codex C1, Claude F1). - L2: the end of a wait for the editor area re-checks lifetime, choice and a new wait after its slot close (waitEndOvertaken()); evidence, not reproduced in the harness (Close ends the timer refresh's signal), kept as a guard for refresh callers without a signal (Codex C2). - L3: a refused close of the waiting slot tabs keeps the still-shown tabs recorded, so the next display or entry closes them (Codex C3, Claude F4). - L4: an entry re-checks its ticket after its slot close, so an overtaken entry asks no one-replay-at-a-time notice (Codex C4). - L5: a browser recording loaded over a retired test replay gives the Learning summary the reader's document again (giveSummaryItsDocument()), so its loading, failure and recovery show (Codex structured P2, regression of task 2). - L6: Reload cancels a pending load of the same study before it opens the recording, so a second Reload while the first loads starts the load again (Codex structured P2). - shownTextTabAddresses() replaces the clone the dupes gate found; waitEndOvertaken() keeps showStudyAwaitingEditorArea under the cognitive limit. - Tests: L1, L3, L4, L5, L6 red on 5bd1db27; README line 76: the recording's summary over a test replay, and the retried slot close. Refs #124 --- extension/README.md | 2 +- extension/src/studyWorkspace.ts | 89 ++++++++-- ...tudyWorkspaceTestReplayComposition.test.ts | 152 +++++++++++++++++- .../studyWorkspaceTestReplayOpens.test.ts | 65 ++++++++ 4 files changed, 289 insertions(+), 19 deletions(-) diff --git a/extension/README.md b/extension/README.md index 71f02cafc..9eb5390c1 100644 --- a/extension/README.md +++ b/extension/README.md @@ -73,7 +73,7 @@ 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. 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. 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, so it neither asks a second time nor reopens the reader. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view and the transport never shows the other replay family's controls; after a browser replay the Application tab takes back its name `codebook · Application`. Reloading a browser replay keeps its Learning summary while the recording reloads. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, 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, and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted. 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. 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. 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, so it neither asks a second time nor reopens the reader. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view and the transport never shows the other replay family's controls; after a browser replay the Application tab takes back its name `codebook · Application`. Reloading a browser replay keeps its Learning summary while the recording reloads. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, 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, and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted. 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. diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 101f4b05d..da0ec0cca 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -9158,6 +9158,8 @@ export class StudyWorkspace { : (this.playback?.state().studyId ?? this.selection.studyId) if (!this.browserReplayShown() || !studyId) throw new Error('Select a recording first.') + // The controller keeps a pending load of the same study and would start none: a Reload whose recording has not loaded cancels that load first, so the recording loads again (Cycle 0052 task 35). + if (!this.recordingActive) this.playback?.leave() this.leavePlayback(true) this.recordingReloadStudyId = studyId await this.openRecording(studyId) @@ -11781,8 +11783,9 @@ export class StudyWorkspace { revision ?? this.revision, ) if (outcome.status === 'superseded') { - // A superseded open leaves the summary composition it entered when no newer replay stands in it, as a failed open does (Cycle 0052 task 13). - await this.leaveEmptySummaryComposition() + // A superseded open leaves the summary composition it entered when no newer replay stands in it, as a failed open does (Cycle 0052 task 13); an entry still under way fills or leaves that composition itself (task 35). + if (this.nodeHistoryEntriesUnderWay === 0) + await this.leaveEmptySummaryComposition() return } if (outcome.status !== 'failed') { @@ -11812,6 +11815,8 @@ export class StudyWorkspace { // The learner's test replay supersedes a saved study that waits for the hidden editor area: the slot tabs that waited with it close now, before its own slot shows, so its tab is never closed as an earlier one's and a failed open leaves none behind (Cycle 0052 task 15). this.studyAwaitingEditorArea = undefined 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 (entry !== this.nodeHistoryEntryRequest) return false try { @@ -11880,8 +11885,9 @@ export class StudyWorkspace { ) 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). - await this.leaveEmptySummaryComposition() + // 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). + if (this.nodeHistoryEntriesUnderWay === 0) + await this.leaveEmptySummaryComposition() // The keep-open refusal has told the learner already, so no second notification follows; the refusal is still recorded once in Output › Codebook, where other open refusals are, so a later take can tell why the replay did not open (F407). if (answer === 'told') this.diagnostics?.log({ @@ -12275,6 +12281,11 @@ export class StudyWorkspace { signal.throwIfAborted() } this.recordingActive = true + // A test replay retired for this recording left the summary its placeholder: the recording's loading, failure and retry need the reader's document there (Cycle 0052 task 35). + if (this.summaryComposition) { + this.giveSummaryItsDocument() + this.lastPublished = undefined + } // The reloaded recording is active again: a same-study Reload's mark has served its turn. this.recordingReloadStudyId = undefined // A browser replay starting ends any earlier record of tabs to close (F474). @@ -14315,15 +14326,7 @@ export class StudyWorkspace { throw new Error('Obsolete source-derived reader could not close.') signal?.throwIfAborted() // A pinned tab keeps showing its capture, so its bytes stay with the provider. - const stillShown = new Set( - vscode.window.tabGroups.all - .flatMap((group) => group.tabs) - .flatMap((tab) => - tab.input instanceof vscode.TabInputText - ? [tab.input.uri.toString()] - : [], - ), - ) + const stillShown = this.shownTextTabAddresses() for (const obsoleteUri of obsoleteUris) if (!stillShown.has(obsoleteUri)) this.documents.delete(obsoleteUri) } @@ -19469,10 +19472,7 @@ export class StudyWorkspace { assertCurrent() } // A summary view resolved earlier holds the closed replay's placeholder; it gets the reader's document even while it waits behind the chat. - if (this.summaryView && !this.summaryDocumentActive) { - this.summaryDocumentActive = true - this.summaryView.webview.html = studyHtml('context') - } + this.giveSummaryItsDocument() this.lastPublished = undefined this.publish() if (keptBehind) this.tellSummaryKeptBehindChat() @@ -19730,6 +19730,16 @@ export class StudyWorkspace { } /** Withdraws replay controls on explicit departure or close while leaving the native sidebar and other chat containers intact. @example this.clearLearningSummary() */ + /** + * Gives the Learning summary view the reader's document when it holds the closed replay's placeholder, so the next publish can draw a replay there; called by {@link showLearningSummary} and by {@link loadPlayback} for a recording that loads over a retired test replay. + * @example this.giveSummaryItsDocument() + */ + private giveSummaryItsDocument(): void { + if (!this.summaryView || this.summaryDocumentActive) return + this.summaryDocumentActive = true + this.summaryView.webview.html = studyHtml('context') + } + private clearLearningSummary(): void { this.summaryDocumentActive = false // The document that reported the focus is replaced; its successor reports for itself. @@ -24736,10 +24746,33 @@ export class StudyWorkspace { (retired.has(tab) || replacedAddresses.has(uri.toString())), ) } catch (error) { + // The tabs a refused close left open stay recorded, so the next explicit display or entry closes them again (Cycle 0052 task 35); a tab no longer shown at its address leaves the record. + const shownAddresses = this.shownTextTabAddresses() + this.nodeSourceSlotTabsAwaitingArea = new Map([ + ...[...retired].filter(([, address]) => shownAddresses.has(address)), + ...(this.nodeSourceSlotTabsAwaitingArea ?? []), + ]) this.reportSlotCloseFailure(error) } } + /** + * Names the addresses every text tab of the editor shows now; read after a close to tell the tabs that stayed (a pinned capture keeps its bytes, a refused slot close keeps its record). + * @returns The `uri.toString()` of each open text tab. + * @example this.shownTextTabAddresses().has(uri.toString()) // => true while a tab shows the document + */ + private shownTextTabAddresses(): Set { + return new Set( + vscode.window.tabGroups.all + .flatMap((group) => group.tabs) + .flatMap((tab) => + tab.input instanceof vscode.TabInputText + ? [tab.input.uri.toString()] + : [], + ), + ) + } + /** * Tells on the layout line that an earlier recorded-source tab could not close; called by {@link retireNodeSourceSlot} and {@link closeSlotTabsAwaitingArea}. * @param error - What the editor's close threw. @@ -24754,6 +24787,25 @@ export class StudyWorkspace { ) } + /** + * Says whether the end of a wait for the editor area was overtaken while it closed the slot tabs that waited: a Close (also with a new Open), a newer choice or a new wait owns the reader then; asked by {@link showStudyAwaitingEditorArea} after its close. + * @param awaited - The wait that ended. + * @param lifetime - The reader's lifetime when the wait ended. + * @returns `true` when the wait's end must arrange nothing; `false` when it still owns the reader. + * @example if (this.waitEndOvertaken(awaited, lifetime)) return + */ + private waitEndOvertaken( + awaited: NonNullable, + lifetime: number, + ): boolean { + return ( + lifetime !== this.lifetime || + this.closing || + this.studyAwaitingEditorArea !== undefined || + awaited.selectionRevision !== this.readerSelectionRevision + ) + } + /** Says whether the selected saved study still waits for the editor area the learner hid before choosing it; the reader's timer then leaves the panes as they are, while an explicit entry, selection or placement shows them. @example if (!this.studyAwaitsEditorArea()) await this.renderSelection(true, false, signal) */ private studyAwaitsEditorArea(): boolean { return ( @@ -24804,8 +24856,11 @@ export class StudyWorkspace { // A newer choice or a close during the read owns the reader now. if (this.studyAwaitingEditorArea !== awaited || this.closing) return this.studyAwaitingEditorArea = undefined + const lifetime = this.lifetime try { await this.closeSlotTabsAwaitingArea() + // A Close, a newer choice or a new wait during the slot close owns the reader now: this wait's end arranges nothing (Cycle 0052 task 35). + if (this.waitEndOvertaken(awaited, lifetime)) return if (awaited.arrange && this.needsReaderArrangement()) await this.open(signal) await this.renderSelection(true, true, signal) diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 60be173e6..8f61c6fb8 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import { randomUUID } from 'node:crypto' -import { appendFile } from 'node:fs/promises' +import { appendFile, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { test } from 'node:test' @@ -2964,3 +2964,153 @@ test('deleting the study of a failed same-study Reload is refused and keeps the await harness.close() } }) + +// Value: protects=a test replay open A that fails after a newer Open replay B has entered the standing summary composition and waits for the work window's focus does not dismantle that composition: once the focus returns, B is shown in the summary composition; fails_when=A's failed or superseded open leaves the "empty" summary composition while B is still under way, so B enters a Study composition (Codex C1 / Claude F1 of the pt1 /ship review, Cycle 0052 task 35); why_new=the leave of an empty composition was tested against Return and Close, not against a newer entry under way; seam=the first open held then failed at the debugger replacement, changeWindowFocus(false) with unfocusedWindowReads; no_claim=the native window manager +test('a failed test replay open does not dismantle the composition a newer Open replay waits to fill', async () => { + // Arrange + const harness = await workspaceHarness() + let release = (): void => {} + try { + const store = harness.service.nodeHistoryStore! + const first = await saveTwoStopNodeHistory(store) + const second = await saveTwoStopNodeHistory(store) + await harness.reader.action('retry') + await harness.reader.open() + const seams = readerSeams(harness) + const replace = seams.debuggerReplacement + const held = new Promise((resolve) => { + release = resolve + }) + let entered = (): void => {} + const opening = new Promise((resolve) => { + entered = resolve + }) + seams.debuggerReplacement = async (): Promise => { + // Only the first open is held and then failed: the second replay starts its debugger as usual. + seams.debuggerReplacement = replace + entered() + await held + throw new Error('The historical debugger did not start.') + } + const firstEntry = harness.reader.openLearningReplay(first.studyId) + await opening + + // Act: the second entry waits for the window's focus inside the composition; then the first open fails and the focus returns. + harness.changeWindowFocus(false) + const readsBefore = harness.unfocusedWindowReads() + const secondEntry = harness.reader.openLearningReplay(second.studyId) + await waitUntil( + () => harness.unfocusedWindowReads() > readsBefore, + 'the second entry waits for the window focus', + READER_RESULT_BOUND_MS, + ) + release() + await firstEntry + harness.changeWindowFocus(true) + await secondEntry + await landsOn(harness, 2, 'the second test replay lands on its first stop') + + // Assert + assert.deepEqual( + { + testReplay: harness.reader.inspect().view?.nodeHistory?.header.studyId, + ...compositionOf(harness), + }, + { + testReplay: second.studyId, + studyPane: false, + application: true, + summary: true, + family: 'test', + }, + ) + } finally { + release() + harness.changeWindowFocus(true) + await harness.close() + } +}) + +// Value: protects=a browser recording opened over a shown test replay whose read fails leaves the Learning summary the reader's document, so the failure and its recovery show there and not the closed replay's placeholder; fails_when=loadPlayback retires the test replay and leaves the summary its placeholder while the recording loads and fails (Codex structured S1, Cycle 0052 task 35); why_new=the failed-read row was tested for a Reload (a browser replay already shown), not for a recording over a test replay; seam=a test replay opened by Open replay, the recording's header.json broken as the Reload recovery test does; no_claim=the native pane's pixels +test('a browser recording that fails to read over a shown test replay gives the Learning summary its recovery document', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + const headerPath = join(harness.store.path(harness.studyId), 'header.json') + const header = await readFile(headerPath) + try { + const history = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.openLearningReplay(history.studyId) + await landsOn(harness, 2, 'the test replay lands on its first stop') + await writeFile(headerPath, '{broken') + + // Act + await harness.reader.openRecording(harness.studyId) + await waitUntil( + () => harness.controller.state().readiness === 'failed', + 'the recording read fails', + READER_RESULT_BOUND_MS, + ) + await harness.controller.settle() + + // Assert + assert.deepEqual( + { + transport: harness.summary.webview.html.includes( + 'id="recording-transport"', + ), + placeholder: harness.summary.webview.html.includes(SUMMARY_PLACEHOLDER), + }, + { transport: true, placeholder: false }, + ) + } finally { + await writeFile(headerPath, header) + await harness.close() + } +}) + +// Value: protects=a second Reload pressed while the first Reload's load is held starts a new load, and the first load, released afterwards and failing, does not decide the outcome: the recording ends ready; fails_when=the controller keeps the pending load of the same study and starts none, so the second Reload restarts nothing and the first load's failure ends the replay failed (Codex structured S2, Cycle 0052 task 35); why_new=the second-Reload test only checked the refusal and the end state, not that a load restarted; seam=the first load held then failed at the debugger replacement, a counting wrapper for the second load; no_claim=the native pane's pixels +test('a second Reload during a held Reload restarts the load and the first load’s failure does not decide the outcome', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + const reloading = await reloadHeldOrFailed(harness, 'held') + const seams = readerSeams(harness) + const replace = seams.debuggerReplacement + let secondLoadsStarted = 0 + seams.debuggerReplacement = async (signal?: AbortSignal): Promise => { + secondLoadsStarted++ + return replace(signal) + } + + // Act + await harness.reader.action('reload-recording') + await waitUntil( + () => harness.controller.state().readiness === 'ready', + 'the second Reload loads the recording', + READER_RESULT_BOUND_MS, + ).catch(() => undefined) + const startedBeforeFirstEnds = secondLoadsStarted + reloading.load.fail() + await reloading.reload + await harness.controller.settle() + + // Assert + assert.deepEqual( + { + startedBeforeFirstEnds, + ...browserReplayObservations(harness), + }, + { + startedBeforeFirstEnds: 1, + readiness: 'ready', + transport: true, + family: 'browser', + }, + ) + } finally { + await harness.close() + } +}) diff --git a/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts b/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts index 111a04783..168b348e8 100644 --- a/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts @@ -1203,3 +1203,68 @@ test('a refused close of the slot tab that waited for the editor area is told an await harness.close() } }) + +// Value: protects=a refused close of the waiting slot tabs at an explicit display keeps them recorded, so the next explicit display closes the slot tab once the editor accepts; fails_when=the failed close drops the record, and the slot tab of the retired replay stays open beside the shown study for good (Codex C3 / Claude F4 of the pt1 /ship review, Cycle 0052 task 35); why_new=the refusal test checked the first display only; seam=refuseCloseOfTabsShowing for the first close, the reader's source action twice; no_claim=the wording of the editor's refusal +test('a slot tab whose close the editor refused at one display closes at the next display', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + let acceptClose = (): void => {} + try { + await chooseBrowserStudyWithPanelMaximized(harness) + acceptClose = harness.refuseCloseOfTabsShowing('/node-history-slot/') + await harness.reader.action('source') + const slotTabsAfterRefusal = slotTabCount(harness) + acceptClose() + + // Act + await harness.reader.action('source') + + // Assert + assert.deepEqual( + { slotTabsAfterRefusal, slotTabsAfterAccept: slotTabCount(harness) }, + { slotTabsAfterRefusal: 1, slotTabsAfterAccept: 0 }, + ) + } finally { + acceptClose() + await harness.close() + } +}) + +// Value: protects=a test replay entry whose slot close for the waiting tabs is overtaken by a Return to Live and a browser recording opened meanwhile asks no one-replay-at-a-time notice and opens no test replay; fails_when=the entry goes on after its slot close with a ticket a newer choice superseded, asks to close the browser replay the learner just opened and opens the test replay over it (Codex C4, Cycle 0052 task 35); why_new=the entry's ticket was checked before the slot close only; seam=holdNextTabCloseResponse during the entry's closeSlotTabsAwaitingArea, answerWarningDialogs counting notices; no_claim=the native notification +test('a test replay entry overtaken during its slot close asks no notice for a browser recording opened meanwhile', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + let release = (): void => {} + try { + const history = await chooseBrowserStudyWithPanelMaximized(harness) + let noticesShown = 0 + harness.answerWarningDialogs(async () => { + noticesShown++ + return 'Close browser replay' + }) + const held = harness.holdNextTabCloseResponse() + release = held.release + const entry = harness.reader.openLearningReplay(history.studyId) + await held.started + + // Act: the learner returns to Live and opens the browser recording while the entry's slot close is pending. + await harness.reader.action('return-current') + await harness.reader.openRecording(harness.studyId) + held.release() + await entry + await harness.controller.settle() + + // Assert + assert.deepEqual( + { + noticesShown, + testReplay: harness.reader.inspect().view?.nodeHistory?.header.studyId, + recording: harness.controller.state().studyId, + }, + { noticesShown: 0, testReplay: undefined, recording: harness.studyId }, + ) + } finally { + release() + await harness.close() + } +}) From a6a6a5d051463a215a3a8dd3647f73523becb131 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 15:44:39 +0900 Subject: [PATCH 21/30] fix: the views read a same-study Reload's recording while it loads or after it failed (Cycle 0052 task 36; local CodeRabbit round 11) - message() projects recording, model, contentTab, links, canClick and runRefusal from browserReplayShown(), so the views read the Reload's loading or failure and the recording tab instead of a stale status under the browser family (minor). Host test, held and failed, red on 40ecb542. - giveSummaryItsDocument() moves below clearLearningSummary, whose JSDoc it had orphaned (trivial). - The second saved browser study fixture is shared (tests/utils/saveOtherBrowserStudy.ts) by the composition and open tests (trivial). Refs #124 --- extension/src/studyWorkspace.ts | 38 ++++++------ ...tudyWorkspaceTestReplayComposition.test.ts | 58 +++++++++++++------ .../studyWorkspaceTestReplayOpens.test.ts | 19 ++---- .../src/tests/utils/saveOtherBrowserStudy.ts | 30 ++++++++++ 4 files changed, 93 insertions(+), 52 deletions(-) create mode 100644 extension/src/tests/utils/saveOtherBrowserStudy.ts diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index da0ec0cca..ea7cc2540 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -13323,10 +13323,10 @@ export class StudyWorkspace { if (!this.baselines) throw new Error('Study reader is not initialized.') const record = this.currentRecord(), live = record && this.service?.state(record) - const playbackState = this.recordingActive - ? this.playback?.state() - : undefined - const bundle = this.recordingActive + // 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() + const playbackState = browserReplay ? this.playback?.state() : undefined + const bundle = browserReplay ? forSource || this.playbackPresentingReady || playbackState?.cleared ? (this.playbackPending ?? this.playback?.bundle()) : this.playback?.bundle() @@ -13464,11 +13464,11 @@ export class StudyWorkspace { return { type: 'study', revision: this.revision, - model: this.recordingActive + model: browserReplay ? projectPlaybackStudy(projectionInput, bundle) : projectStudy(projectionInput), presentation: this.presentationState(), - contentTab: this.recordingActive ? 'recording' : this.selection.tab, + contentTab: browserReplay ? 'recording' : this.selection.tab, repositoryReturnAvailable: Boolean( this.controls.returnToRepository && this.repositoryOrigin?.studyId === this.selection.studyId && @@ -13577,7 +13577,7 @@ export class StudyWorkspace { createdAt: study.createdAt, reason: study.reason, finalOutcome: study.finalOutcome, - links: this.recordingActive ? [] : study.links, + links: browserReplay ? [] : study.links, historyOutcome, studyOutcome, recordingSave: selectedState @@ -13622,7 +13622,7 @@ export class StudyWorkspace { title: profile.title, description: profile.description, })), - canClick: !this.recordingActive && Boolean(nextAction), + canClick: !browserReplay && Boolean(nextAction), canRun: (!record || record.stopped) && this.selection.profile !== REPOSITORY_BROWSER_PROFILE, @@ -13648,7 +13648,7 @@ export class StudyWorkspace { record?.historyExcluded === 'refused-at-admission' && record.error && record.id !== this.answeredRunRefusalId && - !this.recordingActive && + !browserReplay && !this.testReplayHoldsComposition() ? `The last run did not start: ${record.error.message}` : undefined, @@ -19730,16 +19730,6 @@ export class StudyWorkspace { } /** Withdraws replay controls on explicit departure or close while leaving the native sidebar and other chat containers intact. @example this.clearLearningSummary() */ - /** - * Gives the Learning summary view the reader's document when it holds the closed replay's placeholder, so the next publish can draw a replay there; called by {@link showLearningSummary} and by {@link loadPlayback} for a recording that loads over a retired test replay. - * @example this.giveSummaryItsDocument() - */ - private giveSummaryItsDocument(): void { - if (!this.summaryView || this.summaryDocumentActive) return - this.summaryDocumentActive = true - this.summaryView.webview.html = studyHtml('context') - } - private clearLearningSummary(): void { this.summaryDocumentActive = false // The document that reported the focus is replaced; its successor reports for itself. @@ -19751,6 +19741,16 @@ export class StudyWorkspace { '

Open saved learning from Codebook to view its summary.

' } + /** + * Gives the Learning summary view the reader's document when it holds the closed replay's placeholder, so the next publish can draw a replay there; called by {@link showLearningSummary} and by {@link loadPlayback} for a recording that loads over a retired test replay. + * @example this.giveSummaryItsDocument() + */ + private giveSummaryItsDocument(): void { + if (!this.summaryView || this.summaryDocumentActive) return + this.summaryDocumentActive = true + this.summaryView.webview.html = studyHtml('context') + } + /** * Says whether a Learning summary view is still the reader's and is shown, or hidden, as asked; the visibility listener {@link installReaders} gives each summary view asks it twice, before and after it publishes. * @param view - The summary view whose visibility changed. diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 8f61c6fb8..b55e1b98a 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -20,9 +20,9 @@ import { recordedReaderHarness, workspaceHarness, } from './studyWorkspaceHarness' -import { createConfiguredStudy } from './utils/createConfiguredStudy' import { nodeTestHistoryHeader } from './utils/nodeTestHistoryFixture' import { READER_RESULT_BOUND_MS } from './utils/readerResultBound' +import { saveOtherBrowserStudy } from './utils/saveOtherBrowserStudy' import { lastView, saveTwoStopNodeHistory } from './utils/twoStopNodeHistory' type Harness = Awaited> @@ -2054,22 +2054,12 @@ function holdNextRecordingLoad(harness: RecordedHarness) { * Saves a second browser study beside the harness's recorded one, so the learner has another Saved studies entry to choose. * @param harness - The recorded reader harness. * @returns The saved study. - * @example const other = await saveOtherBrowserStudy(harness) + * @example const other = await saveOtherBrowserStudyShown(harness) */ -async function saveOtherBrowserStudy(harness: RecordedHarness) { - const library = await harness.reader.learningStorage(), - study = createConfiguredStudy(), - runId = randomUUID() - for (const observation of study.observations) - observation.snapshotUri = observation.snapshotUri.replace( - study.runId, - runId, - ) - study.workspaceKey = library.workspaceKey - study.runId = runId - study.id = `${study.instanceId}_${runId}` - study.savedAt = '2026-09-20T00:00:01.000Z' - await library.saveStudy(study) +async function saveOtherBrowserStudyShown(harness: RecordedHarness) { + const study = await saveOtherBrowserStudy( + await harness.reader.learningStorage(), + ) // Retry storage reloads the catalog the reader shows. await harness.reader.action('retry') return study @@ -2111,7 +2101,7 @@ test('a Saved studies choice after a failed same-study Reload opens the chosen r // Arrange const harness = await recordedReaderHarness(true) try { - const other = await saveOtherBrowserStudy(harness) + const other = await saveOtherBrowserStudyShown(harness) await reloadHeldOrFailed(harness, 'failed') // Act @@ -2152,7 +2142,7 @@ test('a Saved studies choice during a same-study Reload opens the chosen recordi const harness = await recordedReaderHarness(true) let load: Awaited>['load'] | undefined try { - const other = await saveOtherBrowserStudy(harness) + const other = await saveOtherBrowserStudyShown(harness) const held = await reloadHeldOrFailed(harness, 'held') load = held.load @@ -3114,3 +3104,35 @@ test('a second Reload during a held Reload restarts the load and the first load await harness.close() } }) + +for (const ending of ['held', 'failed'] as const) { + // Value: protects=while a same-study Reload is loading or has failed, the views the reader sends read the browser replay with its recording state (loading, or the failure and its reason) and the recording tab, so the summary's status, failure and recovery are current; fails_when=message() projects the reader as having no recording because recordingActive is false during the Reload, so the views keep a stale recording status under the browser family (Cycle 0052 task 36, local CodeRabbit round 11, minor); why_new=the Reload tests read presentationState, not the views' message; seam=a Reload whose debugger replacement is held, or held then failed; no_claim=the webview's rendering of the status + test(`the views read the recording while a same-study Reload ${ending === 'held' ? 'is loading' : 'has failed'}`, async () => { + // Arrange + const harness = await recordedReaderHarness(true) + let release = (): void => {} + try { + // Act + const reloading = await reloadHeldOrFailed(harness, ending) + release = reloading.load.release + + // Assert + const view = harness.reader.inspect().view + assert.deepEqual( + { + contentTab: view?.contentTab, + readiness: view?.recording?.readiness, + studyId: view?.recording?.studyId, + }, + { + contentTab: 'recording', + readiness: ending === 'held' ? 'loading' : 'failed', + studyId: harness.studyId, + }, + ) + } finally { + release() + await harness.close() + } + }) +} diff --git a/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts b/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts index 168b348e8..b0f20eec7 100644 --- a/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayOpens.test.ts @@ -1,5 +1,4 @@ import assert from 'node:assert/strict' -import { randomUUID } from 'node:crypto' import { writeFile } from 'node:fs/promises' import { join } from 'node:path' import { test } from 'node:test' @@ -11,8 +10,8 @@ import { recordedReaderHarness, workspaceHarness, } from './studyWorkspaceHarness' -import { createConfiguredStudy } from './utils/createConfiguredStudy' import { READER_RESULT_BOUND_MS } from './utils/readerResultBound' +import { saveOtherBrowserStudy } from './utils/saveOtherBrowserStudy' import { lastView, saveTwoStopNodeHistory } from './utils/twoStopNodeHistory' type Harness = Awaited> @@ -861,19 +860,9 @@ test('a second Saved studies choice while the first waits for the editor area ke // Arrange: a second saved browser study stands in Saved studies beside the first. const harness = await recordedReaderHarness(true) try { - const library = await harness.reader.learningStorage(), - second = createConfiguredStudy(), - runId = randomUUID() - for (const observation of second.observations) - observation.snapshotUri = observation.snapshotUri.replace( - second.runId, - runId, - ) - second.workspaceKey = library.workspaceKey - second.runId = runId - second.id = `${second.instanceId}_${runId}` - second.savedAt = '2026-09-20T00:00:01.000Z' - await library.saveStudy(second) + const second = await saveOtherBrowserStudy( + await harness.reader.learningStorage(), + ) await openReplayBesideBrowserStudy(harness) harness.maximizePanel() const chooseSavedStudy = async (studyId: string): Promise => { diff --git a/extension/src/tests/utils/saveOtherBrowserStudy.ts b/extension/src/tests/utils/saveOtherBrowserStudy.ts new file mode 100644 index 000000000..7d9a4acd5 --- /dev/null +++ b/extension/src/tests/utils/saveOtherBrowserStudy.ts @@ -0,0 +1,30 @@ +import { randomUUID } from 'node:crypto' + +import type { ConfiguredStudy } from '../../configuredStudySchemas' +import type { LearningLibrary } from '../../learningLibrary' + +import { createConfiguredStudy } from './createConfiguredStudy' + +/** + * Saves a second browser study beside the harness's own, under a run of its own, so a Saved studies choice can name a study other than the one shown; used by the test replay composition and open tests that choose another saved study. + * @param library - The reader's learning library (`await harness.reader.learningStorage()`). + * @returns The saved study, whose `id` the Saved studies choice names. + * @example const other = await saveOtherBrowserStudy(await harness.reader.learningStorage()) + */ +export async function saveOtherBrowserStudy( + library: LearningLibrary, +): Promise { + const study = createConfiguredStudy(), + runId = randomUUID() + for (const observation of study.observations) + observation.snapshotUri = observation.snapshotUri.replace( + study.runId, + runId, + ) + study.workspaceKey = library.workspaceKey + study.runId = runId + study.id = `${study.instanceId}_${runId}` + study.savedAt = '2026-09-20T00:00:01.000Z' + await library.saveStudy(study) + return study +} From 1198a5a5d4205e4079f42294c05ea7454bf30ab9 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 15:44:47 +0900 Subject: [PATCH 22/30] docs: the packaged README names pt1's replay entries, exits and reloads, as the 0.2.40.0 changelog does (Cycle 0052) Refs #124 --- extension/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extension/README.md b/extension/README.md index 9eb5390c1..e6f65f9c9 100644 --- a/extension/README.md +++ b/extension/README.md @@ -73,7 +73,7 @@ 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. 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, so it neither asks a second time nor reopens the reader. Open replay brings the Learning summary back if you had hidden it, also with the bottom panel hidden. While you leave a replay (Return to Live, or another saved study), the Learning summary shows nothing of the Live view and the transport never shows the other replay family's controls; after a browser replay the Application tab takes back its name `codebook · Application`. Reloading a browser replay keeps its Learning summary while the recording reloads. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, 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, and choosing Live leaves the reloading replay for the Live composition. Until you leave it, the replay's study cannot be deleted. 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. Close and Return to Live cancel an Open replay that is still waiting for its turn, so it neither asks a second time nor reopens the reader; 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 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`. 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 and a runtime release. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, 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, 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. From e177d0c6177646decd6ba4e61df70ec3bf26b8d7 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 15:44:47 +0900 Subject: [PATCH 23/30] chore: bump version and changelog (v0.2.40.0) Co-Authored-By: Claude --- CHANGELOG.md | 25 ++++++++++++++++++++++++ VERSION | 2 +- extension/package.json | 2 +- extension/scripts/tests/package.test.mjs | 2 +- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28ff58efc..7e2ded269 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## [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. + +**Leaving a replay shows nothing of the other side.** + +- **Return to Live and another saved study** withdraw the Learning summary before the reader shows the Live state, so the summary never draws the Live Study page, and the transport never shows the other replay family's controls while you leave. +- **A browser replay's open and its Return to Live** send the Learning summary nothing of the Study before the replay's own summary. +- **The Application tab takes back its name** `codebook · Application` after a browser replay, in the Live composition and in a test replay. + +**Entries do what you chose.** + +- **Close and Return to Live cancel an Open replay that is still waiting** for its turn, so it neither asks a second time nor reopens the reader; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left. An entry overtaken while it closes earlier tabs asks no one-replay-at-a-time notice. +- **A superseded Saved studies open** gives the Study pane back, and an earlier replay's failed or superseded open no longer takes the summary composition from 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. +- **A browser study chosen over a test replay while the bottom panel is maximized** waits for the editor area: the panel stays maximized, and the Study pane, the study's source and the closing of the test replay's recorded-source tab wait until you restore the panel; a close the editor refuses is told and tried again at your next display. + +**Reloading a browser replay keeps it on screen.** + +- **Reload saved recording keeps the 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 and a runtime release. +- **While a reload loads, or after it failed**, a saved study you choose opens its own recording, a step you select is refused until the recording has loaded again, Reload and Retry storage / refresh load the recording again, choosing Live leaves the replay, and the replay's study cannot be deleted until you leave it. An agent reads the reader as showing the recording, so it returns to Live before it starts a run. +- **A browser recording opened over a test replay** shows its loading, failure and recovery in the Learning summary. + +**Compatibility.** The MCP API stays 1.8, the bundled skill is unchanged and no stored format changes. Tasks 1, 5, 7 to 11, 17, 19 to 23, 26 and 27 of Issue #124 follow in the cycle's second and third pull requests. + ## [0.2.39.0] - 2026-10-24 (development candidate) Cycle 0051 ([Issue #122](https://github.com/laststance/codebook/issues/122)): a test replay opens in the learning-replay composition a browser replay has, with 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 (approved choice 5A). diff --git a/VERSION b/VERSION index 1abde3f99..d92eef30e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.39.0 +0.2.40.0 diff --git a/extension/package.json b/extension/package.json index d4010a39e..77156cd46 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.39", + "version": "0.2.40", "private": true, "engines": { "vscode": "^1.120.0", diff --git a/extension/scripts/tests/package.test.mjs b/extension/scripts/tests/package.test.mjs index 915d2ce11..a8f95ab72 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.39') + assert.equal(checked.extension.version, '0.2.40') assert.equal(checked.extension.apiVersion, '1.8') assert.equal(checked.entries, 32) assert.equal(receipt.dependencies.length, 16) From e393b9f6973121bb4dc593719c1d14ebb95416bd Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 15:56:16 +0900 Subject: [PATCH 24/30] fix: Focus source during a same-study Reload leaves the replay's Application pane as it is (Cycle 0052 task 37; local CodeRabbit round 12) presentApplication() returns while browserReplayShown(), so a Focus source during a loading or failed Reload no longer draws the Study selection in the replay's Application pane or renames its tab (minor; Claude adversarial F2/F3 were real through this caller). Host test, held and failed, red on e177d0c6. Refs #124 --- extension/src/studyWorkspace.ts | 3 +- ...tudyWorkspaceTestReplayComposition.test.ts | 35 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index ea7cc2540..2bbd90198 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -15132,7 +15132,8 @@ export class StudyWorkspace { * @example this.presentApplication(message) */ private presentApplication(message: StudyViewMessage): void { - if (this.recordingActive) return + // A same-study Reload that is loading or failed still shows its browser replay: no Study selection is drawn in its Application pane (Cycle 0052 task 37). + if (this.browserReplayShown()) return this.retitleApplicationAfterRecording() // A test replay's pane is one document per history; each later moment is posted into it (EN3). if (message.nodeHistoryApplication) { diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index b55e1b98a..7104f39cc 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -3136,3 +3136,38 @@ for (const ending of ['held', 'failed'] as const) { } }) } + +for (const ending of ['held', 'failed'] as const) { + // Value: protects=Focus source during a same-study Reload, loading or failed, leaves the browser replay's Application pane as it is: no Study selection is drawn there and its title is not renamed; fails_when=renderSelection reaches presentApplication, which checks only recordingActive, and the pane shows the Study selection under the replay's loading or failure (Cycle 0052 task 37, local CodeRabbit round 12, minor); why_new=the Reload tests released the runtime and refreshed, none pressed Focus source; seam=a Reload held at, or failed at, its debugger replacement, the reader's source action; no_claim=the native pane's pixels + test(`Focus source during a same-study Reload${ending === 'held' ? '' : ' that failed'} keeps the Application pane on the recording`, async () => { + // Arrange + const harness = await recordedReaderHarness(true) + let load: Awaited>['load'] | undefined + try { + const reloading = await reloadHeldOrFailed(harness, ending) + load = reloading.load + const application = harness + .nativePanels() + .find((panel) => panel.kind === 'codebook.scopedBrowser')! + const documentBefore = application.webview.html, + titleBefore = application.title + + // Act + await harness.reader.action('source').catch(() => undefined) + const afterSource = { + applicationUnchanged: application.webview.html === documentBefore, + titleUnchanged: application.title === titleBefore, + } + await settleReload(harness, reloading) + + // Assert + assert.deepEqual(afterSource, { + applicationUnchanged: true, + titleUnchanged: true, + }) + } finally { + load?.release() + await harness.close() + } + }) +} From 032a5209b5eb7fbf28e96243fea14ee2fbbb50d1 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 15:56:20 +0900 Subject: [PATCH 25/30] docs: README and the 0.2.40.0 changelog name Focus source among what keeps a reloading replay's Application pane (Cycle 0052 task 37) Refs #124 --- CHANGELOG.md | 2 +- extension/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e2ded269..3836509d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Cycle 0052 pt1 ([Issue #124](https://github.com/laststance/codebook/issues/124)) **Reloading a browser replay keeps it on screen.** -- **Reload saved recording keeps the 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 and a runtime release. +- **Reload saved recording keeps the 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. - **While a reload loads, or after it failed**, a saved study you choose opens its own recording, a step you select is refused until the recording has loaded again, Reload and Retry storage / refresh load the recording again, choosing Live leaves the replay, and the replay's study cannot be deleted until you leave it. An agent reads the reader as showing the recording, so it returns to Live before it starts a run. - **A browser recording opened over a test replay** shows its loading, failure and recovery in the Learning summary. diff --git a/extension/README.md b/extension/README.md index e6f65f9c9..7188789ec 100644 --- a/extension/README.md +++ b/extension/README.md @@ -73,7 +73,7 @@ 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, so it neither asks a second time nor reopens the reader; 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 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`. 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 and a runtime release. A saved study you choose while it reloads, or after the reload failed, opens its own recording as it does during the replay, 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, 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. Close and Return to Live cancel an Open replay that is still waiting for its turn, so it neither asks a second time nor reopens the reader; 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 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`. 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 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, 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. From c1fad4a3cee2fab5e5f5471d262935789e16f5c9 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 15:57:46 +0900 Subject: [PATCH 26/30] docs: the root README's implementation status names the delivered 0.2.39.0 and Cycle 0052's three pull requests (docs audit debt, Cycle 0052) The status line still named 0.2.35.0 and Cycle 0049; the pt1 documentation re-audit found it as pre-existing debt, which a bug-fix cycle fixes before merge. Refs #124 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 96ff54447..98953cd07 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.35.0** once the third pull request of [Cycle 0049](https://github.com/laststance/codebook/issues/107) is merged; until then 0.2.34.0 ([PR #116](https://github.com/laststance/codebook/pull/116)) is the delivered one, and the active cycle Issue says where the delivery stands. Cycle 0049 is a feature cycle: a Vitest test run that the editor chat explained becomes a learning replay, a **test replay**. Its first pull request holds the loop and its contracts: a test run reads `demonstrated` once its test passed after every declared stop and its history sealed complete; **Save learning replay** and **Open replay** in the status bar save and open it (its test output, the executed source and the native debugger's recorded stops; no application video); the run resource tells the agent the test's result and which explanations the saved learning still lacks; a node-test start's refusals arrive before its first stop with their fix; and the bundled skill 1.0.46 routes a question about a test to this flow. The MCP API is 1.8. Its second pull request shows the recorded test output in the Application pane, updated in place up to the selected moment, and rebuilds the Study pane's test replay section around that moment (Previous stop and Next stop first, the learning summary drawn as a browser replay's with the selected step); its third shows the executed source in one recorded-source tab that the native debugger's frames open, takes the earlier moment off the tab and the debugger before each move, marks the unrecorded time on the slider and bounds Play's wait between events; the native round in both editors follows its merge; the test replay's summary in the Learning summary view is the next feature cycle. 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.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. 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. From 8e781153d04468ee3016f73061db1b9f562105ca Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 16:07:36 +0900 Subject: [PATCH 27/30] chore: the runtime version reads 0.2.40 with the package (v0.2.40.0) The release commit bumped VERSION, extension/package.json and the package test, but not EXTENSION_VERSION in extension/src/constants.ts, which the package build compares with the manifest ("Runtime and package versions differ.": 32 package tests failed in the gate on 032a5209). Co-Authored-By: Claude --- extension/src/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extension/src/constants.ts b/extension/src/constants.ts index 3903fe55b..f7c47ed0f 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.39' +export const EXTENSION_VERSION = '0.2.40' 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' From 270022c7c3d10d431dfbdba6f2d8b2045f889d1e Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 16:46:48 +0900 Subject: [PATCH 28/30] fix: leaving a browser replay paints no loading line; refused Reloads, focus waits and choices during a Reload (Cycle 0052 tasks 38-42) - Task 38 (N52 Cursor take on 8e781153): a departure releases the recorded viewport before the playback controller leaves, so the controller's clear never paints "Loading recorded position..." while the reader leaves. - Task 39 (hosted CodeRabbit): reloadShownRecording checks openRecording's refusals before it leaves the replay, so a Retry that meets a pending presentation change keeps the failed replay and its reason. - Task 40 (hosted CodeRabbit): each test replay entry ticket has a cancellation that a newer entry, a Return or a Close aborts; the focus wait listens to it. - Task 41a (hosted CodeRabbit): a Saved studies choice during a same-study Reload keeps its pane reply until the chosen recording has loaded. - Task 42 (hosted CodeRabbit): README's Tracking names Cycle 0052 active. Each code task has a host test red on 8e781153 and green here. --- CHANGELOG.md | 6 +- README.md | 5 +- extension/README.md | 18 +-- extension/src/studyWorkspace.ts | 55 +++++-- .../studyWorkspaceTestReplayBrowser.test.ts | 32 ++++ ...tudyWorkspaceTestReplayComposition.test.ts | 151 ++++++++++++++++++ 6 files changed, 241 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3836509d5..e5797c208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,11 @@ Cycle 0052 pt1 ([Issue #124](https://github.com/laststance/codebook/issues/124)) - **Return to Live and another saved study** withdraw the Learning summary before the reader shows the Live state, so the summary never draws the Live Study page, and the transport never shows the other replay family's controls while you leave. - **A browser replay's open and its Return to Live** send the Learning summary nothing of the Study before the replay's own summary. -- **The Application tab takes back its name** `codebook · Application` after a browser replay, in the Live composition and in a test replay. +- **The Application tab takes back its name** `codebook · Application` after a browser replay, in the Live composition and in a test replay, and while you leave a browser replay the Application pane no longer flashes the recording's `Loading recorded position…` line. **Entries do what you chose.** -- **Close and Return to Live cancel an Open replay that is still waiting** for its turn, so it neither asks a second time nor reopens the reader; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left. An entry overtaken while it closes earlier tabs asks no one-replay-at-a-time notice. +- **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; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left. An entry overtaken while it closes earlier tabs asks no one-replay-at-a-time notice. - **A superseded Saved studies open** gives the Study pane back, and an earlier replay's failed or superseded open no longer takes the summary composition from 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. - **A browser study chosen over a test replay while the bottom panel is maximized** waits for the editor area: the panel stays maximized, and the Study pane, the study's source and the closing of the test replay's recorded-source tab wait until you restore the panel; a close the editor refuses is told and tried again at your next display. @@ -20,7 +20,7 @@ Cycle 0052 pt1 ([Issue #124](https://github.com/laststance/codebook/issues/124)) **Reloading a browser replay keeps it on screen.** - **Reload saved recording keeps the 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. -- **While a reload loads, or after it failed**, a saved study you choose opens its own recording, a step you select is refused until the recording has loaded again, Reload and Retry storage / refresh load the recording again, choosing Live leaves the replay, and the replay's study cannot be deleted until you leave it. An agent reads the reader as showing the recording, so it returns to Live before it starts a run. +- **While a reload loads, or after it failed**, a saved study you choose opens its own recording (Saved studies keeps your choice while it loads), a step you select is refused until the recording has loaded again, Reload and Retry storage / refresh load the recording again (one refused because a layout change is still pending changes nothing, so a failed reload keeps its reason), choosing Live leaves the replay, and the replay's study cannot be deleted until you leave it. An agent reads the reader as showing the recording, so it returns to Live before it starts a run. - **A browser recording opened over a test replay** shows its loading, failure and recovery in the Learning summary. **Compatibility.** The MCP API stays 1.8, the bundled skill is unchanged and no stored format changes. Tasks 1, 5, 7 to 11, 17, 19 to 23, 26 and 27 of Issue #124 follow in the cycle's second and third pull requests. diff --git a/README.md b/README.md index 98953cd07..eb073cc54 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,10 @@ Read [AGENTS.md](AGENTS.md) when starting or resuming work; Codex loads it as it ## Tracking and continuation - [Goal Issue #1](https://github.com/laststance/codebook/issues/1) remains open until all acceptance conditions and owner acceptance hold. -- [Cycle 0049 Issue #107](https://github.com/laststance/codebook/issues/107) is the active cycle: a feature cycle that makes a Vitest test run the chat explained a saved, replayable learning replay in both editors, in three pull requests merged in order (the loop and its contracts; the Application pane and the Study section; the recorded source, the debugger and the transitions, with the native round in both editors). +- [Cycle 0052 Issue #124](https://github.com/laststance/codebook/issues/124) is the active cycle: a bug-fix cycle with no increment that closes every defect Cycle 0051 carried, in three pull requests merged in order (replays leave, open and reload without stale frames; a test replay's moves and summary page; the live runtime, a browser run's refusals and the tests' hygiene); the Issue holds its tasks and state. +- [Cycle 0051 Issue #122](https://github.com/laststance/codebook/issues/122) is closed after [PR #123](https://github.com/laststance/codebook/pull/123): a feature cycle that opens a test replay in the learning-replay composition a browser replay has, with the Learning summary in the chat's place and one transport; the defects it carried are the tasks of Cycle 0052. +- [Cycle 0050 Issue #118](https://github.com/laststance/codebook/issues/118) is closed after [PR #119](https://github.com/laststance/codebook/pull/119), [PR #120](https://github.com/laststance/codebook/pull/120) and [PR #121](https://github.com/laststance/codebook/pull/121): a bug-fix cycle with no increment that closed every defect Cycle 0049 carried. +- [Cycle 0049 Issue #107](https://github.com/laststance/codebook/issues/107) is closed after [PR #113](https://github.com/laststance/codebook/pull/113), [PR #116](https://github.com/laststance/codebook/pull/116) and [PR #117](https://github.com/laststance/codebook/pull/117): a feature cycle that made a Vitest test run the chat explained a saved, replayable learning replay in both editors, in three pull requests merged in order (the loop and its contracts; the Application pane and the Study section; the recorded source, the debugger and the transitions, with the native round in both editors). - [Cycle 0048 Issue #105](https://github.com/laststance/codebook/issues/105) is closed after [PR #106](https://github.com/laststance/codebook/pull/106): a bug-fix cycle with no increment that closed the 24 defects the adversarial review of Cycle 0047's final candidate found, 5 that an archived test guide carried and the 189 found while it ran, and carried nothing. - [Cycle 0047 Issue #90](https://github.com/laststance/codebook/issues/90) is closed after [PR #91](https://github.com/laststance/codebook/pull/91) ([record](https://github.com/laststance/codebook/blob/docs-archive-2026-10-20/docs/cycles/0047-carried-defects-of-cycle-0046.md)): a bug-fix cycle with no increment that closed the 53 defects Cycle 0046 left and every defect found while it ran (279 rows, [the list](https://github.com/laststance/codebook/blob/docs-archive-2026-10-20/docs/cycles/0047-carried-defects.md)); the 24 defects that the adversarial review of its final candidate found are the tasks of Cycle 0048, a bug-fix cycle whose pull request holds only their fixes. - [Cycle 0046 Issue #87](https://github.com/laststance/codebook/issues/87) is closed after [PR #88](https://github.com/laststance/codebook/pull/88) ([record](https://github.com/laststance/codebook/blob/docs-archive-2026-10-20/docs/cycles/0046-carried-defects-of-cycle-0045.md)): a bug-fix cycle with no increment that closed every defect Cycle 0045 left and every defect found while it ran but row 1066 ([1192 rows](https://github.com/laststance/codebook/blob/docs-archive-2026-10-20/docs/cycles/0046-carried-defects.md)); row 1066 and the findings its delivery's last review left open are the tasks of Cycle 0047. diff --git a/extension/README.md b/extension/README.md index 7188789ec..0aa15b1bd 100644 --- a/extension/README.md +++ b/extension/README.md @@ -73,7 +73,7 @@ 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, so it neither asks a second time nor reopens the reader; 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 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`. 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 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, 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. 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; 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 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. @@ -83,14 +83,14 @@ One transport serves both replay families: in the bottom panel, which reads **Le On the focused time slider: -| Key | Browser replay | Test replay | -| --- | --- | --- | -| ← / ↓ | one second back | previous event | -| → / ↑ | one second forward | next event | -| Page Down | ten seconds back | previous stop | -| Page Up | ten seconds forward | next stop | -| Home | recording start | first event | -| End | recording end | last event | +| Key | Browser replay | Test replay | +| --------- | ------------------- | -------------- | +| ← / ↓ | one second back | previous event | +| → / ↑ | one second forward | next event | +| Page Down | ten seconds back | previous stop | +| Page Up | ten seconds forward | next stop | +| Home | recording start | first event | +| End | recording end | last event | 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. diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 2bbd90198..637188c00 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -964,6 +964,8 @@ export class StudyWorkspace { private liveComposition = false /** Counts the learner's test replay entries ({@link openNodeHistoryFromHost}) in the order they were made, so the latest one wins also while an earlier one still enters the summary composition (EN19, Cycle 0051). */ private nodeHistoryEntryRequest = 0 + /** Aborts when the latest test replay entry's ticket ({@link nodeHistoryEntryRequest}) is superseded by a newer entry, a Return or a Close ({@link supersedeNodeHistoryEntries}), so an entry waiting for the work window's focus ends at once instead of holding the next entry's turn (Cycle 0052 task 40). */ + private nodeHistoryEntryCancellation = new AbortController() /** How many test replay entries ({@link openNodeHistoryFromHost}) are under way, the waiting ones included; the last superseded entry to end leaves the empty summary composition an earlier one built only when none is under way to fill it (Cycle 0052 task 12). */ private nodeHistoryEntriesUnderWay = 0 /** Settles when the test replay entry in front has entered its composition: entries take turns there, so two entries never run {@link open} at once (one would be refused as busy and leave no replay shown). */ @@ -1486,7 +1488,7 @@ export class StudyWorkspace { 'The reader content changed. Read current presentation state before returning to live execution.', ) // A Return supersedes every test replay entry still waiting for its turn, also one that finds the reader in Live already (Cycle 0052 task 12). - this.nodeHistoryEntryRequest++ + this.supersedeNodeHistoryEntries() // A return can change the selection while native source rendering fails; report that partial state honestly. // A failed native source open leaves the selection current, so explicit retry must render it again. // A selected Node journal masks the live application even while the selection still follows the run. @@ -8916,7 +8918,7 @@ export class StudyWorkspace { } if (action === 'return-current') { // The learner's Return supersedes every test replay entry still waiting for its turn (Cycle 0052 task 12). - this.nodeHistoryEntryRequest++ + this.supersedeNodeHistoryEntries() await this.returnCurrentSelection() } if (action === 'source') { @@ -9148,7 +9150,7 @@ export class StudyWorkspace { /** * Loads the shown browser replay's recording again (a same-study Reload): the Summary keeps its document, so a failed read can publish its recovery there, and the Reload's mark keeps the replay shown until the recording is active again ({@link loadPlayback}) or a departure supersedes it; a failed load keeps it. Called by the Reload action and by Retry while a Reload's recording has not loaded (Cycle 0052 tasks 18 and 31). * @returns Once the recording's open is accepted; the load itself continues in the playback controller. - * @throws When no browser replay is shown, or it names no study. + * @throws When no browser replay is shown, or it names no study; {@link openRecording}'s refusals of a pending presentation change and of a study on its way to the Trash, before anything changes. * @example await this.reloadShownRecording() */ private async reloadShownRecording(): Promise { @@ -9158,6 +9160,9 @@ export class StudyWorkspace { : (this.playback?.state().studyId ?? this.selection.studyId) if (!this.browserReplayShown() || !studyId) throw new Error('Select a recording first.') + // What openRecording would refuse is refused before the replay is left, so a refused Reload changes nothing: a presentation change that began while Retry read the storage keeps the replay and its failure as they were (Cycle 0052 task 39). After these checks openRecording only awaits the reader's finished initialization. + this.assertPresentationIdle() + this.assertStudyNotMoving(studyId) // The controller keeps a pending load of the same study and would start none: a Reload whose recording has not loaded cancels that load first, so the recording loads again (Cycle 0052 task 35). if (!this.recordingActive) this.playback?.leave() this.leavePlayback(true) @@ -9525,7 +9530,7 @@ export class StudyWorkspace { // The reader's close closes its owned tabs, the slot's among them. this.nodeSourceSlotTabsAwaitingArea = undefined // A test replay entry still waiting for its turn belongs to the closed reader and never opens it again (Cycle 0052 task 12). - this.nodeHistoryEntryRequest++ + this.supersedeNodeHistoryEntries() // Closing disposes the native session: a Node stop still loading its values is abandoned, and no Run and Debug hint may survive a reopen. this.selectedNodeHistoryRequest++ // The cut-off selection no longer clears its requested moment (its token is gone), so the bump gives it back. @@ -11740,6 +11745,19 @@ export class StudyWorkspace { ) } + /** + * Supersedes every test replay entry under way: a newer entry's ticket replaces theirs, and the cancellation their waits listen to aborts, so one waiting for the work window's focus ends at once (Cycle 0052 tasks 12 and 40). Called by {@link openNodeHistoryFromHost} for each new entry, by the learner's and the agent's Return and by {@link close}. + * @returns The new ticket, which the caller holds as its entry when it is one. + * @example const entry = this.supersedeNodeHistoryEntries() + */ + private supersedeNodeHistoryEntries(): number { + this.nodeHistoryEntryCancellation.abort( + new Error('A newer test replay entry, a Return or a Close took over.'), + ) + this.nodeHistoryEntryCancellation = new AbortController() + return ++this.nodeHistoryEntryRequest + } + /** * Opens a test replay for the learner's two entries, the status item's Open replay (and Open retained history) and a Saved studies choice: the summary composition is entered first (Cycle 0051, DS-A), then the shared open runs ({@link openNodeHistory}); called by {@link openLearningReplay} and by the reader's `node-history` message. * During a browser replay the learner is told that one replay shows at a time and may close it first (DS10). A failed open raises the load-failure notification with Show details and writes one Output › Codebook entry; a reader the open left in the summary composition with no replay shown goes back to the Study composition, so the Study pane stands open on Saved studies (X4, DS-G). @@ -11752,7 +11770,8 @@ export class StudyWorkspace { studyId: string, revision?: number, ): Promise { - const entry = ++this.nodeHistoryEntryRequest + const entry = this.supersedeNodeHistoryEntries(), + overtaken = this.nodeHistoryEntryCancellation.signal // Entries take turns entering the composition; a newer entry made meanwhile is the learner's latest choice (EN19). const previousTurn = this.nodeHistoryEntryTurn let releaseTurn!: () => void @@ -11763,7 +11782,7 @@ export class StudyWorkspace { let entered = false try { await previousTurn - entered = await this.enterCompositionForEntry(studyId, entry) + entered = await this.enterCompositionForEntry(studyId, entry, overtaken) } finally { this.nodeHistoryEntriesUnderWay-- releaseTurn() @@ -11804,12 +11823,14 @@ export class StudyWorkspace { * Brings a test replay entry into the summary composition in its turn: a browser replay is closed first when the learner agrees, and a refused entry is told like a failed open (F382, F384); called by {@link openNodeHistoryFromHost} while the entry holds its turn. * @param studyId - The test replay's study id. * @param entry - The entry's ticket ({@link nodeHistoryEntryRequest}). + * @param overtaken - Aborts when a newer entry, a Return or a Close supersedes the ticket ({@link nodeHistoryEntryCancellation}). * @returns `true` when the entry stands in the composition and is still the latest; `false` when a newer entry took over, the browser replay stays open, or the failure was told. - * @example if (!(await this.enterCompositionForEntry(studyId, entry))) return + * @example if (!(await this.enterCompositionForEntry(studyId, entry, overtaken))) return */ private async enterCompositionForEntry( studyId: string, entry: number, + overtaken: AbortSignal, ): Promise { if (entry !== this.nodeHistoryEntryRequest) return false // The learner's test replay supersedes a saved study that waits for the hidden editor area: the slot tabs that waited with it close now, before its own slot shows, so its tab is never closed as an earlier one's and a failed open leaves none behind (Cycle 0052 task 15). @@ -11820,9 +11841,9 @@ export class StudyWorkspace { if (!(await this.closeBrowserReplayForTestReplay(entry))) return false if (entry !== this.nodeHistoryEntryRequest) return false try { - await this.enterSummaryCompositionForReplay(entry) + await this.enterSummaryCompositionForReplay(entry, overtaken) } catch (error) { - // A newer entry answers for the learner now. + // A newer entry answers for the learner now; an entry whose focus wait its supersession aborted ends here quietly too. if (entry !== this.nodeHistoryEntryRequest) return false // A refused entry is told like a failed replay open: one Output › Codebook entry and the notification, never a bare command failure (F382, F384). await this.leaveEmptySummaryComposition() @@ -11905,9 +11926,14 @@ export class StudyWorkspace { * Only a reader that already stands in the summary composition for a test replay (another test replay was shown) is left as it is; an earlier entry that failed after it left the Live composition is opened again, so the Application, the slot and the debugger never show without a transport (F384). * @throws What {@link open} throws; the caller tells it. * @param entry - The entry's ticket ({@link nodeHistoryEntryRequest}); an entry a newer one or a Return overtook while it waited for the work window reveals nothing. - * @example await this.enterSummaryCompositionForReplay(entry) + * @param overtaken - Aborts the wait for the work window when a newer entry, a Return or a Close supersedes the ticket, so the next entry's turn is not held for the focus bound (Cycle 0052 task 40). + * @throws The abort's reason when the wait is overtaken; the caller ends quietly for a superseded ticket. + * @example await this.enterSummaryCompositionForReplay(entry, overtaken) */ - private async enterSummaryCompositionForReplay(entry: number): Promise { + private async enterSummaryCompositionForReplay( + entry: number, + overtaken: AbortSignal, + ): Promise { if ( this.opened && this.summaryComposition && @@ -11915,7 +11941,7 @@ export class StudyWorkspace { !this.recordingActive ) { // The composition stands already, but the learner may have hidden the summary and the bottom panel: the entry shows them again, so Open replay never ends with no visible replay (Cycle 0052 task 14). The reveal waits for the work window as open()'s does, and an entry overtaken meanwhile reveals nothing. - await this.waitForRecordingWindow(undefined, this.lifetime) + await this.waitForRecordingWindow(overtaken, this.lifetime) if (entry !== this.nodeHistoryEntryRequest) return await this.showLearningSummary(undefined, true) return @@ -12828,6 +12854,8 @@ export class StudyWorkspace { this.playback?.state().studyId ?? this.sourceView?.studyId, this.sourceView?.uri.toString(), ) + // A departure releases the recorded viewport before the controller leaves, so the controller's clear reaches no open viewport and the Application pane never paints "Loading recorded position…" while it leaves the replay (Cycle 0052 task 38, N52); a same-study Reload keeps the clear, which reads true there. + if (!preserveSummary) this.dropReplayViewport() this.playback?.leave() // Only same-study Reload keeps the reader's current Summary and its reading context. if (!preserveSummary) this.clearLearningSummary() @@ -22950,7 +22978,8 @@ export class StudyWorkspace { return { webview: owner.webview, openRequest: request.openRequest, - opensRecording: request.type === 'history' && this.recordingActive, + // A same-study Reload still shows its browser replay, so a choice during it opens a recording as during the replay (Cycle 0052 task 41). + opensRecording: request.type === 'history' && this.browserReplayShown(), } } diff --git a/extension/src/tests/studyWorkspaceTestReplayBrowser.test.ts b/extension/src/tests/studyWorkspaceTestReplayBrowser.test.ts index e991966be..1eb14cc5b 100644 --- a/extension/src/tests/studyWorkspaceTestReplayBrowser.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayBrowser.test.ts @@ -1086,3 +1086,35 @@ test('Return to Live from a browser replay titles the Application pane codebook await harness.close() } }) + +// Value: protects=Return to Live from a browser replay releases the recorded viewport before the playback controller leaves, so the Application pane never paints the recording's "Loading recorded position…" line while it leaves the replay; fails_when=the controller's leave clears the still-open recorded viewport first (N52 Cursor take 742da665: two frames of "Loading recorded position…" under codebook · Recorded application before the Live pane, Cycle 0052 task 38); why_new=no test read the order of the viewport's clear and its release on a departure; seam=recordedReaderHarness, the recording viewport's clearRecorded and close; no_claim=the webview's paint timing, which N52 observes +test('Return to Live from a browser replay releases the recorded viewport without clearing it to its loading line', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + await harness.reader.openRecording(harness.studyId) + await harness.controller.settle() + const viewport = harness.viewportInstances.find( + (instance) => instance.mode === 'recording', + )! + const viewportCalls: string[] = [] + const clearRecorded = viewport.clearRecorded.bind(viewport), + close = viewport.close.bind(viewport) + viewport.clearRecorded = (): void => { + viewportCalls.push('clear') + clearRecorded() + } + viewport.close = (): void => { + viewportCalls.push('close') + close() + } + + // Act + await harness.reader.action('return-current') + + // Assert + assert.deepEqual(viewportCalls, ['close']) + } finally { + await harness.close() + } +}) diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index 7104f39cc..cc8262b71 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -3171,3 +3171,154 @@ for (const ending of ['held', 'failed'] as const) { } }) } + +// Value: protects=Retry storage / refresh after a failed same-study Reload that meets a presentation change begun while it read the storage is refused before it leaves the replay, so the failed recording and its reason stay as they were; fails_when=reloadShownRecording leaves the playback and sets the Reload mark before openRecording refuses the pending presentation change, so the replay's failure is gone and no load starts (hosted CodeRabbit on PR #125, Cycle 0052 task 39); why_new=the Reload's refusals were only tested from the Reload button, which action() checks before anything changes; seam=recordedReaderHarness, a Reload failed at the debugger replacement, an agent's restore held at its layout response while Retry reads the storage; no_claim=which presentation change the agent asks for +test('Retry after a failed same-study Reload that meets a pending presentation change keeps the failed replay as it was', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + let releaseArrangement = (): void => {} + try { + await reloadHeldOrFailed(harness, 'failed') + const failedReplay = harness.controller.state() + const arrangement = harness.holdNextLayoutResponse() + releaseArrangement = arrangement.release + + // Act: Retry starts reading the storage, and an agent's restore begins and waits for its layout meanwhile. + const retry = harness.reader.action('retry').then( + () => 'retried', + (error: unknown) => String(error), + ) + const presentation = harness.reader.presentationState() + const restore = harness.reader + .changePresentation( + { + presentationId: presentation.presentationId, + expectedRevision: presentation.presentationControlRevision, + mode: presentation.mode, + action: 'restore', + }, + 'agent', + ) + .catch(() => undefined) + await arrangement.started + const retryOutcome = await retry + const replayAfterRetry = harness.controller.state() + arrangement.release() + await restore + + // Assert + assert.match(retryOutcome, /presentation change or reader close is pending/) + assert.deepEqual( + { + studyId: replayAfterRetry.studyId, + readiness: replayAfterRetry.readiness, + reason: replayAfterRetry.reason, + }, + { + studyId: harness.studyId, + readiness: 'failed', + reason: failedReplay.reason, + }, + ) + } finally { + releaseArrangement() + await harness.close() + } +}) + +// Value: protects=a learner's Return to Live ends an Open replay that waits for the work window's focus at once, so it neither holds the next entry's turn for the 10 s focus bound nor tells a failure; fails_when=the overtaken entry keeps polling the window focus until its bound after the Return (hosted CodeRabbit on PR #125, Cycle 0052 task 40); why_new=task 12 tested entries waiting for their turn, not one waiting for the focus inside its turn; seam=changeWindowFocus(false), the harness's count of unfocused window reads, a bound of half the focus wait; no_claim=the native window manager +test('Return to Live ends an Open replay that waits for the work window’s focus at once', async () => { + // Arrange + const harness = await workspaceHarness() + try { + const second = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await openByOpenReplay(harness) + harness.hideSummary() + harness.changeWindowFocus(false) + const readsBefore = harness.unfocusedWindowReads() + let hasEntryEnded = false + const entry = harness.reader + .openLearningReplay(second.studyId) + .finally(() => { + hasEntryEnded = true + }) + await waitUntil( + () => harness.unfocusedWindowReads() > readsBefore, + 'the entry waits for the window focus', + READER_RESULT_BOUND_MS, + ) + + // Act + const returning = harness.reader.action('return-current') + // Half of the 10 s focus wait: an entry still waiting for the focus would not end within it. + await waitUntil( + () => hasEntryEnded, + 'the overtaken entry ends before the focus wait would', + 5_000, + ) + harness.changeWindowFocus(true) + await returning + await entry + + // Assert + assert.deepEqual( + { + errors: harness.errorMessages, + testReplay: harness.reader.inspect().view?.nodeHistory?.header.studyId, + }, + { errors: [], testReplay: undefined }, + ) + } finally { + harness.changeWindowFocus(true) + await harness.close() + } +}) + +// Value: protects=a saved study chosen in the bottom panel after a same-study Reload failed keeps the panel's open request unanswered until the chosen recording has loaded, as a choice during the replay does, so the panel's list does not fall back to the failed replay's study meanwhile; fails_when=openReplyOwedTo reads recordingActive alone, so the reply is posted while the chosen recording still loads (hosted CodeRabbit on PR #125, Cycle 0052 task 41); why_new=task 28 tested the choice's open, not its pane reply; seam=recordedReaderHarness, a Reload failed and the chosen load held at the debugger replacement, one event-loop turn for the choice's handling to end, the bottom panel's open-request replies; no_claim=the panel's list rendering +test('a Saved studies choice after a failed same-study Reload is answered once the chosen recording has loaded', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + let chosenLoad: ReturnType | undefined + try { + const other = await saveOtherBrowserStudyShown(harness) + await reloadHeldOrFailed(harness, 'failed') + chosenLoad = holdNextRecordingLoad(harness) + + // Act + harness.receivePlaybackPanel({ + type: 'history', + revision: harness.reader.inspect().view!.revision, + studyId: other.id, + openRequest: 7, + }) + await chosenLoad.hasEntered + // The load holds at its first step while the choice's handling still has promise steps to run; one event-loop turn lets them end. + await new Promise((resolve) => setImmediate(resolve)) + const repliesWhileLoading = harness.openRequestReplies() + chosenLoad.release() + await waitUntil( + () => harness.openRequestReplies().length > 0, + 'the choice is answered', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.deepEqual( + { + repliesWhileLoading, + replies: harness.openRequestReplies(), + recording: harness.controller.state().studyId, + }, + { + repliesWhileLoading: [], + replies: [{ webview: 'moments', openRequest: 7 }], + recording: other.id, + }, + ) + } finally { + chosenLoad?.release() + await harness.close() + } +}) From fe37cce987ca9a5f102529be6aef3c572e465e0f Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 17:09:11 +0900 Subject: [PATCH 29/30] fix: leaving a browser replay withdraws its summary first; an Open replay's open() ends with its entry (Cycle 0052 tasks 43-44) - Task 43 (N52 Cursor take 2 on 270022c7): a departure withdraws the Learning summary together with the recorded viewport before the playback controller's leave publishes, so the replay's summary never redraws a departing state. Host test red on 8e781153 and 270022c7. - Task 44 (hosted CodeRabbit, outside-diff finding on 270022c7): the entry that enters a new summary composition passes its cancellation to open(), so open()'s wait for the work window ends when a newer entry, a Return or a Close supersedes it. Host test red without the fix. Test changes: the four seams of enterSummaryCompositionForReplay forward the new argument; the two tests of an agent's Return superseding an entering entry assert that the summary holds no reader document (an entry superseded before open() now builds nothing), which still fails when the superseded entry's leave is removed. --- CHANGELOG.md | 4 +- extension/README.md | 2 +- extension/src/studyWorkspace.ts | 14 +- ...tudyWorkspaceTestReplayComposition.test.ts | 144 ++++++++++++++++-- 4 files changed, 139 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5797c208..cdc412f47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,13 @@ Cycle 0052 pt1 ([Issue #124](https://github.com/laststance/codebook/issues/124)) **Leaving a replay shows nothing of the other side.** -- **Return to Live and another saved study** withdraw the Learning summary before the reader shows the Live state, so the summary never draws the Live Study page, and the transport never shows the other replay family's controls while you leave. +- **Return to Live and another saved study** withdraw the Learning summary before the reader shows the Live state, so the summary never draws the Live Study page, nor a browser replay's departing state, and the transport never shows the other replay family's controls while you leave. - **A browser replay's open and its Return to Live** send the Learning summary nothing of the Study before the replay's own summary. - **The Application tab takes back its name** `codebook · Application` after a browser replay, in the Live composition and in a test replay, and while you leave a browser replay the Application pane no longer flashes the recording's `Loading recorded position…` line. **Entries do what you chose.** -- **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; a Return to Live while a test replay is still opening ends that open at once in Live, with no loading state left. An entry overtaken while it closes earlier tabs asks no one-replay-at-a-time notice. +- **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 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. An entry overtaken while it closes earlier tabs asks no one-replay-at-a-time notice. - **A superseded Saved studies open** gives the Study pane back, and an earlier replay's failed or superseded open no longer takes the summary composition from 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. - **A browser study chosen over a test replay while the bottom panel is maximized** waits for the editor area: the panel stays maximized, and the Study pane, the study's source and the closing of the test replay's recorded-source tab wait until you restore the panel; a close the editor refuses is told and tried again at your next display. diff --git a/extension/README.md b/extension/README.md index 0aa15b1bd..a5079d140 100644 --- a/extension/README.md +++ b/extension/README.md @@ -73,7 +73,7 @@ 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; 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 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. 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 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. diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 637188c00..5f4139c37 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -11926,7 +11926,7 @@ export class StudyWorkspace { * Only a reader that already stands in the summary composition for a test replay (another test replay was shown) is left as it is; an earlier entry that failed after it left the Live composition is opened again, so the Application, the slot and the debugger never show without a transport (F384). * @throws What {@link open} throws; the caller tells it. * @param entry - The entry's ticket ({@link nodeHistoryEntryRequest}); an entry a newer one or a Return overtook while it waited for the work window reveals nothing. - * @param overtaken - Aborts the wait for the work window when a newer entry, a Return or a Close supersedes the ticket, so the next entry's turn is not held for the focus bound (Cycle 0052 task 40). + * @param overtaken - Aborts the waits for the work window, in a standing composition and in {@link open}, when a newer entry, a Return or a Close supersedes the ticket, so the next entry's turn is not held for the focus bound (Cycle 0052 tasks 40 and 44). * @throws The abort's reason when the wait is overtaken; the caller ends quietly for a superseded ticket. * @example await this.enterSummaryCompositionForReplay(entry, overtaken) */ @@ -11948,7 +11948,8 @@ export class StudyWorkspace { } this.hostOpeningTestReplay = true try { - await this.open() + // The open's waits for the work window end too when a newer entry, a Return or a Close supersedes this one (Cycle 0052 task 44). + await this.open(overtaken) } finally { this.hostOpeningTestReplay = false } @@ -12854,11 +12855,12 @@ export class StudyWorkspace { this.playback?.state().studyId ?? this.sourceView?.studyId, this.sourceView?.uri.toString(), ) - // A departure releases the recorded viewport before the controller leaves, so the controller's clear reaches no open viewport and the Application pane never paints "Loading recorded position…" while it leaves the replay (Cycle 0052 task 38, N52); a same-study Reload keeps the clear, which reads true there. - if (!preserveSummary) this.dropReplayViewport() + // A departure releases the recorded viewport and withdraws the Learning summary before the controller leaves: the controller's clear then reaches no open viewport, so the Application pane never paints "Loading recorded position…" (Cycle 0052 task 38), and its leave publishes into no replay summary, which would redraw a departing state (task 43). Only a same-study Reload keeps the clear, which reads true there, and the reader's current Summary with its reading context. + if (!preserveSummary) { + this.dropReplayViewport() + this.clearLearningSummary() + } this.playback?.leave() - // Only same-study Reload keeps the reader's current Summary and its reading context. - if (!preserveSummary) this.clearLearningSummary() this.dropReplayViewport() this.recordingActive = false this.playbackPending = undefined diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index cc8262b71..a630364f9 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -1362,7 +1362,10 @@ test('a Saved studies choice of a test replay superseded while it enters the com await harness.reader.action('retry') await harness.reader.open() const reader = harness.reader as unknown as { - enterSummaryCompositionForReplay: (entry: number) => Promise + enterSummaryCompositionForReplay: ( + entry: number, + overtaken: AbortSignal, + ) => Promise revision: number } const enter = reader.enterSummaryCompositionForReplay.bind(harness.reader) @@ -1373,8 +1376,9 @@ test('a Saved studies choice of a test replay superseded while it enters the com }) reader.enterSummaryCompositionForReplay = async ( entry: number, + overtaken: AbortSignal, ): Promise => { - await enter(entry) + await enter(entry, overtaken) // Another reader change lands while the entry stands in the composition. reader.revision++ messagesAtSupersession = harness.playbackPanelMessages.length @@ -1615,7 +1619,10 @@ test('an agent’s Return to Live that supersedes an entering test replay leaves await harness.service.record(receipt).preparation await harness.reader.open() const reader = harness.reader as unknown as { - enterSummaryCompositionForReplay: (entry: number) => Promise + enterSummaryCompositionForReplay: ( + entry: number, + overtaken: AbortSignal, + ) => Promise } const enter = reader.enterSummaryCompositionForReplay.bind(harness.reader) const held = new Promise((resolve) => { @@ -1627,10 +1634,11 @@ test('an agent’s Return to Live that supersedes an entering test replay leaves }) reader.enterSummaryCompositionForReplay = async ( entry: number, + overtaken: AbortSignal, ): Promise => { entered() await held - return enter(entry) + return enter(entry, overtaken) } // Act @@ -1648,14 +1656,14 @@ test('an agent’s Return to Live that supersedes an entering test replay leaves await entry // Assert + // The summary holds no reader document: an entry superseded before its open builds nothing (Cycle 0052 task 44), and one superseded after it leaves the placeholder. assert.deepEqual( { testReplay: harness.reader.inspect().view?.nodeHistory?.header.studyId, - summaryPlaceholder: harness.summary.webview.html.includes( - 'Open saved learning from Codebook to view its summary.', - ), + summaryHoldsReaderDocument: + harness.summary.webview.html.includes('acquireVsCodeApi'), }, - { testReplay: undefined, summaryPlaceholder: true }, + { testReplay: undefined, summaryHoldsReaderDocument: false }, ) } finally { release() @@ -1772,7 +1780,10 @@ test('an agent’s Return to Live that supersedes an entering and a waiting test await harness.service.record(receipt).preparation await harness.reader.open() const reader = harness.reader as unknown as { - enterSummaryCompositionForReplay: (entry: number) => Promise + enterSummaryCompositionForReplay: ( + entry: number, + overtaken: AbortSignal, + ) => Promise } const enter = reader.enterSummaryCompositionForReplay.bind(harness.reader) const held = new Promise((resolve) => { @@ -1784,10 +1795,11 @@ test('an agent’s Return to Live that supersedes an entering and a waiting test }) reader.enterSummaryCompositionForReplay = async ( entry: number, + overtaken: AbortSignal, ): Promise => { entered() await held - return enter(entry) + return enter(entry, overtaken) } // Act @@ -1806,14 +1818,14 @@ test('an agent’s Return to Live that supersedes an entering and a waiting test await Promise.all([firstEntry, secondEntry]) // Assert + // The summary holds no reader document: an entry superseded before its open builds nothing (Cycle 0052 task 44), and one superseded after it leaves the placeholder. assert.deepEqual( { testReplay: harness.reader.inspect().view?.nodeHistory?.header.studyId, - summaryPlaceholder: harness.summary.webview.html.includes( - 'Open saved learning from Codebook to view its summary.', - ), + summaryHoldsReaderDocument: + harness.summary.webview.html.includes('acquireVsCodeApi'), }, - { testReplay: undefined, summaryPlaceholder: true }, + { testReplay: undefined, summaryHoldsReaderDocument: false }, ) } finally { release() @@ -1881,7 +1893,10 @@ test('a learner’s Return to Live that supersedes an entering test replay tells await harness.service.record(receipt).preparation await harness.reader.open() const reader = harness.reader as unknown as { - enterSummaryCompositionForReplay: (entry: number) => Promise + enterSummaryCompositionForReplay: ( + entry: number, + overtaken: AbortSignal, + ) => Promise } const enter = reader.enterSummaryCompositionForReplay.bind(harness.reader) const held = new Promise((resolve) => { @@ -1893,10 +1908,11 @@ test('a learner’s Return to Live that supersedes an entering test replay tells }) reader.enterSummaryCompositionForReplay = async ( entry: number, + overtaken: AbortSignal, ): Promise => { entered() await held - return enter(entry) + return enter(entry, overtaken) } // Act @@ -3322,3 +3338,99 @@ test('a Saved studies choice after a failed same-study Reload is answered once t await harness.close() } }) + +// Value: protects=Return to Live from a browser replay withdraws the Learning summary before the playback controller's leave publishes, so the replay's summary never redraws a departing state (its captured source not ready, its observation loading) before the placeholder; fails_when=the summary still holds the replay's document when the leave's publish reaches it (N52 Cursor take 2 on 270022c7: one frame of "The current captured source is not ready…" and "Loading the selected observation…", Cycle 0052 task 43); why_new=task 6's test counted only Study messages without a recording, and this message carries the browser family; seam=recordedReaderHarness, the summary webview's html and postMessage from the Return's start; no_claim=the native frames, which N52 (e) checks +test('Return to Live from a browser replay sends its Learning summary nothing once the departure starts', async () => { + // Arrange + const harness = await recordedReaderHarness(true) + try { + await harness.reader.openRecording(harness.studyId) + await harness.controller.settle() + const replayDocument = harness.summary.webview.html + let isReturning = false + const drawnWhileReturning: string[] = [], + post = harness.summary.webview.postMessage.bind(harness.summary.webview) + harness.summary.webview.postMessage = async (message: StudyViewMessage) => { + // Only a message that reaches the replay's own document draws anything; the placeholder has no script. + if (isReturning && harness.summary.webview.html === replayDocument) + drawnWhileReturning.push(message.type) + return post(message) + } + + // Act + isReturning = true + await harness.reader.action('return-current') + + // Assert + assert.deepEqual(drawnWhileReturning, []) + } finally { + await harness.close() + } +}) + +// Value: protects=a newer Open replay ends an earlier one that waits for the work window's focus inside open() while it enters the summary composition from Live, so the earlier entry holds no turn for the 10 s focus bound and tells nothing; fails_when=open() gets no cancellation from the entry and its wait for the window goes on until the focus returns or its bound passes (hosted CodeRabbit on PR #125, outside-diff finding at studyWorkspace.ts:11951, Cycle 0052 task 44); why_new=task 40's test covered the standing composition's wait, not open()'s; seam=the first entry's layout response held while the window loses the focus, changeWindowFocus, the harness's count of unfocused window reads, a bound of half the focus wait; no_claim=the native window manager +test('a newer Open replay ends an earlier one that waits for the work window’s focus while it enters from Live', async () => { + // Arrange + const harness = await workspaceHarness() + let releaseArrangement = (): void => {} + try { + const first = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + const second = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await harness.reader.action('retry') + await harness.reader.open() + // The window keeps the focus while the first entry prepares the Application group, then loses it before the summary's reveal. + const arrangement = harness.holdNextLayoutResponse() + releaseArrangement = arrangement.release + let hasFirstEnded = false + const firstEntry = harness.reader + .openLearningReplay(first.studyId) + .finally(() => { + hasFirstEnded = true + }) + await arrangement.started + harness.changeWindowFocus(false) + const readsBefore = harness.unfocusedWindowReads() + arrangement.release() + await waitUntil( + () => harness.unfocusedWindowReads() > readsBefore, + 'the first entry waits for the window focus', + READER_RESULT_BOUND_MS, + ) + + // Act + const secondEntry = harness.reader.openLearningReplay(second.studyId) + // Half of the 10 s focus wait: an entry still waiting for the focus would not end within it. + await waitUntil( + () => hasFirstEnded, + 'the overtaken entry ends before the focus wait would', + 5_000, + ) + harness.changeWindowFocus(true) + await firstEntry + await secondEntry + await waitUntil( + () => + lastView(harness)?.nodeHistory?.header.studyId === second.studyId && + !lastView(harness)?.loading, + 'the second replay is shown', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.deepEqual( + { + errors: harness.errorMessages, + testReplay: lastView(harness)?.nodeHistory?.header.studyId, + }, + { errors: [], testReplay: second.studyId }, + ) + } finally { + releaseArrangement() + harness.changeWindowFocus(true) + await harness.close() + } +}) From c89e7dc2fa665d7887d93e465295359aae0d4c89 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Sun, 25 Oct 2026 17:25:21 +0900 Subject: [PATCH 30/30] fix: an overtaken Open replay's Learning summary reveal ends with its entry (Cycle 0052 task 45) Hosted CodeRabbit (outside-diff finding on fe37cce9): the standing composition's showLearningSummary got no cancellation, so its wait for the summary view held the next entry's turn for its 5 s bound. It now gets the entry's cancellation. Host test red without the fix (the earlier entry still waiting at 2.5 s), green with it. --- CHANGELOG.md | 2 +- extension/README.md | 2 +- extension/src/studyWorkspace.ts | 5 +- ...tudyWorkspaceTestReplayComposition.test.ts | 59 +++++++++++++++++++ 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdc412f47..7d969114b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ Cycle 0052 pt1 ([Issue #124](https://github.com/laststance/codebook/issues/124)) **Entries do what you chose.** -- **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 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. An entry overtaken while it closes earlier tabs asks no one-replay-at-a-time notice. +- **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. An entry overtaken while it closes earlier tabs asks no one-replay-at-a-time notice. - **A superseded Saved studies open** gives the Study pane back, and an earlier replay's failed or superseded open no longer takes the summary composition from 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. - **A browser study chosen over a test replay while the bottom panel is maximized** waits for the editor area: the panel stays maximized, and the Study pane, the study's source and the closing of the test replay's recorded-source tab wait until you restore the panel; a close the editor refuses is told and tried again at your next display. diff --git a/extension/README.md b/extension/README.md index a5079d140..9fe1b3c5b 100644 --- a/extension/README.md +++ b/extension/README.md @@ -73,7 +73,7 @@ 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 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. 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. diff --git a/extension/src/studyWorkspace.ts b/extension/src/studyWorkspace.ts index 5f4139c37..ca0fe5969 100644 --- a/extension/src/studyWorkspace.ts +++ b/extension/src/studyWorkspace.ts @@ -11926,7 +11926,7 @@ export class StudyWorkspace { * Only a reader that already stands in the summary composition for a test replay (another test replay was shown) is left as it is; an earlier entry that failed after it left the Live composition is opened again, so the Application, the slot and the debugger never show without a transport (F384). * @throws What {@link open} throws; the caller tells it. * @param entry - The entry's ticket ({@link nodeHistoryEntryRequest}); an entry a newer one or a Return overtook while it waited for the work window reveals nothing. - * @param overtaken - Aborts the waits for the work window, in a standing composition and in {@link open}, when a newer entry, a Return or a Close supersedes the ticket, so the next entry's turn is not held for the focus bound (Cycle 0052 tasks 40 and 44). + * @param overtaken - Aborts the waits for the work window and for the Learning summary view, in a standing composition and in {@link open}, when a newer entry, a Return or a Close supersedes the ticket, so the next entry's turn is not held for their bounds (Cycle 0052 tasks 40, 44 and 45). * @throws The abort's reason when the wait is overtaken; the caller ends quietly for a superseded ticket. * @example await this.enterSummaryCompositionForReplay(entry, overtaken) */ @@ -11943,7 +11943,8 @@ export class StudyWorkspace { // The composition stands already, but the learner may have hidden the summary and the bottom panel: the entry shows them again, so Open replay never ends with no visible replay (Cycle 0052 task 14). The reveal waits for the work window as open()'s does, and an entry overtaken meanwhile reveals nothing. await this.waitForRecordingWindow(overtaken, this.lifetime) if (entry !== this.nodeHistoryEntryRequest) return - await this.showLearningSummary(undefined, true) + // The reveal's wait for the summary view ends too when the entry is overtaken (Cycle 0052 task 45). + await this.showLearningSummary(overtaken, true) return } this.hostOpeningTestReplay = true diff --git a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts index a630364f9..d92325fc3 100644 --- a/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts +++ b/extension/src/tests/studyWorkspaceTestReplayComposition.test.ts @@ -3434,3 +3434,62 @@ test('a newer Open replay ends an earlier one that waits for the work window’s await harness.close() } }) + +// Value: protects=a newer Open replay ends an earlier one whose Learning summary reveal in the standing composition waits for the summary view, so the earlier entry holds no turn for the reveal's 5 s bound and tells nothing; fails_when=the standing composition's showLearningSummary gets no cancellation from the entry and waits for the view until its bound (hosted CodeRabbit on PR #125, outside-diff finding at studyWorkspace.ts:11946, Cycle 0052 task 45); why_new=tasks 40 and 44 covered the waits for the work window, not the summary reveal's wait for its view; seam=the first entry's codebook.learningSummary.focus held, hideSummary after it so the view stays hidden, a bound of half the reveal's wait; no_claim=the native view container +test('a newer Open replay ends an earlier one whose Learning summary reveal waits in the standing composition', async () => { + // Arrange + const harness = await workspaceHarness() + let release = (): void => {} + try { + const second = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + const third = await saveTwoStopNodeHistory( + harness.service.nodeHistoryStore!, + ) + await openByOpenReplay(harness) + harness.hideSummary() + const held = harness.holdNextCommand('codebook.learningSummary.focus') + release = held.release + let hasFirstEnded = false + const firstEntry = harness.reader + .openLearningReplay(second.studyId) + .finally(() => { + hasFirstEnded = true + }) + await held.started + // The editor shows no summary for this reveal, so the reveal would wait for the view. + harness.hideSummary() + + // Act + const secondEntry = harness.reader.openLearningReplay(third.studyId) + held.release() + // Half of the reveal's 5 s wait for the view: an entry still waiting for it would not end within it. + await waitUntil( + () => hasFirstEnded, + 'the overtaken entry ends before its summary wait would', + 2_500, + ) + await firstEntry + await secondEntry + await waitUntil( + () => + lastView(harness)?.nodeHistory?.header.studyId === third.studyId && + !lastView(harness)?.loading, + 'the third replay is shown', + READER_RESULT_BOUND_MS, + ) + + // Assert + assert.deepEqual( + { + errors: harness.errorMessages, + testReplay: lastView(harness)?.nodeHistory?.header.studyId, + }, + { errors: [], testReplay: third.studyId }, + ) + } finally { + release() + await harness.close() + } +})