Fix onboarding import wizard sequencing

This commit is contained in:
Nick Beckley 2026-08-25 18:55:51 +00:00
parent c8148245c9
commit 24f2120cb2
11 changed files with 85 additions and 41 deletions

View File

@ -1589,7 +1589,12 @@ static void CoreImportWizardOrdersScenesBeforeCharacters()
Assert(review.Contains("value=\"structure\"", StringComparison.Ordinal), "Chapter review should submit to structural import, not character review.");
Assert(controller.Contains("var readyToImport = string.Equals(intent, \"structure\"", StringComparison.Ordinal), "Controller should treat structure intent as the scene-creation gate.");
Assert(controller.Contains("Import the approved chapters and scenes before reviewing characters.", StringComparison.Ordinal), "Character review should be guarded until structural import exists.");
Assert(controller.Contains("continueToCharacters || continueCharacterReview || backCharacterReview || completeCoreImport", StringComparison.Ordinal), "Structure-save errors should remain on chapter review rather than jumping to characters.");
Assert(controller.Contains("FinaliseCharacterReviewAsync", StringComparison.Ordinal), "Character review should have a distinct post-structure finalisation path.");
Assert(onboarding.Contains("ValidateScanStructure(review, preview);\n review = new ManuscriptScanReviewDecision", StringComparison.Ordinal), "Saving chapter review should validate only structure before scene import.");
Assert(onboarding.Contains("ValidateScanStructure(review, preview);\n\n var chapterDecisions", StringComparison.Ordinal), "Building approved structure should not validate unresolved character decisions.");
Assert(onboarding.Contains("ValidateScanReview(new ManuscriptScanReviewDecision", StringComparison.Ordinal), "Character validation should run when finalising character review.");
Assert(onboarding.Contains("Import the approved chapters and scenes before reviewing characters.", StringComparison.Ordinal), "Character finalisation should refuse to run before structural scenes exist.");
Assert(complete.Contains("Review characters", StringComparison.Ordinal), "After Word sync, the next onboarding step should be character review.");
Assert(complete.Contains("Word may now be closed; keep this browser workflow open", StringComparison.Ordinal), "Word safe-close must be distinct from browser/core-import completion.");
@ -1889,10 +1894,11 @@ static void OnboardingWizardKeepsCharacterPagesInsideOneMainStep()
var storyService = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/OnboardingStoryIntelligenceService.cs"));
Assert(!header.Contains("BuildDynamicSteps", StringComparison.Ordinal), "Onboarding header should not create one top-level step per character page.");
Assert(header.Contains("new(\"Review Characters\", 8)", StringComparison.Ordinal), "Onboarding header should keep character review as one main step.");
Assert(review.Contains("var currentStep = isCharacterStep ? 8 : 7;", StringComparison.Ordinal), "Scan review should keep every character page on the same main wizard step.");
Assert(header.Contains("new(\"Import Scenes\", 8)", StringComparison.Ordinal), "Onboarding header should include scene import as a distinct main step.");
Assert(header.Contains("new(\"Review Characters\", 9)", StringComparison.Ordinal), "Onboarding header should keep character review as one main step.");
Assert(review.Contains("var currentStep = isCharacterStep ? 9 : 7;", StringComparison.Ordinal), "Scan review should keep every character page on the same main wizard step.");
Assert(review.Contains("onboarding-subprogress", StringComparison.Ordinal), "Character pagination should be shown as section-level progress.");
Assert(storyService.Contains("const int totalWizardSteps = 11;", StringComparison.Ordinal), "Story Intelligence summary/context should use the fixed wizard step count.");
Assert(storyService.Contains("const int totalWizardSteps = 12;", StringComparison.Ordinal), "Story Intelligence summary/context should use the fixed wizard step count.");
}
static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel()

View File

@ -60,6 +60,14 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return RedirectToAction(nameof(Index));
}
var build = await onboarding.GetBuildResultAsync(model.PreviewID);
if (build is null || build.SceneMappings.Count == 0)
{
TempData["ArchiveError"] = "Import the approved chapters and scenes before reviewing characters.";
await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview);
return RedirectToAction(nameof(Index));
}
ViewData["ScanReviewStep"] = "characters";
ViewData["CharacterReviewPage"] = Math.Max(1, page);
return View("ScanReview", model);
@ -622,6 +630,17 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return NotFound();
}
if (continueToCharacters)
{
var build = await onboarding.GetBuildResultAsync(form.PreviewID);
if (build is null || build.SceneMappings.Count == 0)
{
TempData["ArchiveError"] = "Import the approved chapters and scenes before reviewing characters.";
await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview);
return RedirectToAction(nameof(Index));
}
}
TempData["OnboardingReviewMessage"] = readyToImport
? "Chapter review saved. Next: import scenes and update Word."
: continueToCharacters ? "Chapter and scene review saved. Next: review detected characters."
@ -642,7 +661,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return NotFound();
}
if (readyToImport || continueCharacterReview || backCharacterReview)
if (continueToCharacters || continueCharacterReview || backCharacterReview || completeCoreImport)
{
ViewData["ScanReviewStep"] = "characters";
ViewData["CharacterReviewPage"] = readyToImport

View File

@ -253,7 +253,7 @@ public sealed class WordCompanionFollowHub(
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress
{
Status = "Complete",
Message = "Manuscript structure has been created and saved. Word and this browser page may now be closed.",
Message = "Manuscript structure has been created and saved. Word may now be closed; keep this browser workflow open to review characters.",
PercentComplete = 100
});
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildMarkerSyncCompleted", new { buildId });

View File

@ -202,7 +202,7 @@ public sealed class OnboardingService(
if (readyToImport)
{
await scanPreviews.SaveReviewAsync(userId, review);
ValidateScanReview(review, preview);
ValidateScanStructure(review, preview);
review = new ManuscriptScanReviewDecision
{
PreviewID = review.PreviewID,
@ -236,7 +236,7 @@ public sealed class OnboardingService(
}
await PublishBuildProgress(userId, preview.PreviewID, "Creating chapters...", 10, progress);
ValidateScanReview(review, preview);
ValidateScanStructure(review, preview);
var chapterDecisions = review.Chapters.Where(chapter => chapter.Include).OrderBy(chapter => chapter.ChapterNumber).ToList();
@ -382,6 +382,14 @@ public sealed class OnboardingService(
};
})
.ToList();
ValidateScanReview(new ManuscriptScanReviewDecision
{
PreviewID = review.PreviewID,
Status = review.Status,
Chapters = review.Chapters,
Scenes = review.Scenes,
Characters = characterDecisions
}, preview);
var characterMappings = new List<OnboardingBuildCharacterMap>();
foreach (var decision in characterDecisions.Where(IsIncludedCanonicalCharacter).OrderBy(item => item.Name).ThenBy(item => item.TemporaryCharacterKey))

View File

@ -71,9 +71,9 @@ public sealed class OnboardingStoryIntelligenceService(
var included = IncludedChapters(preview, review);
var existingBatch = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID);
var missingTextCount = included.Count(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText));
const int storySummaryStep = 9;
const int storyContextStep = 10;
const int totalWizardSteps = 11;
const int storySummaryStep = 10;
const int storyContextStep = 11;
const int totalWizardSteps = 12;
return new StoryIntelligenceOverviewViewModel
{
PreviewID = preview.PreviewID,

View File

@ -127,9 +127,9 @@ public sealed class StoryIntelligenceOverviewViewModel
public int ApprovedChapterCount { get; init; }
public int ApprovedWordCount { get; init; }
public int MissingChapterTextCount { get; init; }
public int WizardCurrentStep { get; init; } = 9;
public int WizardContextStep { get; init; } = 10;
public int WizardTotalSteps { get; init; } = 10;
public int WizardCurrentStep { get; init; } = 10;
public int WizardContextStep { get; init; } = 11;
public int WizardTotalSteps { get; init; } = 12;
public string? Message { get; init; }
public Guid? ExistingBatchID { get; init; }
public StoryIntelligenceJobProgress? ExistingJob { get; init; }

View File

@ -6,15 +6,7 @@
<section class="onboarding-shell" aria-labelledby="build-complete-title">
<div class="onboarding-panel onboarding-review-panel">
<ol class="onboarding-stepper onboarding-stepper--complete" aria-label="Story setup journey">
@foreach (var label in new[] { "Welcome", "Writing Preferences", "Project", "Book", "Connect Word", "Scan", "Review", "Create Structure", "Complete" })
{
<li class="is-complete">
<span></span>
<strong>@label</strong>
</li>
}
</ol>
<partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = coreComplete ? 12 : 9, TotalSteps = 12 })" />
<div class="onboarding-copy">
<p class="eyebrow">Core Import Complete</p>
<div class="onboarding-success-heading">

View File

@ -3,11 +3,21 @@
@{
ViewData["Title"] = "Set up PlotDirector";
var currentJourneyOrder = CurrentJourneyOrder(Model);
var structureReadyToImport = string.Equals(Model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal);
var reviewedScanAvailable = Model.ScanState.IsComplete || structureReadyToImport;
}
<section class="onboarding-shell" aria-labelledby="onboarding-title">
<div class="onboarding-panel">
<partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = currentJourneyOrder })" />
@if (TempData["OnboardingReviewMessage"] is string reviewMessage)
{
<div class="alert alert-success">@reviewMessage</div>
}
@if (TempData["ArchiveError"] is string archiveError)
{
<div class="alert alert-danger">@archiveError</div>
}
@if (Model.CurrentStep == OnboardingSteps.Welcome)
{
@ -229,11 +239,11 @@
else
{
<div class="onboarding-copy">
<p class="eyebrow">Step @currentJourneyOrder of 10</p>
<p class="eyebrow">Step @currentJourneyOrder of 12</p>
@if (Model.IsMicrosoftWordPath)
{
<h1 id="onboarding-title">@(currentJourneyOrder >= 6 ? "Scan Manuscript" : "Connect your Word Companion")</h1>
<p>Open your manuscript in Word and start the Companion. When it is connected, use the wizard button below to scan and continue.</p>
<h1 id="onboarding-title">@(structureReadyToImport ? "Import scenes and Word markers" : currentJourneyOrder >= 6 ? "Scan Manuscript" : "Connect your Word Companion")</h1>
<p>@(structureReadyToImport ? "Keep Word and this browser page open while PlotDirector detects scene boundaries, creates the manuscript structure, inserts bookmark IDs, and saves the document." : "Open your manuscript in Word and start the Companion. When it is connected, use the wizard button below to scan and continue.")</p>
}
else
{
@ -314,10 +324,17 @@
</section>
}
<div class="onboarding-actions">
<form asp-controller="Onboarding" asp-action="Back" method="post">
<button class="btn btn-outline-secondary" type="submit" name="currentStep" value="@OnboardingSteps.NextPathPreview">Back</button>
</form>
@if (Model.IsMicrosoftWordPath && string.Equals(Model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
@if (Model.IsMicrosoftWordPath && reviewedScanAvailable && Model.ScanState.PreviewID.HasValue)
{
<a class="btn btn-outline-secondary" asp-controller="Onboarding" asp-action="ScanReview" asp-route-previewId="@Model.ScanState.PreviewID">Back to chapter review</a>
}
else
{
<form asp-controller="Onboarding" asp-action="Back" method="post">
<button class="btn btn-outline-secondary" type="submit" name="currentStep" value="@OnboardingSteps.NextPathPreview">Back</button>
</form>
}
@if (Model.IsMicrosoftWordPath && structureReadyToImport)
{
<button class="btn btn-primary"
type="button"
@ -444,13 +461,13 @@
OnboardingSteps.WritingSoftware => 3,
OnboardingSteps.Project => 4,
OnboardingSteps.Book => 5,
OnboardingSteps.NextPathPreview when !model.IsMicrosoftWordPath => 10,
OnboardingSteps.NextPathPreview when string.Equals(model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal) => 9,
OnboardingSteps.NextPathPreview when !model.IsMicrosoftWordPath => 11,
OnboardingSteps.NextPathPreview when string.Equals(model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal) => 8,
OnboardingSteps.NextPathPreview when model.ScanState.IsComplete => 7,
OnboardingSteps.NextPathPreview when model.ScanState.IsRunning || model.ScanState.IsFailed || model.ScanState.PercentComplete.HasValue => 6,
OnboardingSteps.NextPathPreview when model.CompanionPresence.IsConnected => 6,
OnboardingSteps.NextPathPreview => 6,
OnboardingSteps.Complete => 10,
OnboardingSteps.Complete => 12,
_ => 1
};
}

View File

@ -31,8 +31,8 @@
var requestedCharacterPage = ViewData["CharacterReviewPage"] is int requestedPage ? requestedPage : 1;
var characterPage = Math.Clamp(requestedCharacterPage, 1, characterReviewPageCount);
var currentCharacters = primaryCharacters.Skip((characterPage - 1) * characterPageSize).Take(characterPageSize).ToList();
var currentStep = isCharacterStep ? 8 : 7;
const int totalSteps = 11;
var currentStep = isCharacterStep ? 9 : 7;
const int totalSteps = 12;
var characterPageTitle = $"Review Characters {characterPage} of {primaryCharacterPageCount}";
var duplicateCanonicalNames = primaryCharacters
.Where(candidate => candidate.Include && string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey))
@ -67,7 +67,7 @@
<div class="onboarding-copy">
<p class="eyebrow">Step @currentStep of @totalSteps</p>
<h1 id="scan-review-title">@(isCharacterStep ? "Review Characters" : "Review Chapters")</h1>
<p>@(isCharacterStep ? "Review this page of detected names and aliases, then continue through the character section." : "Confirm the chapter and scene structure before reviewing detected characters.")</p>
<p>@(isCharacterStep ? "Review this page of detected names and aliases, then continue through the character section." : "Confirm the chapter structure before PlotDirector detects scenes with AI and updates Word markers.")</p>
</div>
@if (TempData["OnboardingReviewMessage"] is string reviewMessage)
@ -192,7 +192,7 @@
</div>
}
<div class="onboarding-actions onboarding-actions--inline">
<button class="btn btn-primary" type="submit" name="intent" value="structure">Save chapters and import scenes</button>
<button class="btn btn-primary" type="submit" name="intent" value="structure">Save chapter review</button>
</div>
</div>
@ -364,7 +364,7 @@
else
{
<a class="btn btn-outline-secondary" asp-controller="Onboarding" asp-action="Index">Back to setup</a>
<button class="btn btn-primary" type="submit" name="intent" value="structure">Save chapters and import scenes</button>
<button class="btn btn-primary" type="submit" name="intent" value="structure">Save chapter review</button>
}
</div>
</form>

View File

@ -36,10 +36,11 @@
new("Book", 5),
new("Connect Word", 6),
new("Review Chapters", 7),
new("Review Characters", 8),
new("Story Summary", 9),
new("Story Context", 10),
new("Complete", 11)
new("Import Scenes", 8),
new("Review Characters", 9),
new("Story Summary", 10),
new("Story Context", 11),
new("Complete", 12)
];
private static string StepClass(int order, int currentStep)

View File

@ -220,6 +220,7 @@
if (!previewId) {
return;
}
pendingBuildPreviewId = previewId;
button.disabled = true;
document.querySelectorAll("[data-onboarding-build-panel]").forEach((node) => {
node.classList.add("is-running");