Add writing workspace launcher

This commit is contained in:
Nick Beckley 2026-08-17 19:09:42 +00:00
parent 451e384966
commit 43a57df070
19 changed files with 1192 additions and 4 deletions

View File

@ -81,6 +81,11 @@ var tests = new (string Name, Action Test)[]
("Writing Intent client uses shared writing follow helper", WritingIntentClientUsesSharedWritingFollowHelper),
("Writing mode pages are isolated from global Word navigation", WritingModePagesAreIsolatedFromGlobalWordNavigation),
("Writing pair shares canonical follow infrastructure", WritingPairSharesCanonicalFollowInfrastructure),
("Writing workspace presence scopes browser windows", WritingWorkspacePresenceScopesBrowserWindows),
("Writing workspace client uses session window identity and stable names", WritingWorkspaceClientUsesSessionIdentityAndStableNames),
("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 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),
@ -269,6 +274,134 @@ static void WritingPairSharesCanonicalFollowInfrastructure()
Assert(helper.Contains("document.addEventListener(window.PlotDirectorStoryPosition?.eventName", StringComparison.Ordinal), "Both pages should react to the same canonical browser event.");
}
static void WritingWorkspacePresenceScopesBrowserWindows()
{
var service = new WritingWorkspacePresenceService();
var word = new WordCompanionPresenceStatus
{
UserID = 9,
IsConnected = true,
DocumentOpen = true,
LinkedProjectID = 10,
LinkedBookID = 20
};
service.RegisterAsync(9, "launcher-1", new WritingWindowRegistration
{
BrowserWindowID = "launcher",
ProjectID = 10,
BookID = 20,
WritingRole = WritingWorkspaceRoles.Launcher,
CurrentRoute = "/Writer"
}, word).GetAwaiter().GetResult();
service.RegisterAsync(9, "context-1", new WritingWindowRegistration
{
BrowserWindowID = "context",
ProjectID = 10,
BookID = 20,
WritingRole = WritingWorkspaceRoles.Context,
CurrentRoute = "/Writing/Context"
}, word).GetAwaiter().GetResult();
service.RegisterAsync(9, "intent-1", new WritingWindowRegistration
{
BrowserWindowID = "intent",
ProjectID = 10,
BookID = 20,
WritingRole = WritingWorkspaceRoles.Intent,
CurrentRoute = "/Writing/Intent"
}, word).GetAwaiter().GetResult();
service.RegisterAsync(9, "other-context", new WritingWindowRegistration
{
BrowserWindowID = "other-context",
ProjectID = 10,
BookID = 21,
WritingRole = WritingWorkspaceRoles.Context,
CurrentRoute = "/Writing/Context"
}, word).GetAwaiter().GetResult();
var status = service.GetStatusAsync(9, new WritingWorkspaceScope { ProjectID = 10, BookID = 20 }, word).GetAwaiter().GetResult();
Assert(status.WordCompanionConnected && status.WordDocumentOpen, "Writing workspace should include Word Companion presence.");
Assert(status.ContextOpen && status.IntentOpen, "Matching Context and Intent windows should be open.");
Assert(status.Context?.BrowserWindowID == "context", "Workspace status should choose the matching book's Context window.");
Assert(status.Windows.All(window => window.BookID == 20), "Workspace status should not include another book's windows.");
service.DisconnectAsync("context-1", word).GetAwaiter().GetResult();
status = service.GetStatusAsync(9, new WritingWorkspaceScope { ProjectID = 10, BookID = 20 }, word).GetAwaiter().GetResult();
Assert(!status.ContextOpen && status.IntentOpen, "Disconnected Context should be removed without affecting Intent.");
}
static void WritingWorkspaceClientUsesSessionIdentityAndStableNames()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-workspace.js"));
var service = File.ReadAllText(Path.Combine(root, "Services/WritingWorkspacePresenceService.cs"));
Assert(script.Contains("sessionStorage.getItem(storageKeys.windowId)", StringComparison.Ordinal), "Browser window IDs must come from sessionStorage.");
Assert(script.Contains("sessionStorage.setItem(storageKeys.windowId", StringComparison.Ordinal), "Browser window IDs must be stored per tab/window.");
Assert(!script.Contains("localStorage.getItem(storageKeys.windowId)", StringComparison.Ordinal), "Browser window IDs must not be shared through localStorage.");
Assert(script.Contains("PlotDirector${role}_${suffix}", StringComparison.Ordinal), "Context and Intent windows should use stable named targets.");
Assert(script.Contains("Book_${bookId}", StringComparison.Ordinal), "Stable writing window names should be book scoped.");
Assert(script.Contains("windowRefs = new Map()", StringComparison.Ordinal), "Launcher-created window references should be retained for focus.");
Assert(script.Contains("RequestWritingWindowFocus", StringComparison.Ordinal), "Launcher should fall back to SignalR focus requests.");
Assert(script.Contains("connection.onreconnected", StringComparison.Ordinal), "Writing workspace windows should re-register after SignalR reconnect.");
Assert(service.Contains("ActiveTimeout = TimeSpan.FromSeconds(45)", StringComparison.Ordinal) && service.Contains("MarkStaleOfflineAsync", StringComparison.Ordinal), "Writing workspace presence should expire stale windows.");
}
static void WritingWorkspaceLauncherUsesProgressiveScreenPlacement()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-workspace.js"));
var launcher = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-workspace-launcher.js"));
var view = File.ReadAllText(Path.Combine(root, "Views/Writer/Index.cshtml"));
Assert(script.Contains("getScreenDetails", StringComparison.Ordinal), "Workspace launcher should use the Window Management API when available.");
Assert(script.Contains("window-management", StringComparison.Ordinal), "Workspace launcher should check/request window-management permission.");
Assert(script.Contains("sort((a, b) => a.left - b.left", StringComparison.Ordinal), "Screen topology should be derived from physical coordinates.");
Assert(script.Contains("mode: \"three-screen\"", StringComparison.Ordinal), "Three-screen layout decision is missing.");
Assert(script.Contains("contextDisplay: preferences.contextDisplay === \"right\" ? ordered[ordered.length - 1] : ordered[0]", StringComparison.Ordinal), "Context should default to the left display.");
Assert(script.Contains("intentDisplay: preferences.intentDisplay === \"left\" ? ordered[0] : ordered[ordered.length - 1]", StringComparison.Ordinal), "Intent should default to the right display.");
Assert(script.Contains("mode: \"two-screen\"", StringComparison.Ordinal), "Two-screen fallback is missing.");
Assert(script.Contains("mode: \"single-screen\"", StringComparison.Ordinal), "One-screen fallback is missing.");
Assert(script.Contains("localStorage.setItem(storageKeys.preferences", StringComparison.Ordinal), "Remembered layout preferences are missing.");
Assert(script.Contains("window.open(url, name, popupFeatures(display))", StringComparison.Ordinal), "Writing windows should be opened from explicit launcher clicks with placement features.");
Assert(launcher.Contains("requestScreens: true", StringComparison.Ordinal), "Screen permission should be requested from the Start action.");
Assert(view.Contains("data-start-writing-workspace", StringComparison.Ordinal), "Writer dashboard launcher start button is missing.");
Assert(view.Contains("data-focus-writing-context", StringComparison.Ordinal), "Focus Context action is missing.");
Assert(view.Contains("data-focus-writing-intent", StringComparison.Ordinal), "Focus Intent action is missing.");
}
static void WritingWorkspacePagesRegisterRolesWithoutChangingFollowBehavior()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var contextView = File.ReadAllText(Path.Combine(root, "Views/Writing/Context.cshtml"));
var intentView = File.ReadAllText(Path.Combine(root, "Views/Writing/Intent.cshtml"));
var follow = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
Assert(contextView.Contains("writingRole: window.PlotDirectorWritingWorkspace.roles.context", StringComparison.Ordinal), "Writing Context should register its workspace role.");
Assert(intentView.Contains("writingRole: window.PlotDirectorWritingWorkspace.roles.intent", StringComparison.Ordinal), "Writing Intent should register its workspace role.");
Assert(contextView.Contains("data-writing-mode=\"context\"", StringComparison.Ordinal), "Writing Context should remain a writing-mode page.");
Assert(intentView.Contains("data-writing-mode=\"intent\"", StringComparison.Ordinal), "Writing Intent should remain a writing-mode page.");
Assert(follow.Contains("registerWorkspaceRole", StringComparison.Ordinal), "Writing follow should refresh presence scope after Word updates.");
Assert(follow.Contains("connection.on(\"storyPositionChanged\"", StringComparison.Ordinal), "Existing canonical Word follow should remain.");
Assert(!follow.Contains("window.location.assign", StringComparison.Ordinal) && !follow.Contains("window.location.href", StringComparison.Ordinal), "Writing pages must still update in place without navigation.");
}
static void WordCompanionExposesWritingWorkspaceLaunchActions()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var view = File.ReadAllText(Path.Combine(root, "Views/WordCompanionHost/Index.cshtml"));
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
Assert(view.Contains("data-open-writing-workspace", StringComparison.Ordinal), "Word Companion should expose Open Workspace.");
Assert(view.Contains("data-open-writing-context", StringComparison.Ordinal), "Word Companion should expose Open Context.");
Assert(view.Contains("data-open-writing-intent", StringComparison.Ordinal), "Word Companion should expose Open Intent.");
Assert(script.Contains("writingWindowName", StringComparison.Ordinal), "Word Companion should use stable writing window names.");
Assert(script.Contains("PlotDirector${role}_${suffix}", StringComparison.Ordinal), "Word Companion writing window names should be scoped.");
Assert(script.Contains("openWritingWorkspaceButton?.addEventListener", StringComparison.Ordinal), "Word Companion Open Workspace button should be wired.");
Assert(script.Contains("openWritingContextButton?.addEventListener", StringComparison.Ordinal), "Word Companion Open Context button should be wired.");
Assert(script.Contains("openWritingIntentButton?.addEventListener", StringComparison.Ordinal), "Word Companion Open Intent button should be wired.");
}
static void WordManuscriptCleanupClassifiesPlotDirectorMarkersByTagOnly()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");

View File

@ -10,6 +10,7 @@ namespace PlotLine.Hubs;
[Authorize]
public sealed class WordCompanionFollowHub(
IWordCompanionPresenceService presence,
IWritingWorkspacePresenceService writingPresence,
IOnboardingRepository onboarding,
IManuscriptScanPreviewStore scanStore,
IOnboardingService onboardingService) : Hub
@ -41,6 +42,7 @@ public sealed class WordCompanionFollowHub(
var status = await presence.RegisterAsync(userId, Context.ConnectionId, registration);
await Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status);
await BroadcastWritingWorkspaceStatusAsync(userId, registration.LinkedProjectID, registration.LinkedBookID);
return status;
}
@ -49,9 +51,51 @@ public sealed class WordCompanionFollowHub(
var userId = RequireUserId();
var status = await presence.HeartbeatAsync(userId, Context.ConnectionId, heartbeat);
await Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status);
await BroadcastWritingWorkspaceStatusAsync(userId, heartbeat.LinkedProjectID, heartbeat.LinkedBookID);
return status;
}
public async Task<WritingWorkspaceStatus> WatchWritingWorkspace(WritingWorkspaceScope scope)
{
var userId = RequireUserId();
await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(userId));
return await WritingWorkspaceStatusAsync(userId, scope);
}
public async Task<WritingWorkspaceStatus> RegisterWritingWindow(WritingWindowRegistration registration)
{
var userId = RequireUserId();
await Groups.AddToGroupAsync(Context.ConnectionId, PresenceGroup(userId));
var status = await writingPresence.RegisterAsync(userId, Context.ConnectionId, registration, await presence.GetStatusAsync(userId));
await Clients.Group(PresenceGroup(userId)).SendAsync("WritingWorkspacePresenceChanged", status);
return status;
}
public async Task<WritingWorkspaceStatus> WritingWindowHeartbeat(WritingWindowHeartbeat heartbeat)
{
var userId = RequireUserId();
var status = await writingPresence.HeartbeatAsync(userId, Context.ConnectionId, heartbeat, await presence.GetStatusAsync(userId));
await Clients.Group(PresenceGroup(userId)).SendAsync("WritingWorkspacePresenceChanged", status);
return status;
}
public async Task<WritingWorkspaceStatus> RequestWritingWindowFocus(WritingWorkspaceScope scope, string role)
{
var userId = RequireUserId();
var connectionId = await writingPresence.GetConnectionIdForRoleAsync(userId, scope, role);
if (!string.IsNullOrWhiteSpace(connectionId))
{
await Clients.Client(connectionId).SendAsync("WritingWorkspaceFocusRequested", new
{
WritingRole = WritingWorkspaceRoles.Normalise(role),
scope.ProjectID,
scope.BookID
});
}
return await WritingWorkspaceStatusAsync(userId, scope);
}
public async Task<ManuscriptScanState> StartOnboardingManuscriptScan()
{
var userId = RequireUserId();
@ -163,10 +207,13 @@ public sealed class WordCompanionFollowHub(
public override async Task OnDisconnectedAsync(Exception? exception)
{
int? disconnectedUserId = null;
var status = await presence.DisconnectAsync(Context.ConnectionId);
if (status is not null)
{
disconnectedUserId = status.UserID;
await Clients.Group(PresenceGroup(status.UserID)).SendAsync("WordCompanionPresenceChanged", status);
await BroadcastWritingWorkspaceStatusAsync(status.UserID, status.LinkedProjectID, status.LinkedBookID);
var failedScans = await scanStore.FailRunningForUserAsync(
status.UserID,
"The Word Companion disconnected before the scan finished. Reopen Word and try again.");
@ -176,9 +223,30 @@ public sealed class WordCompanionFollowHub(
}
}
var writingStatus = await writingPresence.DisconnectAsync(
Context.ConnectionId,
disconnectedUserId.HasValue ? await presence.GetStatusAsync(disconnectedUserId.Value) : null);
if (writingStatus is not null)
{
await Clients.Group(PresenceGroup(writingStatus.UserID)).SendAsync("WritingWorkspacePresenceChanged", writingStatus);
}
await base.OnDisconnectedAsync(exception);
}
private async Task<WritingWorkspaceStatus> WritingWorkspaceStatusAsync(int userId, WritingWorkspaceScope scope)
=> await writingPresence.GetStatusAsync(userId, scope, await presence.GetStatusAsync(userId));
private async Task BroadcastWritingWorkspaceStatusAsync(int userId, int? projectId, int? bookId)
{
var workspaceStatus = await WritingWorkspaceStatusAsync(userId, new WritingWorkspaceScope
{
ProjectID = projectId,
BookID = bookId
});
await Clients.Group(PresenceGroup(userId)).SendAsync("WritingWorkspacePresenceChanged", workspaceStatus);
}
private int RequireUserId()
=> TryGetUserId(out var userId) ? userId : throw new HubException("Sign in to use the Word Companion.");

View File

@ -0,0 +1,76 @@
namespace PlotLine.Models;
public static class WritingWorkspaceRoles
{
public const string None = "None";
public const string Launcher = "Launcher";
public const string Context = "Context";
public const string Intent = "Intent";
public static string Normalise(string? role)
{
var value = (role ?? string.Empty).Trim();
return value.Equals(Launcher, StringComparison.OrdinalIgnoreCase) ? Launcher
: value.Equals(Context, StringComparison.OrdinalIgnoreCase) ? Context
: value.Equals(Intent, StringComparison.OrdinalIgnoreCase) ? Intent
: None;
}
}
public sealed class WritingWorkspaceScope
{
public int? ProjectID { get; init; }
public int? BookID { get; init; }
}
public sealed class WritingWindowRegistration
{
public string? BrowserWindowID { get; init; }
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public string? CurrentRoute { get; init; }
public string? WritingRole { get; init; }
public string? DisplayLabel { get; init; }
}
public sealed class WritingWindowHeartbeat
{
public string? BrowserWindowID { get; init; }
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public string? CurrentRoute { get; init; }
public string? WritingRole { get; init; }
public string? DisplayLabel { get; init; }
}
public sealed class WritingWindowPresence
{
public string BrowserWindowID { get; init; } = string.Empty;
public int UserID { get; init; }
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public string CurrentRoute { get; init; } = string.Empty;
public string WritingRole { get; init; } = WritingWorkspaceRoles.None;
public string? DisplayLabel { get; init; }
public bool IsActive { get; init; }
public DateTime ConnectedUtc { get; init; }
public DateTime LastSeenUtc { get; init; }
}
public sealed class WritingWorkspaceStatus
{
public int UserID { get; init; }
public int? ProjectID { get; init; }
public int? BookID { get; init; }
public bool WordCompanionConnected { get; init; }
public bool WordDocumentOpen { get; init; }
public int? WordProjectID { get; init; }
public int? WordBookID { get; init; }
public StoryPosition? CurrentStoryPosition { get; init; }
public IReadOnlyList<WritingWindowPresence> Windows { get; init; } = [];
public WritingWindowPresence? Launcher { get; init; }
public WritingWindowPresence? Context { get; init; }
public WritingWindowPresence? Intent { get; init; }
public bool ContextOpen => Context?.IsActive == true;
public bool IntentOpen => Intent?.IsActive == true;
}

View File

@ -195,6 +195,7 @@ public class Program
builder.Services.AddScoped<IImportService, ImportService>();
builder.Services.AddScoped<IWordCompanionService, WordCompanionService>();
builder.Services.AddSingleton<IWordCompanionPresenceService, WordCompanionPresenceService>();
builder.Services.AddSingleton<IWritingWorkspacePresenceService, WritingWorkspacePresenceService>();
builder.Services.AddSingleton<IManuscriptScanPreviewStore, ManuscriptScanPreviewStore>();
builder.Services.AddSingleton<IOnboardingStoryIntelligenceBatchStore, OnboardingStoryIntelligenceBatchStore>();
builder.Services.AddHostedService<WordCompanionPresenceMonitor>();

View File

@ -1,10 +1,12 @@
using Microsoft.AspNetCore.SignalR;
using PlotLine.Hubs;
using PlotLine.Models;
namespace PlotLine.Services;
public sealed class WordCompanionPresenceMonitor(
IWordCompanionPresenceService presence,
IWritingWorkspacePresenceService writingPresence,
IManuscriptScanPreviewStore scanStore,
IHubContext<WordCompanionFollowHub> hub) : BackgroundService
{
@ -21,6 +23,14 @@ public sealed class WordCompanionPresenceMonitor(
await hub.Clients
.Group(PresenceGroup(status.UserID))
.SendAsync("WordCompanionPresenceChanged", status, stoppingToken);
var workspaceStatus = await writingPresence.GetStatusAsync(status.UserID, new WritingWorkspaceScope
{
ProjectID = status.LinkedProjectID,
BookID = status.LinkedBookID
}, status);
await hub.Clients
.Group(PresenceGroup(status.UserID))
.SendAsync("WritingWorkspacePresenceChanged", workspaceStatus, stoppingToken);
var failedScans = await scanStore.FailRunningForUserAsync(
status.UserID,
"The Word Companion disconnected before the scan finished. Reopen Word and try again.");
@ -29,10 +39,18 @@ public sealed class WordCompanionPresenceMonitor(
await hub.Clients
.Group(PresenceGroup(status.UserID))
.SendAsync("OnboardingScanFailed", failedScan, stoppingToken);
}
}
}
var staleWorkspaceStatuses = await writingPresence.MarkStaleOfflineAsync(presence.GetStatusAsync);
foreach (var workspaceStatus in staleWorkspaceStatuses)
{
await hub.Clients
.Group(PresenceGroup(workspaceStatus.UserID))
.SendAsync("WritingWorkspacePresenceChanged", workspaceStatus, stoppingToken);
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
}

View File

@ -0,0 +1,217 @@
using System.Collections.Concurrent;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IWritingWorkspacePresenceService
{
Task<WritingWorkspaceStatus> RegisterAsync(int userId, string connectionId, WritingWindowRegistration registration, WordCompanionPresenceStatus wordStatus);
Task<WritingWorkspaceStatus> HeartbeatAsync(int userId, string connectionId, WritingWindowHeartbeat heartbeat, WordCompanionPresenceStatus wordStatus);
Task<WritingWorkspaceStatus?> DisconnectAsync(string connectionId, WordCompanionPresenceStatus? wordStatus);
Task<WritingWorkspaceStatus> GetStatusAsync(int userId, WritingWorkspaceScope scope, WordCompanionPresenceStatus wordStatus);
Task<IReadOnlyList<WritingWorkspaceStatus>> MarkStaleOfflineAsync(Func<int, Task<WordCompanionPresenceStatus>> wordStatusForUser);
Task<string?> GetConnectionIdForRoleAsync(int userId, WritingWorkspaceScope scope, string role);
}
public sealed class WritingWorkspacePresenceService : IWritingWorkspacePresenceService
{
private static readonly TimeSpan ActiveTimeout = TimeSpan.FromSeconds(45);
private readonly ConcurrentDictionary<string, WindowRecord> windowsById = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, string> windowIdsByConnection = new(StringComparer.Ordinal);
public Task<WritingWorkspaceStatus> RegisterAsync(int userId, string connectionId, WritingWindowRegistration registration, WordCompanionPresenceStatus wordStatus)
{
var now = DateTime.UtcNow;
var windowId = Clean(registration.BrowserWindowID);
if (windowId is null)
{
windowId = Guid.NewGuid().ToString("N");
}
var record = windowsById.AddOrUpdate(
WindowKey(userId, windowId),
_ => new WindowRecord
{
BrowserWindowID = windowId,
UserID = userId,
ConnectedUtc = now
},
(_, existing) => existing);
Apply(record, connectionId, registration.ProjectID, registration.BookID, registration.CurrentRoute, registration.WritingRole, registration.DisplayLabel, now);
windowIdsByConnection[connectionId] = WindowKey(userId, windowId);
return Task.FromResult(BuildStatus(userId, new WritingWorkspaceScope { ProjectID = record.ProjectID, BookID = record.BookID }, wordStatus));
}
public Task<WritingWorkspaceStatus> HeartbeatAsync(int userId, string connectionId, WritingWindowHeartbeat heartbeat, WordCompanionPresenceStatus wordStatus)
{
var registration = new WritingWindowRegistration
{
BrowserWindowID = heartbeat.BrowserWindowID,
ProjectID = heartbeat.ProjectID,
BookID = heartbeat.BookID,
CurrentRoute = heartbeat.CurrentRoute,
WritingRole = heartbeat.WritingRole,
DisplayLabel = heartbeat.DisplayLabel
};
return RegisterAsync(userId, connectionId, registration, wordStatus);
}
public Task<WritingWorkspaceStatus?> DisconnectAsync(string connectionId, WordCompanionPresenceStatus? wordStatus)
{
if (!windowIdsByConnection.TryRemove(connectionId, out var key)
|| !windowsById.TryGetValue(key, out var record)
|| !string.Equals(record.ConnectionID, connectionId, StringComparison.Ordinal))
{
return Task.FromResult<WritingWorkspaceStatus?>(null);
}
record.IsActive = false;
record.ConnectionID = string.Empty;
var status = BuildStatus(record.UserID, new WritingWorkspaceScope { ProjectID = record.ProjectID, BookID = record.BookID }, wordStatus ?? new WordCompanionPresenceStatus { UserID = record.UserID });
return Task.FromResult<WritingWorkspaceStatus?>(status);
}
public Task<WritingWorkspaceStatus> GetStatusAsync(int userId, WritingWorkspaceScope scope, WordCompanionPresenceStatus wordStatus)
=> Task.FromResult(BuildStatus(userId, scope, wordStatus));
public async Task<IReadOnlyList<WritingWorkspaceStatus>> MarkStaleOfflineAsync(Func<int, Task<WordCompanionPresenceStatus>> wordStatusForUser)
{
var now = DateTime.UtcNow;
var changedScopes = new Dictionary<string, (int UserID, int? ProjectID, int? BookID)>(StringComparer.Ordinal);
foreach (var record in windowsById.Values)
{
if (!record.IsActive || now - record.LastSeenUtc <= ActiveTimeout)
{
continue;
}
record.IsActive = false;
if (!string.IsNullOrWhiteSpace(record.ConnectionID))
{
windowIdsByConnection.TryRemove(record.ConnectionID, out _);
record.ConnectionID = string.Empty;
}
changedScopes[ScopeKey(record.UserID, record.ProjectID, record.BookID)] = (record.UserID, record.ProjectID, record.BookID);
}
var statuses = new List<WritingWorkspaceStatus>();
foreach (var scope in changedScopes.Values)
{
var wordStatus = await wordStatusForUser(scope.UserID);
statuses.Add(BuildStatus(scope.UserID, new WritingWorkspaceScope { ProjectID = scope.ProjectID, BookID = scope.BookID }, wordStatus));
}
return statuses;
}
public Task<string?> GetConnectionIdForRoleAsync(int userId, WritingWorkspaceScope scope, string role)
{
var normalRole = WritingWorkspaceRoles.Normalise(role);
var now = DateTime.UtcNow;
var record = ActiveWindows(userId, scope, now)
.Where(window => string.Equals(window.WritingRole, normalRole, StringComparison.Ordinal))
.OrderByDescending(window => window.LastSeenUtc)
.FirstOrDefault();
return Task.FromResult(record?.ConnectionID);
}
private static void Apply(WindowRecord record, string connectionId, int? projectId, int? bookId, string? route, string? role, string? displayLabel, DateTime now)
{
record.ConnectionID = connectionId;
record.ProjectID = Positive(projectId);
record.BookID = Positive(bookId);
record.CurrentRoute = Clean(route) ?? "/";
record.WritingRole = WritingWorkspaceRoles.Normalise(role);
record.DisplayLabel = Clean(displayLabel);
record.IsActive = true;
record.LastSeenUtc = now;
}
private WritingWorkspaceStatus BuildStatus(int userId, WritingWorkspaceScope scope, WordCompanionPresenceStatus wordStatus)
{
var now = DateTime.UtcNow;
var windows = ActiveWindows(userId, scope, now)
.Select(ToPresence)
.OrderBy(window => window.WritingRole)
.ThenByDescending(window => window.LastSeenUtc)
.ToList();
return new WritingWorkspaceStatus
{
UserID = userId,
ProjectID = Positive(scope.ProjectID) ?? Positive(wordStatus.LinkedProjectID),
BookID = Positive(scope.BookID) ?? Positive(wordStatus.LinkedBookID),
WordCompanionConnected = wordStatus.IsConnected,
WordDocumentOpen = wordStatus.DocumentOpen,
WordProjectID = wordStatus.LinkedProjectID,
WordBookID = wordStatus.LinkedBookID,
Windows = windows,
Launcher = LatestRole(windows, WritingWorkspaceRoles.Launcher),
Context = LatestRole(windows, WritingWorkspaceRoles.Context),
Intent = LatestRole(windows, WritingWorkspaceRoles.Intent)
};
}
private IEnumerable<WindowRecord> ActiveWindows(int userId, WritingWorkspaceScope scope, DateTime now)
{
var projectId = Positive(scope.ProjectID);
var bookId = Positive(scope.BookID);
return windowsById.Values.Where(record =>
record.UserID == userId
&& record.IsActive
&& now - record.LastSeenUtc <= ActiveTimeout
&& (!projectId.HasValue || record.ProjectID == projectId)
&& (!bookId.HasValue || record.BookID == bookId));
}
private static WritingWindowPresence? LatestRole(IReadOnlyList<WritingWindowPresence> windows, string role)
=> windows
.Where(window => string.Equals(window.WritingRole, role, StringComparison.Ordinal))
.OrderByDescending(window => window.LastSeenUtc)
.FirstOrDefault();
private static WritingWindowPresence ToPresence(WindowRecord record)
=> new()
{
BrowserWindowID = record.BrowserWindowID,
UserID = record.UserID,
ProjectID = record.ProjectID,
BookID = record.BookID,
CurrentRoute = record.CurrentRoute,
WritingRole = record.WritingRole,
DisplayLabel = record.DisplayLabel,
IsActive = record.IsActive,
ConnectedUtc = record.ConnectedUtc,
LastSeenUtc = record.LastSeenUtc
};
private static string? Clean(string? value)
{
var cleaned = (value ?? string.Empty).Trim();
return string.IsNullOrWhiteSpace(cleaned) ? null : cleaned;
}
private static int? Positive(int? value)
=> value is > 0 ? value.Value : null;
private static string WindowKey(int userId, string windowId) => $"{userId}:{windowId}";
private static string ScopeKey(int userId, int? projectId, int? bookId) => $"{userId}:{projectId ?? 0}:{bookId ?? 0}";
private sealed class WindowRecord
{
public string BrowserWindowID { get; init; } = string.Empty;
public int UserID { get; init; }
public string ConnectionID { get; set; } = string.Empty;
public int? ProjectID { get; set; }
public int? BookID { get; set; }
public string CurrentRoute { get; set; } = "/";
public string WritingRole { get; set; } = WritingWorkspaceRoles.None;
public string? DisplayLabel { get; set; }
public bool IsActive { get; set; }
public DateTime ConnectedUtc { get; init; }
public DateTime LastSeenUtc { get; set; }
}
}

View File

@ -101,6 +101,17 @@
<p>Linked to PlotDirector</p>
</section>
<section class="word-companion-section word-companion-writing-workspace" aria-label="Writing workspace">
<div class="word-companion-section-header">
<span>Writing Workspace</span>
</div>
<div class="word-companion-actions">
<button type="button" data-open-writing-workspace>Open Workspace</button>
<button type="button" data-open-writing-context>Open Context</button>
<button type="button" data-open-writing-intent>Open Intent</button>
</div>
</section>
<nav class="word-companion-tabs" aria-label="Companion sections">
<button type="button" class="is-active" data-tab-button="scene" aria-selected="true">Scene</button>
<button type="button" data-tab-button="review" aria-selected="false">Review</button>

View File

@ -3,6 +3,14 @@
ViewData["Title"] = "Writer Workspace";
var message = TempData["WriterWorkspaceMessage"] as string;
var error = TempData["WriterWorkspaceError"] as string;
var workspaceSeed = Model.WriteNext
.Concat(Model.ContinueWriting)
.Concat(Model.RevisionQueue)
.Concat(Model.PolishingQueue)
.FirstOrDefault();
var workspaceProjectId = workspaceSeed?.ProjectID ?? Model.ProjectID;
var workspaceBookId = workspaceSeed?.BookID;
var workspaceBookTitle = workspaceSeed?.BookDisplayTitle ?? "Waiting for Word";
}
<div class="page-heading writer-dashboard-heading">
@ -42,6 +50,35 @@
</div>
}
<section class="story-bible-section writing-workspace-launcher"
data-writing-workspace-launcher
data-project-id="@workspaceProjectId"
data-book-id="@workspaceBookId">
<div class="story-bible-section-heading">
<div>
<p class="eyebrow">Writing Workspace</p>
<h2>Context | Word | Intent</h2>
</div>
<span class="soft-count" data-writing-workspace-book>@workspaceBookTitle</span>
</div>
<div class="writing-workspace-status-grid" aria-label="Writing workspace status">
<div><span>Word Companion</span><strong data-writing-workspace-word>Checking...</strong></div>
<div><span>Context</span><strong data-writing-workspace-context>Checking...</strong></div>
<div><span>Intent</span><strong data-writing-workspace-intent>Checking...</strong></div>
<div><span>Layout</span><strong data-writing-workspace-layout>Adaptive</strong></div>
</div>
<p class="muted writing-workspace-message" data-writing-workspace-message>
Open a linked Word manuscript or use a queued scene to start the writing workstation.
</p>
<div class="button-row">
<button type="button" class="btn btn-primary" data-start-writing-workspace>Start / Reapply Workspace</button>
<button type="button" class="btn btn-outline-primary" data-open-writing-context>Open Context</button>
<button type="button" class="btn btn-outline-primary" data-open-writing-intent>Open Intent</button>
<button type="button" class="btn btn-outline-secondary" data-focus-writing-context hidden>Focus Context</button>
<button type="button" class="btn btn-outline-secondary" data-focus-writing-intent hidden>Focus Intent</button>
</div>
</section>
<section class="story-bible-section">
<div class="story-bible-section-heading">
<div>
@ -274,3 +311,8 @@
}
</div>
</section>
@section Scripts {
<script src="~/js/writing-workspace.js" asp-append-version="true"></script>
<script src="~/js/writing-workspace-launcher.js" asp-append-version="true"></script>
}

View File

@ -19,6 +19,10 @@
<span class="writing-context-status-dot" aria-hidden="true"></span>
<span data-writing-context-status-text>@(Model.Display?.FollowStatus ?? "Connected, waiting for scene")</span>
</div>
<div class="writing-workspace-role-indicator">
<span class="writing-context-status-dot" aria-hidden="true"></span>
<span>Writing Workspace</span>
</div>
</header>
<section data-writing-context-content>
@ -27,6 +31,15 @@
</main>
@section Scripts {
<script src="~/js/writing-workspace.js" asp-append-version="true"></script>
<script src="~/js/writing-follow.js" asp-append-version="true"></script>
<script src="~/js/writing-context.js" asp-append-version="true"></script>
<script>
window.PlotDirectorWritingWorkspace?.registerWindow({
projectID: "@Model.ProjectID",
bookID: "@Model.BookID",
writingRole: window.PlotDirectorWritingWorkspace.roles.context,
displayLabel: "Writing Context"
});
</script>
}

View File

@ -19,6 +19,10 @@
<span class="writing-context-status-dot" aria-hidden="true"></span>
<span data-writing-intent-status-text>@(Model.Display?.FollowStatus ?? "Connected, waiting for scene")</span>
</div>
<div class="writing-workspace-role-indicator">
<span class="writing-context-status-dot" aria-hidden="true"></span>
<span>Writing Workspace</span>
</div>
</header>
<section data-writing-intent-content>
@ -27,6 +31,15 @@
</main>
@section Scripts {
<script src="~/js/writing-workspace.js" asp-append-version="true"></script>
<script src="~/js/writing-follow.js" asp-append-version="true"></script>
<script src="~/js/writing-intent.js" asp-append-version="true"></script>
<script>
window.PlotDirectorWritingWorkspace?.registerWindow({
projectID: "@Model.ProjectID",
bookID: "@Model.BookID",
writingRole: window.PlotDirectorWritingWorkspace.roles.intent,
displayLabel: "Writing Intent"
});
</script>
}

View File

@ -4200,6 +4200,57 @@ body.dragging-location [data-drag-type="location"] {
box-shadow: 0 14px 34px rgba(36, 32, 26, 0.05);
}
.writing-workspace-launcher {
border-color: rgba(47, 111, 99, 0.22);
background: linear-gradient(180deg, #fff 0%, rgba(238, 245, 242, 0.68) 100%);
}
.writing-workspace-status-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-bottom: 10px;
}
.writing-workspace-status-grid div {
display: grid;
gap: 2px;
border: 1px solid var(--plotline-line);
border-radius: 8px;
padding: 10px 12px;
background: var(--plotline-panel);
}
.writing-workspace-status-grid span {
color: var(--plotline-muted);
font-size: 0.76rem;
font-weight: 800;
text-transform: uppercase;
}
.writing-workspace-status-grid strong {
color: var(--plotline-ink);
font-size: 1rem;
}
.writing-workspace-message {
min-height: 1.35rem;
}
.writing-workspace-role-indicator {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.65rem;
border: 1px solid rgba(47, 111, 99, 0.22);
border-radius: 999px;
background: var(--plotline-soft);
color: var(--plotline-accent-dark);
font-size: 0.9rem;
font-weight: 700;
white-space: nowrap;
}
.writer-queue-card {
border: 1px solid var(--plotline-line);
border-radius: 12px;
@ -4659,6 +4710,10 @@ body.dragging-location [data-drag-type="location"] {
grid-template-columns: 1fr;
}
.writing-workspace-status-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.writer-checklist-row,
.writer-add-inline {
grid-template-columns: 1fr;

File diff suppressed because one or more lines are too long

View File

@ -662,6 +662,10 @@ body {
font-weight: 800;
}
.word-companion-writing-workspace .word-companion-actions {
grid-template-columns: 1fr;
}
.word-companion-message:empty {
display: none;
}

File diff suppressed because one or more lines are too long

View File

@ -86,6 +86,9 @@
const unlinkDialogSceneCount = document.querySelector("[data-unlink-dialog-scene-count]");
const cleanDialogChapterCount = document.querySelector("[data-clean-dialog-chapter-count]");
const cleanDialogSceneCount = document.querySelector("[data-clean-dialog-scene-count]");
const openWritingWorkspaceButton = document.querySelector("[data-open-writing-workspace]");
const openWritingContextButton = document.querySelector("[data-open-writing-context]");
const openWritingIntentButton = document.querySelector("[data-open-writing-intent]");
const plotDirectorSceneDetails = document.querySelector("[data-plotdirector-scene-details]");
const plotDirectorSceneTitle = document.querySelector("[data-plotdirector-scene-title]");
const plotDirectorRevisionStatus = document.querySelector("[data-plotdirector-revision-status]");
@ -1349,6 +1352,45 @@
return Number.isInteger(bookId) && bookId > 0 ? bookId : null;
};
const writingWorkspaceScope = () => ({
projectId: selectedPresenceProjectId(),
bookId: selectedPresenceBookId()
});
const writingWindowName = (role) => {
const scope = writingWorkspaceScope();
const suffix = scope.bookId ? `Book_${scope.bookId}` : scope.projectId ? `Project_${scope.projectId}` : "General";
return `PlotDirector${role}_${suffix}`;
};
const writingWorkspaceUrl = (target) => {
const scope = writingWorkspaceScope();
const url = new URL(target === "workspace" ? "/Writer" : `/Writing/${target}`, window.location.origin);
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 (detectedSceneAnchorId && target !== "workspace") {
url.searchParams.set("sceneId", detectedSceneAnchorId);
}
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));
if (opened) {
opened.focus();
} else {
setDocumentMessage("Your browser blocked the writing workspace window. Use the browser pop-up prompt to allow PlotDirector windows.");
}
};
const currentDocumentName = () => {
const url = String(window.Office?.context?.document?.url || "").trim();
if (!url) {
@ -5988,6 +6030,9 @@
attachPlotDirectorIdsButton?.addEventListener("click", attachPlotDirectorIds);
unlinkWordDocumentButton?.addEventListener("click", unlinkThisWordDocument);
cleanUnlinkWordDocumentButton?.addEventListener("click", cleanAndUnlinkWordDocument);
openWritingWorkspaceButton?.addEventListener("click", () => openWritingWindow("workspace"));
openWritingContextButton?.addEventListener("click", () => openWritingWindow("context"));
openWritingIntentButton?.addEventListener("click", () => openWritingWindow("intent"));
createPlotDirectorChapterButton?.addEventListener("click", createPlotDirectorChapter);
linkExistingChapterButton?.addEventListener("click", openLinkExistingChapterDialog);
linkChapterSearch?.addEventListener("input", renderLinkChapterOptions);

File diff suppressed because one or more lines are too long

View File

@ -48,6 +48,25 @@
}
};
const registerWorkspaceRole = () => {
const role = config.source === "WritingContext"
? window.PlotDirectorWritingWorkspace?.roles?.context
: config.source === "WritingIntent"
? window.PlotDirectorWritingWorkspace?.roles?.intent
: null;
if (!role || !window.PlotDirectorWritingWorkspace?.registerWindow) {
return;
}
const fragment = contentHost?.querySelector(config.fragmentSelector);
void window.PlotDirectorWritingWorkspace.registerWindow({
projectID: fragment?.dataset.projectId || root.dataset.scopeProjectId,
bookID: fragment?.dataset.bookId || root.dataset.scopeBookId,
writingRole: role,
displayLabel: config.source === "WritingContext" ? "Writing Context" : "Writing Intent"
}).catch(() => null);
};
const readStoredTab = () => {
try {
return localStorage.getItem(config.storageKey) || config.defaultTab;
@ -87,6 +106,7 @@
if (fragmentStatus) {
setStatus(fragmentStatus);
}
registerWorkspaceRole();
const fragmentSceneId = parsePositiveInt(fragment?.dataset.sceneId);
if (fragmentSceneId) {

View File

@ -0,0 +1,123 @@
(() => {
const root = document.querySelector("[data-writing-workspace-launcher]");
const workspace = window.PlotDirectorWritingWorkspace;
if (!root || !workspace) {
return;
}
const statusNodes = {
book: root.querySelector("[data-writing-workspace-book]"),
word: root.querySelector("[data-writing-workspace-word]"),
context: root.querySelector("[data-writing-workspace-context]"),
intent: root.querySelector("[data-writing-workspace-intent]"),
layout: root.querySelector("[data-writing-workspace-layout]"),
message: root.querySelector("[data-writing-workspace-message]")
};
const startButton = root.querySelector("[data-start-writing-workspace]");
const contextButton = root.querySelector("[data-open-writing-context]");
const intentButton = root.querySelector("[data-open-writing-intent]");
const focusContextButton = root.querySelector("[data-focus-writing-context]");
const focusIntentButton = root.querySelector("[data-focus-writing-intent]");
const fallbackScope = workspace.scopeFrom({
projectID: root.dataset.projectId,
bookID: root.dataset.bookId
});
let activeScope = { ...fallbackScope };
let latestStatus = null;
const text = (node, value) => {
if (node) node.textContent = value;
};
const statusText = (status, role) => {
const match = (status?.windows || []).find((windowPresence) =>
String(windowPresence.writingRole || windowPresence.WritingRole || "").toLowerCase() === role.toLowerCase()
&& (!activeScope.bookID || Number(windowPresence.bookID || windowPresence.BookID) === activeScope.bookID));
if (!match) return "Not open";
const id = match.browserWindowID || match.BrowserWindowID || "";
return id === workspace.getBrowserWindowId() ? "This window" : "Open";
};
const setMessage = (message) => text(statusNodes.message, message || "");
const render = (status = latestStatus) => {
latestStatus = status;
activeScope = workspace.statusScope(status, activeScope);
text(statusNodes.book, activeScope.bookID ? `Book ${activeScope.bookID}` : "Waiting for book");
text(statusNodes.word, status?.wordCompanionConnected || status?.WordCompanionConnected ? "Connected" : "Not connected");
text(statusNodes.context, statusText(status, "Context"));
text(statusNodes.intent, statusText(status, "Intent"));
const contextOpen = statusText(status, "Context") !== "Not open";
const intentOpen = statusText(status, "Intent") !== "Not open";
if (focusContextButton) focusContextButton.hidden = !contextOpen;
if (focusIntentButton) focusIntentButton.hidden = !intentOpen;
if (contextButton) contextButton.textContent = contextOpen ? "Focus Context" : "Open Context";
if (intentButton) intentButton.textContent = intentOpen ? "Focus Intent" : "Open Intent";
};
const refresh = async () => {
try {
render(await workspace.watchWorkspace(activeScope));
} catch (error) {
setMessage("Writing workspace presence is unavailable.");
console.debug("Writing workspace presence unavailable.", error);
}
};
const openOrFocus = async (role) => {
const scope = workspace.statusScope(latestStatus, activeScope);
if (!scope.bookID) {
setMessage("Open a linked Word manuscript or focus a book before opening writing windows.");
return;
}
await workspace.focusRole(role, scope);
await refresh();
};
startButton?.addEventListener("click", async () => {
startButton.disabled = true;
setMessage("Starting writing workspace...");
try {
const result = await workspace.startWorkspace(activeScope, { requestScreens: true });
if (!result.ok) {
setMessage(result.message || `Pop-up blocked for ${result.blocked?.join(", ") || "a writing window"}. Use the explicit open buttons.`);
} else if (result.reusedCurrentWindowAs) {
setMessage("Reusing this window for Writing Intent and opening Context.");
} else {
setMessage("Writing workspace ready.");
}
text(statusNodes.layout, result.screenInfo?.layout?.mode || "single-screen");
await refresh();
} catch (error) {
console.debug("Unable to start writing workspace.", error);
setMessage("Writing workspace could not be started. Use the direct open buttons.");
} finally {
startButton.disabled = false;
}
});
contextButton?.addEventListener("click", () => openOrFocus(workspace.roles.context));
intentButton?.addEventListener("click", () => openOrFocus(workspace.roles.intent));
focusContextButton?.addEventListener("click", () => openOrFocus(workspace.roles.context));
focusIntentButton?.addEventListener("click", () => openOrFocus(workspace.roles.intent));
document.addEventListener("plotdirector:writing-workspace-status", (event) => render(event.detail));
document.addEventListener(window.PlotDirectorStoryPosition?.eventName || "plotdirector:story-position-changed", (event) => {
const position = window.PlotDirectorStoryPosition?.normalise?.(event.detail, event.detail?.source || event.detail?.Source) || event.detail;
if (position?.projectId && position?.bookId) {
activeScope = { projectID: position.projectId, bookID: position.bookId };
void workspace.registerWindow({
...activeScope,
writingRole: workspace.roles.launcher,
displayLabel: "Writing Workspace"
}).then(render).catch(() => null);
}
});
void workspace.registerWindow({
...activeScope,
writingRole: workspace.roles.launcher,
displayLabel: "Writing Workspace"
}).then(render).catch(() => null);
})();

View File

@ -0,0 +1,349 @@
(() => {
const storageKeys = {
windowId: "plotdirector.browserWindowId",
preferences: "plotdirector.writingWorkspace.preferences"
};
const roles = {
none: "None",
launcher: "Launcher",
context: "Context",
intent: "Intent"
};
const windowRefs = new Map();
let connection = null;
let connectionPromise = null;
let heartbeatTimer = null;
let registration = null;
let latestStatus = null;
let latestPosition = null;
const parsePositiveInt = (value) => {
const parsed = Number.parseInt(value || "", 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const getBrowserWindowId = () => {
try {
let value = sessionStorage.getItem(storageKeys.windowId);
if (!value) {
value = window.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
sessionStorage.setItem(storageKeys.windowId, value);
}
return value;
} catch {
return window.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
};
const cleanRole = (role) => {
const value = String(role || "").toLowerCase();
return value === "launcher" ? roles.launcher
: value === "context" ? roles.context
: value === "intent" ? roles.intent
: roles.none;
};
const scopeFrom = (source = {}) => ({
projectID: parsePositiveInt(source.projectID ?? source.projectId ?? source.ProjectID ?? source.ProjectId),
bookID: parsePositiveInt(source.bookID ?? source.bookId ?? source.BookID ?? source.BookId)
});
const scopeMatches = (windowPresence, scope) => {
const projectId = parsePositiveInt(scope?.projectID ?? scope?.projectId);
const bookId = parsePositiveInt(scope?.bookID ?? scope?.bookId);
return (!projectId || parsePositiveInt(windowPresence?.projectID ?? windowPresence?.projectId) === projectId)
&& (!bookId || parsePositiveInt(windowPresence?.bookID ?? windowPresence?.bookId) === bookId);
};
const roleWindow = (status, role, scope) => (status?.windows || [])
.filter((windowPresence) => cleanRole(windowPresence.writingRole ?? windowPresence.WritingRole) === role && scopeMatches(windowPresence, scope))
.sort((a, b) => Date.parse(b.lastSeenUtc || b.LastSeenUtc || 0) - Date.parse(a.lastSeenUtc || a.LastSeenUtc || 0))[0] || null;
const currentRoute = () => `${window.location.pathname}${window.location.search}${window.location.hash}`;
const statusScope = (status, fallback = {}) => ({
projectID: parsePositiveInt(status?.projectID ?? status?.projectId ?? fallback.projectID ?? fallback.projectId),
bookID: parsePositiveInt(status?.bookID ?? status?.bookId ?? fallback.bookID ?? fallback.bookId)
});
const buildWritingUrl = (role, scope) => {
const url = new URL(role === roles.context ? "/Writing/Context" : "/Writing/Intent", window.location.origin);
const projectId = parsePositiveInt(scope?.projectID ?? scope?.projectId);
const bookId = parsePositiveInt(scope?.bookID ?? scope?.bookId);
const position = latestPosition || {};
if (projectId) url.searchParams.set("projectId", projectId);
if (bookId) url.searchParams.set("bookId", bookId);
if (position.chapterId) url.searchParams.set("chapterId", position.chapterId);
if (position.sceneId) url.searchParams.set("sceneId", position.sceneId);
return `${url.pathname}${url.search}${url.hash}`;
};
const stableWindowName = (role, scope) => {
const bookId = parsePositiveInt(scope?.bookID ?? scope?.bookId);
const projectId = parsePositiveInt(scope?.projectID ?? scope?.projectId);
const suffix = bookId ? `Book_${bookId}` : projectId ? `Project_${projectId}` : "General";
return `PlotDirector${role}_${suffix}`.replace(/[^A-Za-z0-9_]/g, "_");
};
const readPreferences = () => {
try {
return JSON.parse(localStorage.getItem(storageKeys.preferences) || "{}") || {};
} catch {
return {};
}
};
const savePreferences = (preferences) => {
try {
localStorage.setItem(storageKeys.preferences, JSON.stringify({ ...readPreferences(), ...preferences }));
} catch {
// Layout memory is a convenience only.
}
};
const sortScreens = (screens) => [...(screens || [])]
.map((screen, index) => ({
index,
left: Number(screen.availLeft ?? screen.left ?? 0),
top: Number(screen.availTop ?? screen.top ?? 0),
width: Math.max(320, Number(screen.availWidth ?? screen.width ?? 1200)),
height: Math.max(320, Number(screen.availHeight ?? screen.height ?? 800)),
isPrimary: Boolean(screen.isPrimary),
isInternal: Boolean(screen.isInternal),
label: screen.label || (screen.isPrimary ? "Primary display" : `Display ${index + 1}`)
}))
.sort((a, b) => a.left - b.left || a.top - b.top || a.index - b.index);
const chooseLayout = (screens, preferences = {}) => {
const ordered = sortScreens(screens);
if (ordered.length >= 3) {
return {
mode: "three-screen",
contextDisplay: preferences.contextDisplay === "right" ? ordered[ordered.length - 1] : ordered[0],
intentDisplay: preferences.intentDisplay === "left" ? ordered[0] : ordered[ordered.length - 1],
wordDisplay: ordered[Math.floor(ordered.length / 2)],
displayCount: ordered.length
};
}
if (ordered.length === 2) {
const primaryRole = preferences.twoScreenPrimary === "intent" ? roles.intent : roles.context;
const secondary = ordered.find((screen) => !screen.isPrimary) || ordered[1];
return {
mode: "two-screen",
contextDisplay: primaryRole === roles.context ? secondary : ordered[0],
intentDisplay: primaryRole === roles.intent ? secondary : ordered[0],
wordDisplay: ordered.find((screen) => screen.isPrimary) || ordered[0],
displayCount: ordered.length
};
}
return {
mode: "single-screen",
contextDisplay: ordered[0] || null,
intentDisplay: ordered[0] || null,
wordDisplay: ordered[0] || null,
displayCount: ordered.length
};
};
const popupFeatures = (display) => {
if (!display) {
return "popup=yes,width=1120,height=900";
}
const width = Math.max(900, Math.floor(display.width * 0.92));
const height = Math.max(760, Math.floor(display.height * 0.92));
const left = Math.floor(display.left + (display.width - width) / 2);
const top = Math.floor(display.top + (display.height - height) / 2);
return `popup=yes,left=${left},top=${top},width=${width},height=${height}`;
};
const getScreenLayout = async (requestPermission) => {
if (!("getScreenDetails" in window) || typeof window.getScreenDetails !== "function") {
return { supported: false, permission: "unavailable", layout: chooseLayout([], readPreferences()) };
}
try {
if (navigator.permissions?.query && !requestPermission) {
const permission = await navigator.permissions.query({ name: "window-management" });
if (permission.state !== "granted") {
return { supported: true, permission: permission.state, layout: chooseLayout([], readPreferences()) };
}
}
const details = await window.getScreenDetails();
const screens = sortScreens(details.screens || []);
return { supported: true, permission: "granted", screens, currentScreen: details.currentScreen, layout: chooseLayout(screens, readPreferences()) };
} catch (error) {
return { supported: true, permission: error?.name === "NotAllowedError" ? "denied" : "unavailable", error, layout: chooseLayout([], readPreferences()) };
}
};
const ensureConnection = async () => {
if (!window.signalR) {
throw new Error("SignalR is unavailable.");
}
if (connectionPromise) {
return connectionPromise;
}
connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/word-companion-follow")
.withAutomaticReconnect()
.build();
connection.on("WritingWorkspacePresenceChanged", (status) => {
latestStatus = status;
document.dispatchEvent(new CustomEvent("plotdirector:writing-workspace-status", { bubbles: true, detail: status }));
});
connection.on("WordCompanionPresenceChanged", (status) => {
latestStatus = latestStatus ? { ...latestStatus, wordCompanionConnected: Boolean(status?.isConnected ?? status?.IsConnected), wordDocumentOpen: Boolean(status?.documentOpen ?? status?.DocumentOpen) } : latestStatus;
document.dispatchEvent(new CustomEvent("plotdirector:writing-workspace-word-status", { bubbles: true, detail: status }));
});
connection.on("WritingWorkspaceFocusRequested", () => {
try {
window.focus();
} catch {
// Browser focus restrictions are expected here.
}
});
connection.onreconnected(() => {
if (registration) {
void registerWindow(registration);
}
});
connectionPromise = connection.start().then(() => connection);
return connectionPromise;
};
const invoke = async (method, ...args) => {
const activeConnection = await ensureConnection();
return await activeConnection.invoke(method, ...args);
};
const registerWindow = async (options) => {
registration = {
browserWindowID: getBrowserWindowId(),
projectID: parsePositiveInt(options.projectID ?? options.projectId),
bookID: parsePositiveInt(options.bookID ?? options.bookId),
currentRoute: currentRoute(),
writingRole: cleanRole(options.writingRole || options.role),
displayLabel: options.displayLabel || ""
};
latestStatus = await invoke("RegisterWritingWindow", registration);
document.dispatchEvent(new CustomEvent("plotdirector:writing-workspace-status", { bubbles: true, detail: latestStatus }));
if (heartbeatTimer) {
window.clearInterval(heartbeatTimer);
}
heartbeatTimer = window.setInterval(() => {
if (!registration) return;
void invoke("WritingWindowHeartbeat", { ...registration, currentRoute: currentRoute() }).catch(() => null);
}, 15000);
return latestStatus;
};
const watchWorkspace = async (scope) => {
latestStatus = await invoke("WatchWritingWorkspace", scopeFrom(scope));
document.dispatchEvent(new CustomEvent("plotdirector:writing-workspace-status", { bubbles: true, detail: latestStatus }));
return latestStatus;
};
const openRoleWindow = (role, scope, display) => {
const url = buildWritingUrl(role, scope);
const name = stableWindowName(role, scope);
const opened = window.open(url, name, popupFeatures(display));
if (opened) {
windowRefs.set(role, opened);
try {
opened.focus();
} catch {
// Focus is best-effort only.
}
}
return opened;
};
const focusRole = async (role, scope) => {
const ref = windowRefs.get(role);
if (ref && !ref.closed) {
ref.focus();
return true;
}
const opened = window.open(buildWritingUrl(role, scope), stableWindowName(role, scope));
if (opened) {
windowRefs.set(role, opened);
opened.focus();
return true;
}
await invoke("RequestWritingWindowFocus", scopeFrom(scope), role).catch(() => null);
return false;
};
const startWorkspace = async (scope, options = {}) => {
const cleanScope = scopeFrom(scope);
const status = await watchWorkspace(cleanScope);
const resolvedScope = statusScope(status, cleanScope);
if (!resolvedScope.projectID || !resolvedScope.bookID) {
return { ok: false, message: "Choose a book or open a linked Word manuscript before starting the writing workspace.", status };
}
const screenInfo = await getScreenLayout(Boolean(options.requestScreens));
const layout = screenInfo.layout;
const existingContext = roleWindow(status, roles.context, resolvedScope);
const existingIntent = roleWindow(status, roles.intent, resolvedScope);
const currentRole = cleanRole(registration?.writingRole);
const opened = [];
const blocked = [];
if (!existingIntent && currentRole !== roles.context && currentRole !== roles.intent) {
savePreferences({ reuseLauncherAs: roles.intent, lastLayoutMode: layout.mode });
if (!existingContext) {
const contextWindow = openRoleWindow(roles.context, resolvedScope, layout.contextDisplay);
(contextWindow ? opened : blocked).push(roles.context);
}
window.location.assign(buildWritingUrl(roles.intent, resolvedScope));
return { ok: true, reusedCurrentWindowAs: roles.intent, opened, blocked, status, screenInfo };
}
if (!existingContext) {
const contextWindow = openRoleWindow(roles.context, resolvedScope, layout.contextDisplay);
(contextWindow ? opened : blocked).push(roles.context);
} else {
await focusRole(roles.context, resolvedScope);
}
if (!existingIntent) {
const intentWindow = currentRole === roles.intent ? window : openRoleWindow(roles.intent, resolvedScope, layout.intentDisplay);
(intentWindow ? opened : blocked).push(roles.intent);
} else {
await focusRole(roles.intent, resolvedScope);
}
savePreferences({ reuseLauncherAs: roles.intent, lastLayoutMode: layout.mode });
return { ok: blocked.length === 0, opened, blocked, status: await watchWorkspace(resolvedScope), screenInfo };
};
document.addEventListener(window.PlotDirectorStoryPosition?.eventName || "plotdirector:story-position-changed", (event) => {
latestPosition = window.PlotDirectorStoryPosition?.normalise?.(event.detail, event.detail?.source || event.detail?.Source) || event.detail;
});
window.PlotDirectorWritingWorkspace = {
roles,
getBrowserWindowId,
registerWindow,
watchWorkspace,
startWorkspace,
focusRole,
openRoleWindow,
stableWindowName,
buildWritingUrl,
sortScreens,
chooseLayout,
getScreenLayout,
scopeFrom,
statusScope
};
})();