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 Intent client uses shared writing follow helper", WritingIntentClientUsesSharedWritingFollowHelper),
("Writing mode pages are isolated from global Word navigation", WritingModePagesAreIsolatedFromGlobalWordNavigation), ("Writing mode pages are isolated from global Word navigation", WritingModePagesAreIsolatedFromGlobalWordNavigation),
("Writing pair shares canonical follow infrastructure", WritingPairSharesCanonicalFollowInfrastructure), ("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 classifies PlotDirector markers by tag only", WordManuscriptCleanupClassifiesPlotDirectorMarkersByTagOnly),
("Word manuscript cleanup preserves content controls content", WordManuscriptCleanupPreservesContentControlContent), ("Word manuscript cleanup preserves content controls content", WordManuscriptCleanupPreservesContentControlContent),
("Word manuscript cleanup UI separates unlink from clean unlink", WordManuscriptCleanupUiSeparatesUnlinkFromCleanUnlink), ("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."); 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() static void WordManuscriptCleanupClassifiesPlotDirectorMarkersByTagOnly()
{ {
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");

View File

@ -10,6 +10,7 @@ namespace PlotLine.Hubs;
[Authorize] [Authorize]
public sealed class WordCompanionFollowHub( public sealed class WordCompanionFollowHub(
IWordCompanionPresenceService presence, IWordCompanionPresenceService presence,
IWritingWorkspacePresenceService writingPresence,
IOnboardingRepository onboarding, IOnboardingRepository onboarding,
IManuscriptScanPreviewStore scanStore, IManuscriptScanPreviewStore scanStore,
IOnboardingService onboardingService) : Hub IOnboardingService onboardingService) : Hub
@ -41,6 +42,7 @@ public sealed class WordCompanionFollowHub(
var status = await presence.RegisterAsync(userId, Context.ConnectionId, registration); var status = await presence.RegisterAsync(userId, Context.ConnectionId, registration);
await Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status); await Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status);
await BroadcastWritingWorkspaceStatusAsync(userId, registration.LinkedProjectID, registration.LinkedBookID);
return status; return status;
} }
@ -49,9 +51,51 @@ public sealed class WordCompanionFollowHub(
var userId = RequireUserId(); var userId = RequireUserId();
var status = await presence.HeartbeatAsync(userId, Context.ConnectionId, heartbeat); var status = await presence.HeartbeatAsync(userId, Context.ConnectionId, heartbeat);
await Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status); await Clients.Group(PresenceGroup(userId)).SendAsync("WordCompanionPresenceChanged", status);
await BroadcastWritingWorkspaceStatusAsync(userId, heartbeat.LinkedProjectID, heartbeat.LinkedBookID);
return status; 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() public async Task<ManuscriptScanState> StartOnboardingManuscriptScan()
{ {
var userId = RequireUserId(); var userId = RequireUserId();
@ -163,10 +207,13 @@ public sealed class WordCompanionFollowHub(
public override async Task OnDisconnectedAsync(Exception? exception) public override async Task OnDisconnectedAsync(Exception? exception)
{ {
int? disconnectedUserId = null;
var status = await presence.DisconnectAsync(Context.ConnectionId); var status = await presence.DisconnectAsync(Context.ConnectionId);
if (status is not null) if (status is not null)
{ {
disconnectedUserId = status.UserID;
await Clients.Group(PresenceGroup(status.UserID)).SendAsync("WordCompanionPresenceChanged", status); await Clients.Group(PresenceGroup(status.UserID)).SendAsync("WordCompanionPresenceChanged", status);
await BroadcastWritingWorkspaceStatusAsync(status.UserID, status.LinkedProjectID, status.LinkedBookID);
var failedScans = await scanStore.FailRunningForUserAsync( var failedScans = await scanStore.FailRunningForUserAsync(
status.UserID, status.UserID,
"The Word Companion disconnected before the scan finished. Reopen Word and try again."); "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); 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() private int RequireUserId()
=> TryGetUserId(out var userId) ? userId : throw new HubException("Sign in to use the Word Companion."); => 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<IImportService, ImportService>();
builder.Services.AddScoped<IWordCompanionService, WordCompanionService>(); builder.Services.AddScoped<IWordCompanionService, WordCompanionService>();
builder.Services.AddSingleton<IWordCompanionPresenceService, WordCompanionPresenceService>(); builder.Services.AddSingleton<IWordCompanionPresenceService, WordCompanionPresenceService>();
builder.Services.AddSingleton<IWritingWorkspacePresenceService, WritingWorkspacePresenceService>();
builder.Services.AddSingleton<IManuscriptScanPreviewStore, ManuscriptScanPreviewStore>(); builder.Services.AddSingleton<IManuscriptScanPreviewStore, ManuscriptScanPreviewStore>();
builder.Services.AddSingleton<IOnboardingStoryIntelligenceBatchStore, OnboardingStoryIntelligenceBatchStore>(); builder.Services.AddSingleton<IOnboardingStoryIntelligenceBatchStore, OnboardingStoryIntelligenceBatchStore>();
builder.Services.AddHostedService<WordCompanionPresenceMonitor>(); builder.Services.AddHostedService<WordCompanionPresenceMonitor>();

View File

@ -1,10 +1,12 @@
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using PlotLine.Hubs; using PlotLine.Hubs;
using PlotLine.Models;
namespace PlotLine.Services; namespace PlotLine.Services;
public sealed class WordCompanionPresenceMonitor( public sealed class WordCompanionPresenceMonitor(
IWordCompanionPresenceService presence, IWordCompanionPresenceService presence,
IWritingWorkspacePresenceService writingPresence,
IManuscriptScanPreviewStore scanStore, IManuscriptScanPreviewStore scanStore,
IHubContext<WordCompanionFollowHub> hub) : BackgroundService IHubContext<WordCompanionFollowHub> hub) : BackgroundService
{ {
@ -21,6 +23,14 @@ public sealed class WordCompanionPresenceMonitor(
await hub.Clients await hub.Clients
.Group(PresenceGroup(status.UserID)) .Group(PresenceGroup(status.UserID))
.SendAsync("WordCompanionPresenceChanged", status, stoppingToken); .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( var failedScans = await scanStore.FailRunningForUserAsync(
status.UserID, status.UserID,
"The Word Companion disconnected before the scan finished. Reopen Word and try again."); "The Word Companion disconnected before the scan finished. Reopen Word and try again.");
@ -29,10 +39,18 @@ public sealed class WordCompanionPresenceMonitor(
await hub.Clients await hub.Clients
.Group(PresenceGroup(status.UserID)) .Group(PresenceGroup(status.UserID))
.SendAsync("OnboardingScanFailed", failedScan, stoppingToken); .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) 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> <p>Linked to PlotDirector</p>
</section> </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"> <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" class="is-active" data-tab-button="scene" aria-selected="true">Scene</button>
<button type="button" data-tab-button="review" aria-selected="false">Review</button> <button type="button" data-tab-button="review" aria-selected="false">Review</button>

View File

@ -3,6 +3,14 @@
ViewData["Title"] = "Writer Workspace"; ViewData["Title"] = "Writer Workspace";
var message = TempData["WriterWorkspaceMessage"] as string; var message = TempData["WriterWorkspaceMessage"] as string;
var error = TempData["WriterWorkspaceError"] 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"> <div class="page-heading writer-dashboard-heading">
@ -42,6 +50,35 @@
</div> </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"> <section class="story-bible-section">
<div class="story-bible-section-heading"> <div class="story-bible-section-heading">
<div> <div>
@ -274,3 +311,8 @@
} }
</div> </div>
</section> </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 class="writing-context-status-dot" aria-hidden="true"></span>
<span data-writing-context-status-text>@(Model.Display?.FollowStatus ?? "Connected, waiting for scene")</span> <span data-writing-context-status-text>@(Model.Display?.FollowStatus ?? "Connected, waiting for scene")</span>
</div> </div>
<div class="writing-workspace-role-indicator">
<span class="writing-context-status-dot" aria-hidden="true"></span>
<span>Writing Workspace</span>
</div>
</header> </header>
<section data-writing-context-content> <section data-writing-context-content>
@ -27,6 +31,15 @@
</main> </main>
@section Scripts { @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-follow.js" asp-append-version="true"></script>
<script src="~/js/writing-context.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 class="writing-context-status-dot" aria-hidden="true"></span>
<span data-writing-intent-status-text>@(Model.Display?.FollowStatus ?? "Connected, waiting for scene")</span> <span data-writing-intent-status-text>@(Model.Display?.FollowStatus ?? "Connected, waiting for scene")</span>
</div> </div>
<div class="writing-workspace-role-indicator">
<span class="writing-context-status-dot" aria-hidden="true"></span>
<span>Writing Workspace</span>
</div>
</header> </header>
<section data-writing-intent-content> <section data-writing-intent-content>
@ -27,6 +31,15 @@
</main> </main>
@section Scripts { @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-follow.js" asp-append-version="true"></script>
<script src="~/js/writing-intent.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); 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 { .writer-queue-card {
border: 1px solid var(--plotline-line); border: 1px solid var(--plotline-line);
border-radius: 12px; border-radius: 12px;
@ -4659,6 +4710,10 @@ body.dragging-location [data-drag-type="location"] {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.writing-workspace-status-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.writer-checklist-row, .writer-checklist-row,
.writer-add-inline { .writer-add-inline {
grid-template-columns: 1fr; 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; font-weight: 800;
} }
.word-companion-writing-workspace .word-companion-actions {
grid-template-columns: 1fr;
}
.word-companion-message:empty { .word-companion-message:empty {
display: none; 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 unlinkDialogSceneCount = document.querySelector("[data-unlink-dialog-scene-count]");
const cleanDialogChapterCount = document.querySelector("[data-clean-dialog-chapter-count]"); const cleanDialogChapterCount = document.querySelector("[data-clean-dialog-chapter-count]");
const cleanDialogSceneCount = document.querySelector("[data-clean-dialog-scene-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 plotDirectorSceneDetails = document.querySelector("[data-plotdirector-scene-details]");
const plotDirectorSceneTitle = document.querySelector("[data-plotdirector-scene-title]"); const plotDirectorSceneTitle = document.querySelector("[data-plotdirector-scene-title]");
const plotDirectorRevisionStatus = document.querySelector("[data-plotdirector-revision-status]"); const plotDirectorRevisionStatus = document.querySelector("[data-plotdirector-revision-status]");
@ -1349,6 +1352,45 @@
return Number.isInteger(bookId) && bookId > 0 ? bookId : null; 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 currentDocumentName = () => {
const url = String(window.Office?.context?.document?.url || "").trim(); const url = String(window.Office?.context?.document?.url || "").trim();
if (!url) { if (!url) {
@ -5988,6 +6030,9 @@
attachPlotDirectorIdsButton?.addEventListener("click", attachPlotDirectorIds); attachPlotDirectorIdsButton?.addEventListener("click", attachPlotDirectorIds);
unlinkWordDocumentButton?.addEventListener("click", unlinkThisWordDocument); unlinkWordDocumentButton?.addEventListener("click", unlinkThisWordDocument);
cleanUnlinkWordDocumentButton?.addEventListener("click", cleanAndUnlinkWordDocument); cleanUnlinkWordDocumentButton?.addEventListener("click", cleanAndUnlinkWordDocument);
openWritingWorkspaceButton?.addEventListener("click", () => openWritingWindow("workspace"));
openWritingContextButton?.addEventListener("click", () => openWritingWindow("context"));
openWritingIntentButton?.addEventListener("click", () => openWritingWindow("intent"));
createPlotDirectorChapterButton?.addEventListener("click", createPlotDirectorChapter); createPlotDirectorChapterButton?.addEventListener("click", createPlotDirectorChapter);
linkExistingChapterButton?.addEventListener("click", openLinkExistingChapterDialog); linkExistingChapterButton?.addEventListener("click", openLinkExistingChapterDialog);
linkChapterSearch?.addEventListener("input", renderLinkChapterOptions); 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 = () => { const readStoredTab = () => {
try { try {
return localStorage.getItem(config.storageKey) || config.defaultTab; return localStorage.getItem(config.storageKey) || config.defaultTab;
@ -87,6 +106,7 @@
if (fragmentStatus) { if (fragmentStatus) {
setStatus(fragmentStatus); setStatus(fragmentStatus);
} }
registerWorkspaceRole();
const fragmentSceneId = parsePositiveInt(fragment?.dataset.sceneId); const fragmentSceneId = parsePositiveInt(fragment?.dataset.sceneId);
if (fragmentSceneId) { 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
};
})();