Fix Word Companion writing screen handoff
This commit is contained in:
parent
a90b98f8f0
commit
c2a6dbd849
@ -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");
|
||||
|
||||
@ -14,7 +14,8 @@ namespace PlotLine.Controllers;
|
||||
public sealed class WordCompanionController(
|
||||
IWordCompanionService wordCompanion,
|
||||
IWordCompanionPresenceService presence,
|
||||
IHubContext<WordCompanionFollowHub> followHub) : ControllerBase
|
||||
IHubContext<WordCompanionFollowHub> followHub,
|
||||
ILogger<WordCompanionController> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet("projects")]
|
||||
public Task<WordCompanionProjectsResponse> GetProjects() => wordCompanion.ListProjectsAsync();
|
||||
@ -125,13 +126,34 @@ public sealed class WordCompanionController(
|
||||
[HttpPost("runtime/current-scene")]
|
||||
public async Task<IActionResult> 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
|
||||
{
|
||||
|
||||
@ -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<WordCompanionHostController> logger) : Controller
|
||||
{
|
||||
[HttpGet("")]
|
||||
public async Task<IActionResult> 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -13,37 +13,60 @@ public sealed class WritingController(
|
||||
IStoryContextService storyContext,
|
||||
IRelationshipMapService relationshipMap,
|
||||
IFloorPlanService floorPlans,
|
||||
IWriterWorkspaceService writerWorkspace) : Controller
|
||||
IWriterWorkspaceService writerWorkspace,
|
||||
ILogger<WritingController> logger) : Controller
|
||||
{
|
||||
[HttpGet("Context")]
|
||||
public async Task<IActionResult> Context(int? projectId, int? bookId, int? chapterId, int? sceneId)
|
||||
public async Task<IActionResult> 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<IActionResult> Intent(int? projectId, int? bookId, int? chapterId, int? sceneId)
|
||||
public async Task<IActionResult> 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<WritingContextDisplayViewModel?> 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" : "")}.";
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,8 @@ public sealed class WordCompanionFollowHub(
|
||||
IWritingWorkspacePresenceService writingPresence,
|
||||
IOnboardingRepository onboarding,
|
||||
IManuscriptScanPreviewStore scanStore,
|
||||
IOnboardingService onboardingService) : Hub
|
||||
IOnboardingService onboardingService,
|
||||
ILogger<WordCompanionFollowHub> 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;
|
||||
}
|
||||
|
||||
@ -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<SelectListItem> 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<SelectListItem> SceneOptions { get; set; } = [];
|
||||
public bool HasScene => Context?.Scene is not null;
|
||||
public bool HasChapter => ChapterWorkflow?.Chapter is not null || Context?.Chapter is not null;
|
||||
|
||||
@ -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">
|
||||
<header class="writing-context-shell-header">
|
||||
<div>
|
||||
<p class="eyebrow">Writing Context</p>
|
||||
|
||||
@ -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">
|
||||
<header class="writing-context-shell-header">
|
||||
<div>
|
||||
<p class="eyebrow">Writing Intent</p>
|
||||
|
||||
@ -14,9 +14,9 @@
|
||||
@if (scene is null)
|
||||
{
|
||||
<section class="writing-context-empty">
|
||||
<p class="eyebrow">Waiting</p>
|
||||
<h2>Waiting for Word...</h2>
|
||||
<p>Open a linked manuscript or choose a scene manually once context is available.</p>
|
||||
<p class="eyebrow">Context</p>
|
||||
<h2>@Model.EmptyTitle</h2>
|
||||
<p>@Model.EmptyMessage</p>
|
||||
</section>
|
||||
}
|
||||
else
|
||||
|
||||
@ -22,9 +22,9 @@
|
||||
@if (!Model.HasChapter && scene is null)
|
||||
{
|
||||
<section class="writing-context-empty">
|
||||
<p class="eyebrow">Waiting</p>
|
||||
<h2>Waiting for Word...</h2>
|
||||
<p>Open a linked manuscript or choose a planned chapter or scene once intent is available.</p>
|
||||
<p class="eyebrow">Intent</p>
|
||||
<h2>@Model.EmptyTitle</h2>
|
||||
<p>@Model.EmptyMessage</p>
|
||||
</section>
|
||||
}
|
||||
else
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user