diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 898c8d2..1da881a 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -53,6 +53,7 @@ var tests = new (string Name, Action Test)[] ("Story Intelligence evidence extraction prevents observed attribute leakage", StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage), ("Illustration assignment repair enforces explicit evidence groups and strict subtypes", IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes), ("Phase 21O preserves adult defaults and real-mode continuity", Phase21OPreservesAdultDefaultsAndRealModeContinuity), + ("Phase 21P uses one authoritative current visualisation scene", Phase21PUsesOneAuthoritativeCurrentVisualisationScene), ("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions), ("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel), ("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics), @@ -635,6 +636,23 @@ static void Phase21OPreservesAdultDefaultsAndRealModeContinuity() Assert(view.Contains("Model.Mode == \"simulation\"", StringComparison.Ordinal), "Prototype controls should be gated to simulation mode."); } +static void Phase21PUsesOneAuthoritativeCurrentVisualisationScene() +{ + var root = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".."); + var viewModel = File.ReadAllText(Path.Combine(root, "PlotLine", "ViewModels", "StoryIntelligenceExperiencePrototypeViewModels.cs")); + var snapshotService = File.ReadAllText(Path.Combine(root, "PlotLine", "Services", "StoryIntelligenceVisualisationSnapshotService.cs")); + var browser = File.ReadAllText(Path.Combine(root, "PlotLine", "wwwroot", "js", "story-intelligence-experience-prototype.js")); + var view = File.ReadAllText(Path.Combine(root, "PlotLine", "Views", "Development", "StoryIntelligenceExperience.cshtml")); + + Assert(viewModel.Contains("public bool IsCurrent", StringComparison.Ordinal), "Scene snapshots must expose an authoritative current-scene marker."); + Assert(snapshotService.Contains("IsCurrent = isCurrent", StringComparison.Ordinal), "Snapshot builder must mark the selected current scene."); + Assert(snapshotService.Contains("followingCount: 0", StringComparison.Ordinal), "Real import snapshots must not include following scenes that can pull the browser ahead."); + Assert(snapshotService.Contains("Story Intelligence snapshot synchronisation", StringComparison.Ordinal), "Snapshot builder must log synchronisation evidence."); + Assert(browser.Contains("scene?.isCurrent", StringComparison.Ordinal), "Browser must choose the current scene from the authoritative marker."); + Assert(browser.Contains("Story Intelligence browser synchronisation", StringComparison.Ordinal), "Browser must log synchronisation evidence."); + Assert(view.Contains("Scenes = Model.Scenes", StringComparison.Ordinal), "Initial real page payload must include the same scene collection as polled snapshots."); +} + static IllustrationCharacterMetadata Metadata(string code, string ageBand, string presentation, bool isUnknownFigure = false) => new( ageBand, diff --git a/PlotLine/Controllers/StoryIntelligenceVisualisationController.cs b/PlotLine/Controllers/StoryIntelligenceVisualisationController.cs index ff0287c..f59054a 100644 --- a/PlotLine/Controllers/StoryIntelligenceVisualisationController.cs +++ b/PlotLine/Controllers/StoryIntelligenceVisualisationController.cs @@ -8,7 +8,8 @@ namespace PlotLine.Controllers; [Route("api/story-intelligence")] public sealed class StoryIntelligenceVisualisationController( ICurrentUserService currentUser, - IStoryIntelligenceVisualisationSnapshotService snapshots) : ControllerBase + IStoryIntelligenceVisualisationSnapshotService snapshots, + ILogger logger) : ControllerBase { [HttpGet("runs/{runId:int}/visualisation-snapshot")] public async Task GetSnapshot(int runId) @@ -19,6 +20,18 @@ public sealed class StoryIntelligenceVisualisationController( } var snapshot = await snapshots.BuildSnapshotAsync(runId, userId); + if (snapshot is not null) + { + var current = snapshot.Scenes.FirstOrDefault(scene => scene.IsCurrent) ?? snapshot.Scenes.LastOrDefault(); + logger.LogInformation( + "Story Intelligence browser endpoint synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {CurrentChapter}; current scene {CurrentScene}; scene result {SceneResultId}; snapshot version Endpoint; change token {ChangeToken}.", + snapshot.ImportSessionId, + snapshot.RunId, + current?.ChapterLabel, + current?.SceneNumber, + snapshot.Diagnostics.SelectedCurrentSceneResultId, + snapshot.ChangeToken); + } return snapshot is null ? NotFound() : Ok(snapshot); } @@ -31,6 +44,18 @@ public sealed class StoryIntelligenceVisualisationController( } var snapshot = await snapshots.BuildImportSessionSnapshotAsync(importSessionId, userId); + if (snapshot is not null) + { + var current = snapshot.Scenes.FirstOrDefault(scene => scene.IsCurrent) ?? snapshot.Scenes.LastOrDefault(); + logger.LogInformation( + "Story Intelligence browser endpoint synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {CurrentChapter}; current scene {CurrentScene}; scene result {SceneResultId}; snapshot version Endpoint; change token {ChangeToken}.", + snapshot.ImportSessionId, + snapshot.RunId, + current?.ChapterLabel, + current?.SceneNumber, + snapshot.Diagnostics.SelectedCurrentSceneResultId, + snapshot.ChangeToken); + } return snapshot is null ? NotFound() : Ok(snapshot); } } diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index 949dc11..bd20128 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -253,6 +253,7 @@ public sealed class OnboardingStoryIntelligenceService( SourceWordCount = run.SourceWordCount, TotalTokens = run.TotalTokens, TotalDurationMs = run.TotalDurationMs, + UpdatedUtc = run.UpdatedUtc, EstimatedCostUSD = run.EstimatedCostUSD, EstimatedCostGBP = run.EstimatedCostGBP, FailureStage = run.FailureStage, diff --git a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs index 00ecf76..c1c69f2 100644 --- a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs +++ b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs @@ -13,6 +13,7 @@ public interface IPersistedStoryIntelligenceRunner public sealed class PersistedStoryIntelligenceRunner( IStoryIntelligenceResultRepository repository, + IStoryIntelligencePipelineRepository pipelines, IStoryPromptRepository prompts, IStoryPromptVersionService versions, IStoryPromptBuilder scenePromptBuilder, @@ -304,7 +305,7 @@ public sealed class PersistedStoryIntelligenceRunner( hasWarnings = hasWarnings || validation.Warnings.Count > 0; - await repository.SaveSceneResultAsync( + var sceneResultId = await repository.SaveSceneResultAsync( run.StoryIntelligenceRunID, chapterResultId, new StoryIntelligenceSceneResultSaveRequest @@ -329,6 +330,14 @@ public sealed class PersistedStoryIntelligenceRunner( TotalTokens = SumTokens(sceneAttempt.InputTokens, sceneAttempt.OutputTokens), DurationMs = Convert.ToInt64(sceneAttempt.Duration.TotalMilliseconds) }); + var importSessionId = run.BookID.HasValue ? (await pipelines.GetByBookAsync(run.BookID.Value))?.StoryIntelligenceBookPipelineID : null; + logger.LogInformation( + "Story Intelligence importer synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {ChapterNumber}; current scene {SceneNumber}; scene result {SceneResultId}; snapshot version ImportPipeline; change token n/a; status analysed.", + importSessionId, + run.StoryIntelligenceRunID, + run.ChapterNumber, + block.TemporarySceneNumber, + sceneResultId); completedScenes++; await PublishAsync( @@ -721,7 +730,7 @@ public sealed class PersistedStoryIntelligenceRunner( string message, AiJsonParseException? parseException = null) { - await repository.SaveSceneResultAsync( + var sceneResultId = await repository.SaveSceneResultAsync( run.StoryIntelligenceRunID, chapterResultId, new StoryIntelligenceSceneResultSaveRequest @@ -752,6 +761,14 @@ public sealed class PersistedStoryIntelligenceRunner( TotalTokens = SumTokens(parseException?.InputTokens, parseException?.OutputTokens), DurationMs = parseException is null ? null : Convert.ToInt64(parseException.Duration.TotalMilliseconds) }); + var importSessionId = run.BookID.HasValue ? (await pipelines.GetByBookAsync(run.BookID.Value))?.StoryIntelligenceBookPipelineID : null; + logger.LogInformation( + "Story Intelligence importer synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {ChapterNumber}; current scene {SceneNumber}; scene result {SceneResultId}; snapshot version ImportPipeline; change token n/a; status failed.", + importSessionId, + run.StoryIntelligenceRunID, + run.ChapterNumber, + block.TemporarySceneNumber, + sceneResultId); } private StoryIntelligenceStageModels ResolveStageModels(StoryIntelligenceQueuedRun run) diff --git a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs index 3ba112a..9cbb6ac 100644 --- a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs +++ b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs @@ -188,7 +188,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( } var activeSnapshot = PickActiveRunSnapshot(runSnapshots) ?? runSnapshots.Last(); - var current = activeSnapshot.Current ?? runSnapshots.LastOrDefault(snapshot => snapshot.Completed.Count > 0)?.Current; + var current = PickAuthoritativeCurrentScene(runSnapshots); + var currentSnapshot = OwnerOf(runSnapshots, current) ?? activeSnapshot; var totalPersistedResults = runSnapshots.Sum(snapshot => snapshot.Results.Count); var parsedScenes = runSnapshots.Sum(snapshot => snapshot.Completed.Count); var totalScenes = Math.Max( @@ -215,8 +216,12 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( var overallStage = isSuccessfullyComplete ? "Analysis Complete" : isTerminal ? "Analysis stopped" : BackendStageDisplay(activeSnapshot.Run); var scenes = new List(); var storyMemory = new StoryMemory(); - var allCompleted = runSnapshots.SelectMany(snapshot => snapshot.Completed).ToList(); - var visibleSceneIds = VisibleSceneResultIds(allCompleted, current, SceneWindowPrevious, SceneWindowFollowing); + var allCompleted = runSnapshots + .SelectMany(snapshot => snapshot.Completed) + .OrderBy(item => item.Result.CreatedUtc) + .ThenBy(item => item.Result.SceneResultID) + .ToList(); + var visibleSceneIds = VisibleSceneResultIds(allCompleted, current, SceneWindowPrevious, followingCount: 0); foreach (var snapshot in runSnapshots) { var runScenes = BuildSceneStates( @@ -248,7 +253,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( { Mode = "real", ImportSessionId = session.StoryIntelligenceBookPipelineID, - RunId = activeSnapshot.Run.StoryIntelligenceRunID, + RunId = currentSnapshot.Run.StoryIntelligenceRunID, BookId = session.BookID, RunStatus = SessionStatus(session, runSnapshots), GeneratedUtc = DateTime.UtcNow, @@ -258,7 +263,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( Diagnostics = BuildSessionDiagnostics( session, runSnapshots, - activeSnapshot, + currentSnapshot, current, parsedScenes, totalPersistedResults, @@ -280,6 +285,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( await illustrationMatcher.ApplyCharacterIllustrationsAsync(session.ProjectID, model); ApplyIllustrationDiagnostics(model); StripLiveDiagnostics(model); + LogSnapshotSynchronisation(importSessionId, currentSnapshot, current, scenes, model.ChangeToken); LogPayload(model, $"import session {importSessionId}", stopwatch.ElapsedMilliseconds); return model; } @@ -322,6 +328,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( yield return new StoryIntelligenceExperienceSceneState { Id = $"scene-result-{item.Result.SceneResultID}", + IsCurrent = isCurrent, ChapterLabel = ChapterLabel(run, item), SceneNumber = sceneNumber, Title = SceneTitle(scene, item.Result, sceneNumber), @@ -382,9 +389,24 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( return null; } - return completed.Last(); + return completed + .OrderBy(item => item.Result.CreatedUtc) + .ThenBy(item => item.Result.SceneResultID) + .Last(); } + private static ParsedSceneResult? PickAuthoritativeCurrentScene(IReadOnlyList snapshots) + => snapshots + .SelectMany(snapshot => snapshot.Completed) + .OrderBy(item => item.Result.CreatedUtc) + .ThenBy(item => item.Result.SceneResultID) + .LastOrDefault(); + + private static RunSnapshot? OwnerOf(IReadOnlyList snapshots, ParsedSceneResult? scene) + => scene is null + ? null + : snapshots.FirstOrDefault(snapshot => snapshot.Run.StoryIntelligenceRunID == scene.Result.StoryIntelligenceRunID); + private static IReadOnlyList BuildCharacters(SceneIntelligenceScene scene, StoryMemory? storyMemory = null) { var narratorResolution = storyMemory?.ResolvePointOfView(scene) ?? ResolveNarratorName(scene); @@ -817,8 +839,21 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( } private static RunSnapshot? PickActiveRunSnapshot(IReadOnlyList snapshots) - => snapshots.FirstOrDefault(snapshot => !IsTerminal(snapshot.Run.Status)) - ?? snapshots.LastOrDefault(snapshot => snapshot.Completed.Count > 0) + => snapshots + .Where(snapshot => string.Equals(snapshot.Run.Status, StoryIntelligenceRunStatuses.Running, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(snapshot => snapshot.Run.UpdatedUtc) + .ThenByDescending(snapshot => snapshot.Run.StoryIntelligenceRunID) + .FirstOrDefault() + ?? snapshots + .Where(snapshot => !IsTerminal(snapshot.Run.Status)) + .OrderBy(snapshot => snapshot.Run.ChapterNumber ?? decimal.MaxValue) + .ThenBy(snapshot => snapshot.Run.StoryIntelligenceRunID) + .FirstOrDefault() + ?? snapshots + .Where(snapshot => snapshot.Completed.Count > 0) + .OrderByDescending(snapshot => snapshot.Run.UpdatedUtc) + .ThenByDescending(snapshot => snapshot.Run.StoryIntelligenceRunID) + .FirstOrDefault() ?? snapshots.LastOrDefault(); private static string OverallStage(StoryIntelligenceBookPipelineState session, StoryIntelligenceSavedRun activeRun) @@ -1290,7 +1325,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( { if (snapshot.Run.TotalDetectedScenes.HasValue) { - var scene = Math.Clamp((snapshot.Run.CompletedScenes ?? snapshot.Completed.Count) + (IsTerminal(snapshot.Run.Status) ? 0 : 1), 1, snapshot.Run.TotalDetectedScenes.Value); + var scene = Math.Clamp(snapshot.Run.CompletedScenes ?? snapshot.Completed.Count, 0, snapshot.Run.TotalDetectedScenes.Value); return $"Scene {scene:N0} of {snapshot.Run.TotalDetectedScenes.Value:N0}"; } @@ -1613,6 +1648,26 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( largestString.Length); } + private void LogSnapshotSynchronisation( + int importSessionId, + RunSnapshot activeSnapshot, + ParsedSceneResult? current, + IReadOnlyList scenes, + string changeToken) + { + var displayed = scenes.FirstOrDefault(scene => scene.IsCurrent) ?? scenes.LastOrDefault(); + logger.LogInformation( + "Story Intelligence snapshot synchronisation: import session {ImportSessionId}; active run {RunId}; current chapter {ChapterNumber}; current scene {SceneNumber}; selected scene result {SceneResultId}; displayed scene id {DisplayedSceneId}; change token {ChangeToken}; payload scenes {SceneIds}.", + importSessionId, + activeSnapshot.Run.StoryIntelligenceRunID, + activeSnapshot.Run.ChapterNumber, + current is null ? null : SceneSortNumber(current), + current?.Result.SceneResultID, + displayed?.Id, + changeToken, + string.Join(",", scenes.Select(scene => scene.Id))); + } + private static (string Path, int Length) LargestStringProperty(StoryIntelligenceExperiencePrototypeViewModel model) { var largest = ("model", 0); diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index 14e0d94..0e26a84 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -531,6 +531,7 @@ public sealed class StoryIntelligenceOnboardingChapterViewModel public int? SourceWordCount { get; init; } public int? TotalTokens { get; init; } public long? TotalDurationMs { get; init; } + public DateTime? UpdatedUtc { get; init; } public decimal? EstimatedCostUSD { get; init; } public decimal? EstimatedCostGBP { get; init; } public string? FailureStage { get; init; } diff --git a/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs b/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs index 9fb9ff8..7cdca9c 100644 --- a/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs +++ b/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs @@ -80,6 +80,7 @@ public sealed class StoryIntelligenceExperienceRunOption public sealed class StoryIntelligenceExperienceSceneState { public string Id { get; init; } = string.Empty; + public bool IsCurrent { get; init; } public string ChapterLabel { get; init; } = string.Empty; public int SceneNumber { get; init; } public string Title { get; init; } = string.Empty; diff --git a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml b/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml index cab9b0c..3c235bd 100644 --- a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml +++ b/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml @@ -31,7 +31,9 @@ ProjectName = Model.ProjectName, BookTitle = Model.BookTitle, SnapshotMessage = Model.SnapshotMessage, - Diagnostics = Model.Diagnostics + Diagnostics = Model.Diagnostics, + Scenes = Model.Scenes, + ChangeToken = Model.ChangeToken } : Model; } @@ -54,8 +56,8 @@ data-book-id="@Model.BookId" data-import-session-id="@Model.ImportSessionId" data-snapshot-url="@(Model.ImportSessionId.HasValue ? $"/api/story-intelligence/import-sessions/{Model.ImportSessionId.Value}/visualisation-snapshot" : Model.RunId.HasValue ? $"/api/story-intelligence/runs/{Model.RunId.Value}/visualisation-snapshot" : string.Empty)" - data-change-token="@(Model.Mode == "real" ? string.Empty : Model.ChangeToken)" - data-terminal="@((Model.Mode != "real" && Model.IsTerminal).ToString().ToLowerInvariant())"> + data-change-token="@Model.ChangeToken" + data-terminal="@Model.IsTerminal.ToString().ToLowerInvariant()">
PlotDirector diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml index 0e6798c..4210595 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml @@ -113,6 +113,10 @@
@FriendlyStatus(chapter.Status) diff --git a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js index c68aa02..8d43c3c 100644 --- a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js +++ b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js @@ -133,7 +133,8 @@ return; } - state.index = realMode ? Math.max(0, scenes.length - 1) : Math.min(state.index, Math.max(0, scenes.length - 1)); + state.index = realMode ? authoritativeSceneIndex(scenes) : Math.min(state.index, Math.max(0, scenes.length - 1)); + logSnapshotSynchronisation("snapshot-applied", model, loadSceneState(state.index)); if (model.projectName) { text(dom.projectName, model.projectName); } @@ -1219,7 +1220,25 @@ function activeScene(snapshot) { const snapshotScenes = snapshot?.scenes || []; - return snapshotScenes.length ? snapshotScenes[snapshotScenes.length - 1] : null; + return snapshotScenes.find((scene) => scene?.isCurrent) || (snapshotScenes.length ? snapshotScenes[snapshotScenes.length - 1] : null); + } + + function authoritativeSceneIndex(snapshotScenes) { + const index = (snapshotScenes || []).findIndex((scene) => scene?.isCurrent); + return index >= 0 ? index : Math.max(0, (snapshotScenes || []).length - 1); + } + + function logSnapshotSynchronisation(eventName, snapshot, displayedScene) { + if (!realMode || !window.console?.info) return; + const authoritative = activeScene(snapshot); + console.info("Story Intelligence browser synchronisation", { + event: eventName, + changeToken: snapshot?.changeToken || "", + snapshotSceneId: authoritative?.id || "", + displayedSceneId: displayedScene?.id || "", + snapshotSceneNumber: authoritative?.sceneNumber || "", + displayedSceneNumber: displayedScene?.sceneNumber || "" + }); } function keyedById(items) { @@ -1414,7 +1433,7 @@ } if (scenes.length) { - transitionToNextState(realMode ? Math.max(0, scenes.length - 1) : 0); + transitionToNextState(realMode ? authoritativeSceneIndex(scenes) : 0); startTimer(); } else { updateDiagnostics(); diff --git a/PlotLine/wwwroot/js/story-intelligence-progress.js b/PlotLine/wwwroot/js/story-intelligence-progress.js index ffcde39..90ee363 100644 --- a/PlotLine/wwwroot/js/story-intelligence-progress.js +++ b/PlotLine/wwwroot/js/story-intelligence-progress.js @@ -243,6 +243,73 @@ } }; + const parseNumber = (value) => Number.parseFloat(String(value || "").replace(/,/g, "")) || 0; + + const parseTime = (value) => { + const parsed = Date.parse(value || ""); + return Number.isNaN(parsed) ? 0 : parsed; + }; + + const isActiveStatus = (status) => status === "Pending" || status === "Running"; + + const authoritativeChapter = (chapters) => { + const active = chapters + .filter((chapter) => isActiveStatus(chapter.dataset.storyRawStatus || "") + && parseNumber(chapter.dataset.storyCurrentSceneNumber) > 0) + .sort((left, right) => { + const byUpdated = parseTime(right.dataset.storyUpdatedUtc) - parseTime(left.dataset.storyUpdatedUtc); + if (byUpdated !== 0) return byUpdated; + return parseNumber(right.dataset.storyChapterNumber) - parseNumber(left.dataset.storyChapterNumber); + }); + if (active.length > 0) return active[0]; + + const completed = chapters + .filter((chapter) => { + const status = chapter.dataset.storyRawStatus || ""; + return status === "Completed" || status === "CompletedWithWarnings"; + }) + .sort((left, right) => { + const byUpdated = parseTime(right.dataset.storyUpdatedUtc) - parseTime(left.dataset.storyUpdatedUtc); + if (byUpdated !== 0) return byUpdated; + return parseNumber(right.dataset.storyChapterNumber) - parseNumber(left.dataset.storyChapterNumber); + }); + return completed[0] || chapters[0] || null; + }; + + const applyAuthoritativeCursor = (root) => { + const chapters = [...root.querySelectorAll("[data-story-run-id]")]; + const chapter = authoritativeChapter(chapters); + if (!chapter) return; + + const status = chapter.dataset.storyRawStatus || "Pending"; + const stage = chapter.dataset.storyCurrentStage || status; + const currentSceneNumber = chapter.dataset.storyCurrentSceneNumber || ""; + const total = chapter.querySelector("[data-story-run-total]")?.textContent || ""; + const completed = chapter.querySelector("[data-story-run-completed]")?.textContent || ""; + const message = chapter.querySelector("[data-story-run-message]")?.textContent || ""; + + root.querySelectorAll("[data-story-current-chapter]").forEach((node) => { + node.textContent = chapter.dataset.storyChapterTitle || "Current chapter"; + }); + root.querySelectorAll("[data-story-current-stage]").forEach((node) => { + node.textContent = friendlyStage(stage, status); + }); + root.querySelectorAll("[data-story-current-scene]").forEach((node) => { + node.textContent = currentSceneNumber ? numberText(currentSceneNumber) : "Waiting"; + }); + const totalNumber = Number.parseInt(total.replace(/,/g, "") || "0", 10) || 0; + const completedNumber = Number.parseInt(completed.replace(/,/g, "") || "0", 10) || 0; + const currentSceneDisplay = currentSceneNumber ? Number.parseInt(currentSceneNumber, 10) || completedNumber : completedNumber; + root.querySelectorAll("[data-story-chapter-progress-text]").forEach((node) => { + node.textContent = totalNumber > 0 + ? `Scene ${numberText(Math.min(Math.max(currentSceneDisplay, 0), totalNumber))} of ${numberText(totalNumber)}` + : "Finding scenes..."; + }); + root.querySelectorAll("[data-story-current-message]").forEach((node) => { + node.textContent = message; + }); + }; + const recalculateOnboardingTotals = (root) => { const hadActiveRuns = root.dataset.storyIntelligenceHasActiveRuns === "true"; const chapters = [...root.querySelectorAll("[data-story-run-id]")]; @@ -306,6 +373,7 @@ root.querySelectorAll("[data-story-remaining]").forEach((node) => { node.textContent = calculateRemaining(chapters, active); }); + applyAuthoritativeCursor(root); if (hadActiveRuns && !active && root.dataset.reloadScheduled !== "true") { root.dataset.reloadScheduled = "true"; @@ -335,6 +403,7 @@ const totalDurationMs = field(state, "totalDurationMs", "TotalDurationMs"); const elapsed = field(state, "elapsedTime", "ElapsedTime") || "0 sec"; const currentSceneNumber = field(state, "currentSceneNumber", "CurrentSceneNumber"); + const updatedUtc = field(state, "updatedUtc", "UpdatedUtc"); const displayMessage = friendlyMessage(message, stage, status, currentSceneNumber); const latestSummary = field(state, "latestSceneSummary", "LatestSceneSummary"); const discoveries = field(state, "recentDiscoveries", "RecentDiscoveries") || []; @@ -344,6 +413,11 @@ node.textContent = statusLabel(state); }); chapter.dataset.storyRawStatus = status; + chapter.dataset.storyCurrentStage = stage; + chapter.dataset.storyCurrentSceneNumber = completed || "0"; + if (updatedUtc) { + chapter.dataset.storyUpdatedUtc = updatedUtc; + } if (totalDurationMs !== null) { chapter.dataset.storyDurationMs = String(totalDurationMs); } @@ -392,30 +466,12 @@ } }); - root.querySelectorAll("[data-story-current-chapter]").forEach((node) => { - node.textContent = chapter.dataset.storyChapterTitle || "Current chapter"; - }); - root.querySelectorAll("[data-story-current-stage]").forEach((node) => { - node.textContent = friendlyStage(stage, status); - }); - root.querySelectorAll("[data-story-current-scene]").forEach((node) => { - node.textContent = currentSceneNumber ? numberText(currentSceneNumber) : "Waiting"; - }); const totalNumber = Number.parseInt(total || "0", 10) || 0; const completedNumber = Number.parseInt(completed || "0", 10) || 0; - const currentSceneDisplay = currentSceneNumber ? Number.parseInt(currentSceneNumber, 10) || completedNumber : completedNumber; - root.querySelectorAll("[data-story-chapter-progress-text]").forEach((node) => { - node.textContent = totalNumber > 0 - ? `Scene ${numberText(Math.min(Math.max(currentSceneDisplay, 0), totalNumber))} of ${numberText(totalNumber)}` - : "Finding scenes..."; - }); root.querySelectorAll("[data-story-chapter-progress-bar]").forEach((node) => { const percent = totalNumber > 0 ? Math.floor((Math.min(completedNumber, totalNumber) * 100) / totalNumber) : 0; node.style.width = `${Math.max(0, Math.min(100, percent))}%`; }); - root.querySelectorAll("[data-story-current-message]").forEach((node) => { - node.textContent = displayMessage; - }); root.querySelectorAll("[data-story-elapsed]").forEach((node) => { node.textContent = elapsed; });