diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 72f2ae7..9fc3a09 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -101,6 +101,9 @@ var tests = new (string Name, Action Test)[] ("Writing workspace launcher uses progressive screen placement", WritingWorkspaceLauncherUsesProgressiveScreenPlacement), ("Writing workspace pages register roles without changing follow behavior", WritingWorkspacePagesRegisterRolesWithoutChangingFollowBehavior), ("Word Companion exposes writing workspace launch actions", WordCompanionExposesWritingWorkspaceLaunchActions), + ("Word Companion writing screens receive resolved context identifiers", WordCompanionWritingScreensReceiveResolvedContextIdentifiers), + ("Writing screens show diagnostic empty states", WritingScreensShowDiagnosticEmptyStates), + ("Word Companion three-screen flow logs connection identifiers", WordCompanionThreeScreenFlowLogsConnectionIdentifiers), ("Word manuscript cleanup classifies PlotDirector markers by tag only", WordManuscriptCleanupClassifiesPlotDirectorMarkersByTagOnly), ("Word manuscript cleanup preserves content controls content", WordManuscriptCleanupPreservesContentControlContent), ("Word manuscript cleanup UI separates unlink from clean unlink", WordManuscriptCleanupUiSeparatesUnlinkFromCleanUnlink), @@ -428,6 +431,62 @@ static void WordCompanionExposesWritingWorkspaceLaunchActions() Assert(script.Contains("openWritingIntentButton?.addEventListener", StringComparison.Ordinal), "Word Companion Open Intent button should be wired."); } +static void WordCompanionWritingScreensReceiveResolvedContextIdentifiers() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js")); + var controller = File.ReadAllText(Path.Combine(root, "Controllers/WritingController.cs")); + var contextView = File.ReadAllText(Path.Combine(root, "Views/Writing/Context.cshtml")); + var intentView = File.ReadAllText(Path.Combine(root, "Views/Writing/Intent.cshtml")); + + Assert(script.Contains("const chapterId = Number.isInteger(resolvedChapterId)", StringComparison.Ordinal), "Word Companion should prefer resolved chapter IDs for writing screen URLs."); + Assert(script.Contains("const sceneId = Number.isInteger(resolvedSceneId)", StringComparison.Ordinal), "Word Companion should prefer resolved scene IDs for writing screen URLs."); + Assert(script.Contains("url.searchParams.set(\"chapterId\", chapterId)", StringComparison.Ordinal), "Writing screen URLs should include chapterId when available."); + Assert(script.Contains("url.searchParams.set(\"sceneId\", sceneId)", StringComparison.Ordinal), "Writing screen URLs should include sceneId when available."); + Assert(script.Contains("url.searchParams.set(\"documentGuid\", documentGuid)", StringComparison.Ordinal), "Writing screen URLs should include documentGuid for diagnostics."); + Assert(controller.Contains("Guid? documentGuid", StringComparison.Ordinal), "Writing routes should accept the Word document identifier."); + Assert(contextView.Contains("data-document-guid=\"@Model.DocumentGuid\"", StringComparison.Ordinal), "Writing Context should retain the document identifier."); + Assert(intentView.Contains("data-document-guid=\"@Model.DocumentGuid\"", StringComparison.Ordinal), "Writing Intent should retain the document identifier."); +} + +static void WritingScreensShowDiagnosticEmptyStates() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/CoreViewModels.cs")); + var controller = File.ReadAllText(Path.Combine(root, "Controllers/WritingController.cs")); + var contextPartial = File.ReadAllText(Path.Combine(root, "Views/Writing/_WritingContextContent.cshtml")); + var intentPartial = File.ReadAllText(Path.Combine(root, "Views/Writing/_WritingIntentContent.cshtml")); + var follow = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js")); + + Assert(viewModel.Contains("EmptyTitle", StringComparison.Ordinal), "Writing display models should carry an empty-state title."); + Assert(viewModel.Contains("EmptyMessage", StringComparison.Ordinal), "Writing display models should carry an empty-state message."); + Assert(controller.Contains("No current Word scene received", StringComparison.Ordinal), "Writing routes should not default to Waiting for Word forever."); + Assert(controller.Contains("No Story Intelligence context is available", StringComparison.Ordinal), "Writing Context should report missing context clearly."); + Assert(controller.Contains("No writing intent is available", StringComparison.Ordinal), "Writing Intent should report missing intent clearly."); + Assert(contextPartial.Contains("@Model.EmptyTitle", StringComparison.Ordinal), "Writing Context empty state should render the diagnostic title."); + Assert(intentPartial.Contains("@Model.EmptyMessage", StringComparison.Ordinal), "Writing Intent empty state should render the diagnostic message."); + Assert(follow.Contains("setEmptyState", StringComparison.Ordinal), "Writing follow should update empty states after connection/fetch failures."); + Assert(follow.Contains("No current Word scene has been received", StringComparison.Ordinal), "Writing follow should replace indefinite waiting with a diagnostic message."); +} + +static void WordCompanionThreeScreenFlowLogsConnectionIdentifiers() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var hostController = File.ReadAllText(Path.Combine(root, "Controllers/WordCompanionHostController.cs")); + var wordController = File.ReadAllText(Path.Combine(root, "Controllers/WordCompanionController.cs")); + var hub = File.ReadAllText(Path.Combine(root, "Hubs/WordCompanionFollowHub.cs")); + var hostScript = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js")); + var follow = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js")); + + Assert(hostController.Contains("Serving Word Companion host", StringComparison.Ordinal), "Word Companion host route should log which host page is served."); + Assert(wordController.Contains("Word Companion current scene received", StringComparison.Ordinal), "Current-scene API should log received identifiers."); + Assert(wordController.Contains("DocumentGuidPresent", StringComparison.Ordinal), "Current-scene API should log document identifier presence without exposing the value."); + Assert(hub.Contains("Word Companion registered", StringComparison.Ordinal), "Companion SignalR registration should be logged."); + Assert(hub.Contains("Writing window registered", StringComparison.Ordinal), "Context/Intent window registration should be logged."); + Assert(hostScript.Contains("[Word Companion] Opening writing screen.", StringComparison.Ordinal), "Host UI should log direct writing screen opens."); + Assert(follow.Contains("Loading writing position", StringComparison.Ordinal), "Context/Intent follow helper should log received positions."); +} + static void WordManuscriptCleanupClassifiesPlotDirectorMarkersByTagOnly() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); diff --git a/PlotLine/Controllers/WordCompanionController.cs b/PlotLine/Controllers/WordCompanionController.cs index 978b148..aa6ae8d 100644 --- a/PlotLine/Controllers/WordCompanionController.cs +++ b/PlotLine/Controllers/WordCompanionController.cs @@ -14,7 +14,8 @@ namespace PlotLine.Controllers; public sealed class WordCompanionController( IWordCompanionService wordCompanion, IWordCompanionPresenceService presence, - IHubContext followHub) : ControllerBase + IHubContext followHub, + ILogger logger) : ControllerBase { [HttpGet("projects")] public Task GetProjects() => wordCompanion.ListProjectsAsync(); @@ -125,13 +126,34 @@ public sealed class WordCompanionController( [HttpPost("runtime/current-scene")] public async Task UpdateRuntimeCurrentScene(WordCompanionRuntimeCurrentSceneRequest request) { + logger.LogInformation( + "Word Companion current scene received. ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneID={SceneID} DocumentGuidPresent={DocumentGuidPresent}", + request.ProjectId, + request.BookId, + request.ChapterId, + request.SceneId, + request.DocumentGuid != Guid.Empty); + var response = await wordCompanion.ValidateRuntimeCurrentSceneAsync(request); if (response is null) { + logger.LogWarning( + "Word Companion current scene rejected. ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneID={SceneID} DocumentGuidPresent={DocumentGuidPresent}", + request.ProjectId, + request.BookId, + request.ChapterId, + request.SceneId, + request.DocumentGuid != Guid.Empty); return BadRequest(); } await presence.UpdateCurrentStoryPositionAsync(RequireUserId(), response.Position); + logger.LogInformation( + "Word Companion current scene accepted. ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneID={SceneID}", + response.ProjectId, + response.BookId, + response.ChapterId, + response.SceneId); await BroadcastToCurrentUserAsync("wordCompanionSceneChanged", new { diff --git a/PlotLine/Controllers/WordCompanionHostController.cs b/PlotLine/Controllers/WordCompanionHostController.cs index 39ec67e..61b33cf 100644 --- a/PlotLine/Controllers/WordCompanionHostController.cs +++ b/PlotLine/Controllers/WordCompanionHostController.cs @@ -7,13 +7,21 @@ namespace PlotLine.Controllers; [AllowAnonymous] [Route("word-companion")] -public sealed class WordCompanionHostController(ICurrentUserService currentUser, IAuthorizationService authorization) : Controller +public sealed class WordCompanionHostController( + ICurrentUserService currentUser, + IAuthorizationService authorization, + ILogger logger) : Controller { [HttpGet("")] public async Task Index() { var isAdmin = currentUser.IsAuthenticated && (await authorization.AuthorizeAsync(User, "AdminOnly")).Succeeded; + logger.LogInformation( + "Serving Word Companion host. IsAuthenticated={IsAuthenticated} UserID={UserID} IsAdmin={IsAdmin}", + currentUser.IsAuthenticated, + currentUser.UserId, + isAdmin); return View(new WordCompanionHostViewModel { @@ -25,4 +33,3 @@ public sealed class WordCompanionHostController(ICurrentUserService currentUser, }); } } - diff --git a/PlotLine/Controllers/WritingController.cs b/PlotLine/Controllers/WritingController.cs index 8ff8e4e..b053f8f 100644 --- a/PlotLine/Controllers/WritingController.cs +++ b/PlotLine/Controllers/WritingController.cs @@ -13,37 +13,60 @@ public sealed class WritingController( IStoryContextService storyContext, IRelationshipMapService relationshipMap, IFloorPlanService floorPlans, - IWriterWorkspaceService writerWorkspace) : Controller + IWriterWorkspaceService writerWorkspace, + ILogger logger) : Controller { [HttpGet("Context")] - public async Task Context(int? projectId, int? bookId, int? chapterId, int? sceneId) + public async Task Context(int? projectId, int? bookId, int? chapterId, int? sceneId, Guid? documentGuid) { + logger.LogInformation( + "Opening Writing Context. ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneID={SceneID} DocumentGuidPresent={DocumentGuidPresent}", + projectId, + bookId, + chapterId, + sceneId, + documentGuid.HasValue); + + var display = sceneId.HasValue + ? await BuildDisplayAsync(projectId, bookId, chapterId, sceneId, "Manual context") + : WaitingContextDisplay(projectId, bookId, documentGuid); + var model = new WritingContextPageViewModel { ProjectID = projectId, BookID = bookId, ChapterID = chapterId, SceneID = sceneId, - Display = sceneId.HasValue - ? await BuildDisplayAsync(projectId, bookId, chapterId, sceneId, "Manual context") - : new WritingContextDisplayViewModel { FollowStatus = "Connected, waiting for scene" } + DocumentGuid = documentGuid, + Display = display ?? MissingContextDisplay(projectId, bookId, sceneId) }; return View(model); } [HttpGet("Intent")] - public async Task Intent(int? projectId, int? bookId, int? chapterId, int? sceneId) + public async Task Intent(int? projectId, int? bookId, int? chapterId, int? sceneId, Guid? documentGuid) { + logger.LogInformation( + "Opening Writing Intent. ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneID={SceneID} DocumentGuidPresent={DocumentGuidPresent}", + projectId, + bookId, + chapterId, + sceneId, + documentGuid.HasValue); + + var display = sceneId.HasValue || chapterId.HasValue + ? await BuildIntentDisplayAsync(projectId, bookId, chapterId, sceneId, "Manual intent") + : WaitingIntentDisplay(projectId, bookId, documentGuid); + var model = new WritingIntentPageViewModel { ProjectID = projectId, BookID = bookId, ChapterID = chapterId, SceneID = sceneId, - Display = sceneId.HasValue || chapterId.HasValue - ? await BuildIntentDisplayAsync(projectId, bookId, chapterId, sceneId, "Manual intent") - : new WritingIntentDisplayViewModel { FollowStatus = "Connected, waiting for scene" } + DocumentGuid = documentGuid, + Display = display ?? MissingIntentDisplay(projectId, bookId, chapterId, sceneId) }; return View(model); @@ -54,16 +77,18 @@ public sealed class WritingController( { if (!sceneId.HasValue) { - return PartialView("~/Views/Writing/_WritingContextContent.cshtml", new WritingContextDisplayViewModel - { - FollowStatus = "Connected, waiting for scene" - }); + logger.LogWarning("Writing Context partial requested without SceneID. ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID}", projectId, bookId, chapterId); + return PartialView("~/Views/Writing/_WritingContextContent.cshtml", WaitingContextDisplay(projectId, bookId, null)); } var model = await BuildDisplayAsync(projectId, bookId, chapterId, sceneId, "Following Word"); - return model?.HasScene == true - ? PartialView("~/Views/Writing/_WritingContextContent.cshtml", model) - : NotFound(); + if (model?.HasScene == true) + { + return PartialView("~/Views/Writing/_WritingContextContent.cshtml", model); + } + + logger.LogWarning("Writing Context could not resolve scene. ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneID={SceneID}", projectId, bookId, chapterId, sceneId); + return PartialView("~/Views/Writing/_WritingContextContent.cshtml", MissingContextDisplay(projectId, bookId, sceneId)); } [HttpGet("Intent/Scene")] @@ -71,16 +96,18 @@ public sealed class WritingController( { if (!sceneId.HasValue && !chapterId.HasValue) { - return PartialView("~/Views/Writing/_WritingIntentContent.cshtml", new WritingIntentDisplayViewModel - { - FollowStatus = "Connected, waiting for scene" - }); + logger.LogWarning("Writing Intent partial requested without ChapterID or SceneID. ProjectID={ProjectID} BookID={BookID}", projectId, bookId); + return PartialView("~/Views/Writing/_WritingIntentContent.cshtml", WaitingIntentDisplay(projectId, bookId, null)); } var model = await BuildIntentDisplayAsync(projectId, bookId, chapterId, sceneId, "Following Word"); - return model is not null && (model.HasScene || model.HasChapter) - ? PartialView("~/Views/Writing/_WritingIntentContent.cshtml", model) - : NotFound(); + if (model is not null && (model.HasScene || model.HasChapter)) + { + return PartialView("~/Views/Writing/_WritingIntentContent.cshtml", model); + } + + logger.LogWarning("Writing Intent could not resolve chapter/scene. ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneID={SceneID}", projectId, bookId, chapterId, sceneId); + return PartialView("~/Views/Writing/_WritingIntentContent.cshtml", MissingIntentDisplay(projectId, bookId, chapterId, sceneId)); } private async Task BuildDisplayAsync(int? projectId, int? bookId, int? chapterId, int? sceneId, string followStatus) @@ -188,4 +215,42 @@ public sealed class WritingController( .ToList() ?? []; } + + private static WritingContextDisplayViewModel WaitingContextDisplay(int? projectId, int? bookId, Guid? documentGuid) => new() + { + FollowStatus = "No current Word scene received", + EmptyTitle = "No current Word scene received", + EmptyMessage = MissingScopeMessage(projectId, bookId, documentGuid, "Context") + }; + + private static WritingIntentDisplayViewModel WaitingIntentDisplay(int? projectId, int? bookId, Guid? documentGuid) => new() + { + FollowStatus = "No current Word scene received", + EmptyTitle = "No current Word scene received", + EmptyMessage = MissingScopeMessage(projectId, bookId, documentGuid, "Intent") + }; + + private static WritingContextDisplayViewModel MissingContextDisplay(int? projectId, int? bookId, int? sceneId) => new() + { + FollowStatus = "Context unavailable", + EmptyTitle = "No Story Intelligence context is available", + EmptyMessage = $"PlotDirector could not load context for scene {sceneId?.ToString() ?? "unknown"} in project {projectId?.ToString() ?? "unknown"}, book {bookId?.ToString() ?? "unknown"}." + }; + + private static WritingIntentDisplayViewModel MissingIntentDisplay(int? projectId, int? bookId, int? chapterId, int? sceneId) => new() + { + FollowStatus = "Intent unavailable", + EmptyTitle = "No writing intent is available", + EmptyMessage = $"PlotDirector could not load writing intent for chapter {chapterId?.ToString() ?? "unknown"}, scene {sceneId?.ToString() ?? "none"} in project {projectId?.ToString() ?? "unknown"}, book {bookId?.ToString() ?? "unknown"}." + }; + + private static string MissingScopeMessage(int? projectId, int? bookId, Guid? documentGuid, string screen) + { + if (!projectId.HasValue || !bookId.HasValue) + { + return $"{screen} did not receive a project/book from Word Companion. Reopen it from a linked manuscript, or choose a scene manually."; + } + + return $"Waiting for Word Companion to report the current scene for project {projectId}, book {bookId}{(documentGuid.HasValue ? " and this Word document" : "")}."; + } } diff --git a/PlotLine/Hubs/WordCompanionFollowHub.cs b/PlotLine/Hubs/WordCompanionFollowHub.cs index 5093655..817f9b7 100644 --- a/PlotLine/Hubs/WordCompanionFollowHub.cs +++ b/PlotLine/Hubs/WordCompanionFollowHub.cs @@ -13,7 +13,8 @@ public sealed class WordCompanionFollowHub( IWritingWorkspacePresenceService writingPresence, IOnboardingRepository onboarding, IManuscriptScanPreviewStore scanStore, - IOnboardingService onboardingService) : Hub + IOnboardingService onboardingService, + ILogger logger) : Hub { public override async Task OnConnectedAsync() { @@ -41,6 +42,12 @@ public sealed class WordCompanionFollowHub( } var status = await presence.RegisterAsync(userId, Context.ConnectionId, registration); + logger.LogInformation( + "Word Companion registered. UserID={UserID} ProjectID={ProjectID} BookID={BookID} DocumentOpen={DocumentOpen}", + userId, + registration.LinkedProjectID, + registration.LinkedBookID, + registration.DocumentOpen); await Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status); await BroadcastWritingWorkspaceStatusAsync(userId, registration.LinkedProjectID, registration.LinkedBookID); return status; @@ -50,6 +57,12 @@ public sealed class WordCompanionFollowHub( { var userId = RequireUserId(); var status = await presence.HeartbeatAsync(userId, Context.ConnectionId, heartbeat); + logger.LogDebug( + "Word Companion heartbeat. UserID={UserID} ProjectID={ProjectID} BookID={BookID} DocumentOpen={DocumentOpen}", + userId, + heartbeat.LinkedProjectID, + heartbeat.LinkedBookID, + heartbeat.DocumentOpen); await Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status); await BroadcastWritingWorkspaceStatusAsync(userId, heartbeat.LinkedProjectID, heartbeat.LinkedBookID); return status; @@ -59,6 +72,11 @@ public sealed class WordCompanionFollowHub( { var userId = RequireUserId(); await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(userId)); + logger.LogInformation( + "Writing workspace watched. UserID={UserID} ProjectID={ProjectID} BookID={BookID}", + userId, + scope.ProjectID, + scope.BookID); return await WritingWorkspaceStatusAsync(userId, scope); } @@ -67,6 +85,13 @@ public sealed class WordCompanionFollowHub( var userId = RequireUserId(); await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(userId)); var status = await writingPresence.RegisterAsync(userId, Context.ConnectionId, registration, await presence.GetStatusAsync(userId)); + logger.LogInformation( + "Writing window registered. UserID={UserID} Role={Role} ProjectID={ProjectID} BookID={BookID} Route={Route}", + userId, + registration.WritingRole, + registration.ProjectID, + registration.BookID, + registration.CurrentRoute); await Clients.Group(PresenceGroup(userId)).SendAsync("WritingWorkspacePresenceChanged", status); return status; } diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index ff064e7..49cc600 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -435,6 +435,7 @@ public sealed class WritingContextPageViewModel public int? BookID { get; set; } public int? ChapterID { get; set; } public int? SceneID { get; set; } + public Guid? DocumentGuid { get; set; } public WritingContextDisplayViewModel? Display { get; set; } } @@ -444,6 +445,8 @@ public sealed class WritingContextDisplayViewModel public RelationshipMapViewModel? RelationshipMap { get; set; } public FloorPlanEditorViewModel? FloorPlan { get; set; } public string FollowStatus { get; set; } = "Connected, waiting for scene"; + public string EmptyTitle { get; set; } = "No current Word scene"; + public string EmptyMessage { get; set; } = "Open a linked manuscript or choose a scene manually once context is available."; public IReadOnlyList SceneOptions { get; set; } = []; public bool HasScene => Context?.Scene is not null; } @@ -454,6 +457,7 @@ public sealed class WritingIntentPageViewModel public int? BookID { get; set; } public int? ChapterID { get; set; } public int? SceneID { get; set; } + public Guid? DocumentGuid { get; set; } public WritingIntentDisplayViewModel? Display { get; set; } } @@ -462,6 +466,8 @@ public sealed class WritingIntentDisplayViewModel public StoryContextViewModel? Context { get; set; } public ChapterWorkflowViewModel? ChapterWorkflow { get; set; } public string FollowStatus { get; set; } = "Connected, waiting for scene"; + public string EmptyTitle { get; set; } = "No current Word scene"; + public string EmptyMessage { get; set; } = "Open a linked manuscript or choose a planned chapter or scene once intent is available."; public IReadOnlyList SceneOptions { get; set; } = []; public bool HasScene => Context?.Scene is not null; public bool HasChapter => ChapterWorkflow?.Chapter is not null || Context?.Chapter is not null; diff --git a/PlotLine/Views/Writing/Context.cshtml b/PlotLine/Views/Writing/Context.cshtml index db05315..f04f8f4 100644 --- a/PlotLine/Views/Writing/Context.cshtml +++ b/PlotLine/Views/Writing/Context.cshtml @@ -9,7 +9,8 @@ data-writing-context data-context-url="@Url.Action("ContextScene", "Writing")" data-scope-project-id="@Model.ProjectID" - data-scope-book-id="@Model.BookID"> + data-scope-book-id="@Model.BookID" + data-document-guid="@Model.DocumentGuid">

Writing Context

diff --git a/PlotLine/Views/Writing/Intent.cshtml b/PlotLine/Views/Writing/Intent.cshtml index 1a85bd0..91c5bc4 100644 --- a/PlotLine/Views/Writing/Intent.cshtml +++ b/PlotLine/Views/Writing/Intent.cshtml @@ -9,7 +9,8 @@ data-writing-intent data-context-url="@Url.Action("IntentScene", "Writing")" data-scope-project-id="@Model.ProjectID" - data-scope-book-id="@Model.BookID"> + data-scope-book-id="@Model.BookID" + data-document-guid="@Model.DocumentGuid">

Writing Intent

diff --git a/PlotLine/Views/Writing/_WritingContextContent.cshtml b/PlotLine/Views/Writing/_WritingContextContent.cshtml index 4537371..5cf8f16 100644 --- a/PlotLine/Views/Writing/_WritingContextContent.cshtml +++ b/PlotLine/Views/Writing/_WritingContextContent.cshtml @@ -14,9 +14,9 @@ @if (scene is null) {
-

Waiting

-

Waiting for Word...

-

Open a linked manuscript or choose a scene manually once context is available.

+

Context

+

@Model.EmptyTitle

+

@Model.EmptyMessage

} else diff --git a/PlotLine/Views/Writing/_WritingIntentContent.cshtml b/PlotLine/Views/Writing/_WritingIntentContent.cshtml index ae66725..f8c9024 100644 --- a/PlotLine/Views/Writing/_WritingIntentContent.cshtml +++ b/PlotLine/Views/Writing/_WritingIntentContent.cshtml @@ -22,9 +22,9 @@ @if (!Model.HasChapter && scene is null) {
-

Waiting

-

Waiting for Word...

-

Open a linked manuscript or choose a planned chapter or scene once intent is available.

+

Intent

+

@Model.EmptyTitle

+

@Model.EmptyMessage

} else diff --git a/PlotLine/wwwroot/js/word-companion-host.js b/PlotLine/wwwroot/js/word-companion-host.js index e6b0619..1912b0e 100644 --- a/PlotLine/wwwroot/js/word-companion-host.js +++ b/PlotLine/wwwroot/js/word-companion-host.js @@ -1366,24 +1366,44 @@ const writingWorkspaceUrl = (target) => { const scope = writingWorkspaceScope(); const url = new URL(target === "workspace" ? "/Writer" : `/Writing/${target}`, window.location.origin); + const chapterId = Number.isInteger(resolvedChapterId) && resolvedChapterId > 0 + ? resolvedChapterId + : detectedChapterAnchorId; + const sceneId = Number.isInteger(resolvedSceneId) && resolvedSceneId > 0 + ? resolvedSceneId + : detectedSceneAnchorId; + const documentGuid = currentDocumentGuid(); if (scope.projectId) { url.searchParams.set("projectId", scope.projectId); } if (scope.bookId && target !== "workspace") { url.searchParams.set("bookId", scope.bookId); } - if (detectedChapterAnchorId && target !== "workspace") { - url.searchParams.set("chapterId", detectedChapterAnchorId); + if (chapterId && target !== "workspace") { + url.searchParams.set("chapterId", chapterId); } - if (detectedSceneAnchorId && target !== "workspace") { - url.searchParams.set("sceneId", detectedSceneAnchorId); + if (sceneId && target !== "workspace") { + url.searchParams.set("sceneId", sceneId); + } + if (documentGuid && target !== "workspace") { + url.searchParams.set("documentGuid", documentGuid); } return `${url.pathname}${url.search}`; }; const openWritingWindow = (target) => { const role = target === "context" ? "Context" : target === "intent" ? "Intent" : "Workspace"; - const opened = window.open(writingWorkspaceUrl(target), writingWindowName(role)); + const url = writingWorkspaceUrl(target); + console.info("[Word Companion] Opening writing screen.", { + target, + url, + projectId: selectedPresenceProjectId(), + bookId: selectedPresenceBookId(), + chapterId: resolvedChapterId || detectedChapterAnchorId || null, + sceneId: resolvedSceneId || detectedSceneAnchorId || null, + documentGuidPresent: !!currentDocumentGuid() + }); + const opened = window.open(url, writingWindowName(role)); if (opened) { opened.focus(); } else { @@ -1420,6 +1440,7 @@ return; } + console.debug("[Word Companion] Sending presence heartbeat.", companionPresencePayload()); await companionPresenceConnection.invoke("CompanionHeartbeat", companionPresencePayload()); }; @@ -1454,6 +1475,7 @@ }); await companionPresenceConnection.start(); + console.info("[Word Companion] Registering companion presence.", companionPresencePayload()); await companionPresenceConnection.invoke("RegisterCompanion", companionPresencePayload()); companionPresenceHeartbeatTimer = window.setInterval(refreshCompanionPresence, 25000); }; diff --git a/PlotLine/wwwroot/js/writing-follow.js b/PlotLine/wwwroot/js/writing-follow.js index 8bcb006..03155e8 100644 --- a/PlotLine/wwwroot/js/writing-follow.js +++ b/PlotLine/wwwroot/js/writing-follow.js @@ -48,6 +48,26 @@ } }; + const setEmptyState = (title, message) => { + const empty = contentHost?.querySelector(".writing-context-empty"); + const heading = empty?.querySelector("h2"); + const copy = empty?.querySelector("p:last-child"); + if (heading && title) { + heading.textContent = title; + } + if (copy && message) { + copy.textContent = message; + } + }; + + const noCurrentSceneMessage = () => { + const bits = []; + if (scopedProjectId) bits.push(`project ${scopedProjectId}`); + if (scopedBookId) bits.push(`book ${scopedBookId}`); + const scope = bits.length ? ` for ${bits.join(", ")}` : ""; + return `No current Word scene has been received${scope}. Reopen this screen from Word Companion after the manuscript scene is detected, or choose a scene manually.`; + }; + const registerWorkspaceRole = () => { const role = config.source === "WritingContext" ? window.PlotDirectorWritingWorkspace?.roles?.context @@ -201,6 +221,7 @@ if (hasScene) url.searchParams.set("sceneId", position.sceneId); try { + console.info(`[${config.source}] Loading writing position.`, position); const response = await fetch(url, { headers: { "X-Requested-With": "XMLHttpRequest" } }); if (!response.ok) { throw new Error(`${config.source} request failed: ${response.status}`); @@ -216,8 +237,12 @@ wireFragment(); activateTab(tab); } catch (error) { - console.debug(`${config.source} could not update.`, error); - setStatus(config.waitingStatus); + console.warn(`${config.source} could not update.`, error); + setStatus("Context unavailable"); + setEmptyState( + config.source === "WritingIntent" ? "No writing intent is available" : "No Story Intelligence context is available", + `PlotDirector could not load ${config.source === "WritingIntent" ? "intent" : "context"} for the selected Word position. Check that the scene belongs to this project/book and that import data exists.` + ); } finally { loadingSceneId = null; loadingChapterId = null; @@ -250,15 +275,24 @@ } if (!currentSceneId && !(config.allowChapterOnly && currentChapterId)) { - setStatus(config.waitingStatus); + setStatus("No current Word scene received"); + setEmptyState("No current Word scene received", noCurrentSceneMessage()); } }) .catch((error) => { - console.debug(`${config.source} follow connection unavailable.`, error); + console.warn(`${config.source} follow connection unavailable.`, error); setStatus(config.disconnectedStatus); + setEmptyState( + "Word Companion connection unavailable", + `This ${config.source === "WritingIntent" ? "Intent" : "Context"} screen could not connect to the Word Companion session. Reopen it from Word Companion, or use the manual scene controls.` + ); }); } else { setStatus(config.disconnectedStatus); + setEmptyState( + "Word Companion connection unavailable", + "SignalR did not load for this screen, so live Word updates are unavailable. Use a URL with project/book/scene IDs or the manual scene controls." + ); } wireFragment();