Fix Word Companion writing screen handoff

This commit is contained in:
Nick Beckley 2026-08-24 20:53:43 +00:00
parent a90b98f8f0
commit c2a6dbd849
12 changed files with 286 additions and 44 deletions

View File

@ -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");

View File

@ -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
{

View File

@ -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,
});
}
}

View File

@ -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" : "")}.";
}
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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);
};

View File

@ -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();